1use super::*;
2use acp_thread::StubAgentConnection;
3use agent::ThreadStore;
4use agent_ui::{
5 test_support::{active_session_id, open_thread_with_connection, send_message},
6 thread_metadata_store::ThreadMetadata,
7};
8use chrono::DateTime;
9use feature_flags::FeatureFlagAppExt as _;
10use fs::FakeFs;
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;
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 cx.update_flags(false, vec!["agent-v2".into()]);
28 ThreadStore::init_global(cx);
29 ThreadMetadataStore::init_global(cx);
30 language_model::LanguageModelRegistry::test(cx);
31 prompt_store::init(cx);
32 });
33}
34
35#[track_caller]
36fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
37 assert!(
38 sidebar
39 .active_entry
40 .as_ref()
41 .is_some_and(|e| e.is_active_thread(session_id)),
42 "{msg}: expected active_entry to be Thread({session_id:?}), got {:?}",
43 sidebar.active_entry,
44 );
45}
46
47#[track_caller]
48fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
49 assert!(
50 matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == workspace),
51 "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
52 workspace.entity_id(),
53 sidebar.active_entry,
54 );
55}
56
57fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
58 sidebar
59 .contents
60 .entries
61 .iter()
62 .any(|entry| matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id))
63}
64
65async fn init_test_project(
66 worktree_path: &str,
67 cx: &mut TestAppContext,
68) -> Entity<project::Project> {
69 init_test(cx);
70 let fs = FakeFs::new(cx.executor());
71 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
72 .await;
73 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
74 project::Project::test(fs, [worktree_path.as_ref()], cx).await
75}
76
77fn setup_sidebar(
78 multi_workspace: &Entity<MultiWorkspace>,
79 cx: &mut gpui::VisualTestContext,
80) -> Entity<Sidebar> {
81 let sidebar = setup_sidebar_closed(multi_workspace, cx);
82 multi_workspace.update_in(cx, |mw, window, cx| {
83 mw.toggle_sidebar(window, cx);
84 });
85 cx.run_until_parked();
86 sidebar
87}
88
89fn setup_sidebar_closed(
90 multi_workspace: &Entity<MultiWorkspace>,
91 cx: &mut gpui::VisualTestContext,
92) -> Entity<Sidebar> {
93 let multi_workspace = multi_workspace.clone();
94 let sidebar =
95 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
96 multi_workspace.update(cx, |mw, cx| {
97 mw.register_sidebar(sidebar.clone(), cx);
98 });
99 cx.run_until_parked();
100 sidebar
101}
102
103async fn save_n_test_threads(
104 count: u32,
105 project: &Entity<project::Project>,
106 cx: &mut gpui::VisualTestContext,
107) {
108 for i in 0..count {
109 save_thread_metadata(
110 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
111 format!("Thread {}", i + 1).into(),
112 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
113 None,
114 project,
115 cx,
116 )
117 }
118 cx.run_until_parked();
119}
120
121async fn save_test_thread_metadata(
122 session_id: &acp::SessionId,
123 project: &Entity<project::Project>,
124 cx: &mut TestAppContext,
125) {
126 save_thread_metadata(
127 session_id.clone(),
128 "Test".into(),
129 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
130 None,
131 project,
132 cx,
133 )
134}
135
136async fn save_named_thread_metadata(
137 session_id: &str,
138 title: &str,
139 project: &Entity<project::Project>,
140 cx: &mut gpui::VisualTestContext,
141) {
142 save_thread_metadata(
143 acp::SessionId::new(Arc::from(session_id)),
144 SharedString::from(title.to_string()),
145 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
146 None,
147 project,
148 cx,
149 );
150 cx.run_until_parked();
151}
152
153fn save_thread_metadata(
154 session_id: acp::SessionId,
155 title: SharedString,
156 updated_at: DateTime<Utc>,
157 created_at: Option<DateTime<Utc>>,
158 project: &Entity<project::Project>,
159 cx: &mut TestAppContext,
160) {
161 cx.update(|cx| {
162 let (folder_paths, main_worktree_paths) = {
163 let project_ref = project.read(cx);
164 let paths: Vec<Arc<Path>> = project_ref
165 .visible_worktrees(cx)
166 .map(|worktree| worktree.read(cx).abs_path())
167 .collect();
168 let folder_paths = PathList::new(&paths);
169 let main_worktree_paths = project_ref.project_group_key(cx).path_list().clone();
170 (folder_paths, main_worktree_paths)
171 };
172 let metadata = ThreadMetadata {
173 session_id,
174 agent_id: agent::ZED_AGENT_ID.clone(),
175 title,
176 updated_at,
177 created_at,
178 folder_paths,
179 main_worktree_paths,
180 archived: false,
181 };
182 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
183 });
184 cx.run_until_parked();
185}
186
187fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
188 sidebar.update_in(cx, |_, window, cx| {
189 cx.focus_self(window);
190 });
191 cx.run_until_parked();
192}
193
194fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
195 let mut seen = Vec::new();
196 let mut chips = Vec::new();
197 for wt in worktrees {
198 if wt.kind == ui::WorktreeKind::Main {
199 continue;
200 }
201 if !seen.contains(&wt.name) {
202 seen.push(wt.name.clone());
203 chips.push(format!("{{{}}}", wt.name));
204 }
205 }
206 if chips.is_empty() {
207 String::new()
208 } else {
209 format!(" {}", chips.join(", "))
210 }
211}
212
213fn visible_entries_as_strings(
214 sidebar: &Entity<Sidebar>,
215 cx: &mut gpui::VisualTestContext,
216) -> Vec<String> {
217 sidebar.read_with(cx, |sidebar, _cx| {
218 sidebar
219 .contents
220 .entries
221 .iter()
222 .enumerate()
223 .map(|(ix, entry)| {
224 let selected = if sidebar.selection == Some(ix) {
225 " <== selected"
226 } else {
227 ""
228 };
229 match entry {
230 ListEntry::ProjectHeader {
231 label,
232 key,
233 highlight_positions: _,
234 ..
235 } => {
236 let icon = if sidebar.collapsed_groups.contains(key.path_list()) {
237 ">"
238 } else {
239 "v"
240 };
241 format!("{} [{}]{}", icon, label, selected)
242 }
243 ListEntry::Thread(thread) => {
244 let title = thread.metadata.title.as_ref();
245 let active = if thread.is_live { " *" } else { "" };
246 let status_str = match thread.status {
247 AgentThreadStatus::Running => " (running)",
248 AgentThreadStatus::Error => " (error)",
249 AgentThreadStatus::WaitingForConfirmation => " (waiting)",
250 _ => "",
251 };
252 let notified = if sidebar
253 .contents
254 .is_thread_notified(&thread.metadata.session_id)
255 {
256 " (!)"
257 } else {
258 ""
259 };
260 let worktree = format_linked_worktree_chips(&thread.worktrees);
261 format!(" {title}{worktree}{active}{status_str}{notified}{selected}")
262 }
263 ListEntry::ViewMore {
264 is_fully_expanded, ..
265 } => {
266 if *is_fully_expanded {
267 format!(" - Collapse{}", selected)
268 } else {
269 format!(" + View More{}", selected)
270 }
271 }
272 ListEntry::DraftThread { worktrees, .. } => {
273 let worktree = format_linked_worktree_chips(worktrees);
274 format!(" [~ Draft{}]{}", worktree, selected)
275 }
276 ListEntry::NewThread { worktrees, .. } => {
277 let worktree = format_linked_worktree_chips(worktrees);
278 format!(" [+ New Thread{}]{}", worktree, selected)
279 }
280 }
281 })
282 .collect()
283 })
284}
285
286#[gpui::test]
287async fn test_serialization_round_trip(cx: &mut TestAppContext) {
288 let project = init_test_project("/my-project", cx).await;
289 let (multi_workspace, cx) =
290 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
291 let sidebar = setup_sidebar(&multi_workspace, cx);
292
293 save_n_test_threads(3, &project, cx).await;
294
295 let path_list = project.read_with(cx, |project, cx| {
296 project.project_group_key(cx).path_list().clone()
297 });
298
299 // Set a custom width, collapse the group, and expand "View More".
300 sidebar.update_in(cx, |sidebar, window, cx| {
301 sidebar.set_width(Some(px(420.0)), cx);
302 sidebar.toggle_collapse(&path_list, window, cx);
303 sidebar.expanded_groups.insert(path_list.clone(), 2);
304 });
305 cx.run_until_parked();
306
307 // Capture the serialized state from the first sidebar.
308 let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
309 let serialized = serialized.expect("serialized_state should return Some");
310
311 // Create a fresh sidebar and restore into it.
312 let sidebar2 =
313 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
314 cx.run_until_parked();
315
316 sidebar2.update_in(cx, |sidebar, window, cx| {
317 sidebar.restore_serialized_state(&serialized, window, cx);
318 });
319 cx.run_until_parked();
320
321 // Assert all serialized fields match.
322 let (width1, collapsed1, expanded1) = sidebar.read_with(cx, |s, _| {
323 (
324 s.width,
325 s.collapsed_groups.clone(),
326 s.expanded_groups.clone(),
327 )
328 });
329 let (width2, collapsed2, expanded2) = sidebar2.read_with(cx, |s, _| {
330 (
331 s.width,
332 s.collapsed_groups.clone(),
333 s.expanded_groups.clone(),
334 )
335 });
336
337 assert_eq!(width1, width2);
338 assert_eq!(collapsed1, collapsed2);
339 assert_eq!(expanded1, expanded2);
340 assert_eq!(width1, px(420.0));
341 assert!(collapsed1.contains(&path_list));
342 assert_eq!(expanded1.get(&path_list), Some(&2));
343}
344
345#[gpui::test]
346async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
347 // A regression test to ensure that restoring a serialized archive view does not panic.
348 let project = init_test_project_with_agent_panel("/my-project", cx).await;
349 let (multi_workspace, cx) =
350 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
351 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
352 cx.update(|_window, cx| {
353 AgentRegistryStore::init_test_global(cx, vec![]);
354 });
355
356 let serialized = serde_json::to_string(&SerializedSidebar {
357 width: Some(400.0),
358 collapsed_groups: Vec::new(),
359 expanded_groups: Vec::new(),
360 active_view: SerializedSidebarView::Archive,
361 })
362 .expect("serialization should succeed");
363
364 multi_workspace.update_in(cx, |multi_workspace, window, cx| {
365 if let Some(sidebar) = multi_workspace.sidebar() {
366 sidebar.restore_serialized_state(&serialized, window, cx);
367 }
368 });
369 cx.run_until_parked();
370
371 // After the deferred `show_archive` runs, the view should be Archive.
372 sidebar.read_with(cx, |sidebar, _cx| {
373 assert!(
374 matches!(sidebar.view, SidebarView::Archive(_)),
375 "expected sidebar view to be Archive after restore, got ThreadList"
376 );
377 });
378}
379
380#[test]
381fn test_clean_mention_links() {
382 // Simple mention link
383 assert_eq!(
384 Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
385 "check @Button.tsx"
386 );
387
388 // Multiple mention links
389 assert_eq!(
390 Sidebar::clean_mention_links(
391 "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
392 ),
393 "look at @foo.rs and @bar.rs"
394 );
395
396 // No mention links — passthrough
397 assert_eq!(
398 Sidebar::clean_mention_links("plain text with no mentions"),
399 "plain text with no mentions"
400 );
401
402 // Incomplete link syntax — preserved as-is
403 assert_eq!(
404 Sidebar::clean_mention_links("broken [@mention without closing"),
405 "broken [@mention without closing"
406 );
407
408 // Regular markdown link (no @) — not touched
409 assert_eq!(
410 Sidebar::clean_mention_links("see [docs](https://example.com)"),
411 "see [docs](https://example.com)"
412 );
413
414 // Empty input
415 assert_eq!(Sidebar::clean_mention_links(""), "");
416}
417
418#[gpui::test]
419async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
420 let project = init_test_project("/my-project", cx).await;
421 let (multi_workspace, cx) =
422 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
423 let sidebar = setup_sidebar(&multi_workspace, cx);
424
425 let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
426 let weak_sidebar = sidebar.downgrade();
427 let weak_multi_workspace = multi_workspace.downgrade();
428
429 drop(sidebar);
430 drop(multi_workspace);
431 cx.update(|window, _cx| window.remove_window());
432 cx.run_until_parked();
433
434 weak_multi_workspace.assert_released();
435 weak_sidebar.assert_released();
436 weak_workspace.assert_released();
437}
438
439#[gpui::test]
440async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
441 let project = init_test_project("/my-project", cx).await;
442 let (multi_workspace, cx) =
443 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
444 let sidebar = setup_sidebar(&multi_workspace, cx);
445
446 assert_eq!(
447 visible_entries_as_strings(&sidebar, cx),
448 vec!["v [my-project]", " [+ New Thread]"]
449 );
450}
451
452#[gpui::test]
453async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
454 let project = init_test_project("/my-project", cx).await;
455 let (multi_workspace, cx) =
456 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
457 let sidebar = setup_sidebar(&multi_workspace, cx);
458
459 save_thread_metadata(
460 acp::SessionId::new(Arc::from("thread-1")),
461 "Fix crash in project panel".into(),
462 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
463 None,
464 &project,
465 cx,
466 );
467
468 save_thread_metadata(
469 acp::SessionId::new(Arc::from("thread-2")),
470 "Add inline diff view".into(),
471 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
472 None,
473 &project,
474 cx,
475 );
476 cx.run_until_parked();
477
478 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
479 cx.run_until_parked();
480
481 assert_eq!(
482 visible_entries_as_strings(&sidebar, cx),
483 vec![
484 "v [my-project]",
485 " Fix crash in project panel",
486 " Add inline diff view",
487 ]
488 );
489}
490
491#[gpui::test]
492async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
493 let project = init_test_project("/project-a", cx).await;
494 let (multi_workspace, cx) =
495 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
496 let sidebar = setup_sidebar(&multi_workspace, cx);
497
498 // Single workspace with a thread
499 save_thread_metadata(
500 acp::SessionId::new(Arc::from("thread-a1")),
501 "Thread A1".into(),
502 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
503 None,
504 &project,
505 cx,
506 );
507 cx.run_until_parked();
508
509 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
510 cx.run_until_parked();
511
512 assert_eq!(
513 visible_entries_as_strings(&sidebar, cx),
514 vec!["v [project-a]", " Thread A1"]
515 );
516
517 // Add a second workspace
518 multi_workspace.update_in(cx, |mw, window, cx| {
519 mw.create_test_workspace(window, cx).detach();
520 });
521 cx.run_until_parked();
522
523 assert_eq!(
524 visible_entries_as_strings(&sidebar, cx),
525 vec!["v [project-a]", " Thread A1",]
526 );
527
528 // Remove the second workspace
529 multi_workspace.update_in(cx, |mw, window, cx| {
530 let workspace = mw.workspaces().nth(1).cloned().unwrap();
531 mw.remove(&workspace, window, cx);
532 });
533 cx.run_until_parked();
534
535 assert_eq!(
536 visible_entries_as_strings(&sidebar, cx),
537 vec!["v [project-a]", " Thread A1"]
538 );
539}
540
541#[gpui::test]
542async fn test_view_more_pagination(cx: &mut TestAppContext) {
543 let project = init_test_project("/my-project", cx).await;
544 let (multi_workspace, cx) =
545 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
546 let sidebar = setup_sidebar(&multi_workspace, cx);
547
548 save_n_test_threads(12, &project, cx).await;
549
550 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
551 cx.run_until_parked();
552
553 assert_eq!(
554 visible_entries_as_strings(&sidebar, cx),
555 vec![
556 "v [my-project]",
557 " Thread 12",
558 " Thread 11",
559 " Thread 10",
560 " Thread 9",
561 " Thread 8",
562 " + View More",
563 ]
564 );
565}
566
567#[gpui::test]
568async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
569 let project = init_test_project("/my-project", cx).await;
570 let (multi_workspace, cx) =
571 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
572 let sidebar = setup_sidebar(&multi_workspace, cx);
573
574 // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
575 save_n_test_threads(17, &project, cx).await;
576
577 let path_list = project.read_with(cx, |project, cx| {
578 project.project_group_key(cx).path_list().clone()
579 });
580
581 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
582 cx.run_until_parked();
583
584 // Initially shows 5 threads + View More
585 let entries = visible_entries_as_strings(&sidebar, cx);
586 assert_eq!(entries.len(), 7); // header + 5 threads + View More
587 assert!(entries.iter().any(|e| e.contains("View More")));
588
589 // Focus and navigate to View More, then confirm to expand by one batch
590 focus_sidebar(&sidebar, cx);
591 for _ in 0..7 {
592 cx.dispatch_action(SelectNext);
593 }
594 cx.dispatch_action(Confirm);
595 cx.run_until_parked();
596
597 // Now shows 10 threads + View More
598 let entries = visible_entries_as_strings(&sidebar, cx);
599 assert_eq!(entries.len(), 12); // header + 10 threads + View More
600 assert!(entries.iter().any(|e| e.contains("View More")));
601
602 // Expand again by one batch
603 sidebar.update_in(cx, |s, _window, cx| {
604 let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
605 s.expanded_groups.insert(path_list.clone(), current + 1);
606 s.update_entries(cx);
607 });
608 cx.run_until_parked();
609
610 // Now shows 15 threads + View More
611 let entries = visible_entries_as_strings(&sidebar, cx);
612 assert_eq!(entries.len(), 17); // header + 15 threads + View More
613 assert!(entries.iter().any(|e| e.contains("View More")));
614
615 // Expand one more time - should show all 17 threads with Collapse button
616 sidebar.update_in(cx, |s, _window, cx| {
617 let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
618 s.expanded_groups.insert(path_list.clone(), current + 1);
619 s.update_entries(cx);
620 });
621 cx.run_until_parked();
622
623 // All 17 threads shown with Collapse button
624 let entries = visible_entries_as_strings(&sidebar, cx);
625 assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
626 assert!(!entries.iter().any(|e| e.contains("View More")));
627 assert!(entries.iter().any(|e| e.contains("Collapse")));
628
629 // Click collapse - should go back to showing 5 threads
630 sidebar.update_in(cx, |s, _window, cx| {
631 s.expanded_groups.remove(&path_list);
632 s.update_entries(cx);
633 });
634 cx.run_until_parked();
635
636 // Back to initial state: 5 threads + View More
637 let entries = visible_entries_as_strings(&sidebar, cx);
638 assert_eq!(entries.len(), 7); // header + 5 threads + View More
639 assert!(entries.iter().any(|e| e.contains("View More")));
640}
641
642#[gpui::test]
643async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
644 let project = init_test_project("/my-project", cx).await;
645 let (multi_workspace, cx) =
646 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
647 let sidebar = setup_sidebar(&multi_workspace, cx);
648
649 save_n_test_threads(1, &project, cx).await;
650
651 let path_list = project.read_with(cx, |project, cx| {
652 project.project_group_key(cx).path_list().clone()
653 });
654
655 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
656 cx.run_until_parked();
657
658 assert_eq!(
659 visible_entries_as_strings(&sidebar, cx),
660 vec!["v [my-project]", " Thread 1"]
661 );
662
663 // Collapse
664 sidebar.update_in(cx, |s, window, cx| {
665 s.toggle_collapse(&path_list, window, cx);
666 });
667 cx.run_until_parked();
668
669 assert_eq!(
670 visible_entries_as_strings(&sidebar, cx),
671 vec!["> [my-project]"]
672 );
673
674 // Expand
675 sidebar.update_in(cx, |s, window, cx| {
676 s.toggle_collapse(&path_list, window, cx);
677 });
678 cx.run_until_parked();
679
680 assert_eq!(
681 visible_entries_as_strings(&sidebar, cx),
682 vec!["v [my-project]", " Thread 1"]
683 );
684}
685
686#[gpui::test]
687async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
688 let project = init_test_project("/my-project", cx).await;
689 let (multi_workspace, cx) =
690 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
691 let sidebar = setup_sidebar(&multi_workspace, cx);
692
693 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
694 let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
695 let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
696
697 sidebar.update_in(cx, |s, _window, _cx| {
698 s.collapsed_groups.insert(collapsed_path.clone());
699 s.contents
700 .notified_threads
701 .insert(acp::SessionId::new(Arc::from("t-5")));
702 s.contents.entries = vec![
703 // Expanded project header
704 ListEntry::ProjectHeader {
705 key: project::ProjectGroupKey::new(None, expanded_path.clone()),
706 label: "expanded-project".into(),
707 highlight_positions: Vec::new(),
708 has_running_threads: false,
709 waiting_thread_count: 0,
710 is_active: true,
711 },
712 ListEntry::Thread(ThreadEntry {
713 metadata: ThreadMetadata {
714 session_id: acp::SessionId::new(Arc::from("t-1")),
715 agent_id: AgentId::new("zed-agent"),
716 folder_paths: PathList::default(),
717 main_worktree_paths: PathList::default(),
718 title: "Completed thread".into(),
719 updated_at: Utc::now(),
720 created_at: Some(Utc::now()),
721 archived: false,
722 },
723 icon: IconName::ZedAgent,
724 icon_from_external_svg: None,
725 status: AgentThreadStatus::Completed,
726 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
727 is_live: false,
728 is_background: false,
729 is_title_generating: false,
730 highlight_positions: Vec::new(),
731 worktrees: Vec::new(),
732 diff_stats: DiffStats::default(),
733 }),
734 // Active thread with Running status
735 ListEntry::Thread(ThreadEntry {
736 metadata: ThreadMetadata {
737 session_id: acp::SessionId::new(Arc::from("t-2")),
738 agent_id: AgentId::new("zed-agent"),
739 folder_paths: PathList::default(),
740 main_worktree_paths: PathList::default(),
741 title: "Running thread".into(),
742 updated_at: Utc::now(),
743 created_at: Some(Utc::now()),
744 archived: false,
745 },
746 icon: IconName::ZedAgent,
747 icon_from_external_svg: None,
748 status: AgentThreadStatus::Running,
749 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
750 is_live: true,
751 is_background: false,
752 is_title_generating: false,
753 highlight_positions: Vec::new(),
754 worktrees: Vec::new(),
755 diff_stats: DiffStats::default(),
756 }),
757 // Active thread with Error status
758 ListEntry::Thread(ThreadEntry {
759 metadata: ThreadMetadata {
760 session_id: acp::SessionId::new(Arc::from("t-3")),
761 agent_id: AgentId::new("zed-agent"),
762 folder_paths: PathList::default(),
763 main_worktree_paths: PathList::default(),
764 title: "Error thread".into(),
765 updated_at: Utc::now(),
766 created_at: Some(Utc::now()),
767 archived: false,
768 },
769 icon: IconName::ZedAgent,
770 icon_from_external_svg: None,
771 status: AgentThreadStatus::Error,
772 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
773 is_live: true,
774 is_background: false,
775 is_title_generating: false,
776 highlight_positions: Vec::new(),
777 worktrees: Vec::new(),
778 diff_stats: DiffStats::default(),
779 }),
780 // Thread with WaitingForConfirmation status, not active
781 ListEntry::Thread(ThreadEntry {
782 metadata: ThreadMetadata {
783 session_id: acp::SessionId::new(Arc::from("t-4")),
784 agent_id: AgentId::new("zed-agent"),
785 folder_paths: PathList::default(),
786 main_worktree_paths: PathList::default(),
787 title: "Waiting thread".into(),
788 updated_at: Utc::now(),
789 created_at: Some(Utc::now()),
790 archived: false,
791 },
792 icon: IconName::ZedAgent,
793 icon_from_external_svg: None,
794 status: AgentThreadStatus::WaitingForConfirmation,
795 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
796 is_live: false,
797 is_background: false,
798 is_title_generating: false,
799 highlight_positions: Vec::new(),
800 worktrees: Vec::new(),
801 diff_stats: DiffStats::default(),
802 }),
803 // Background thread that completed (should show notification)
804 ListEntry::Thread(ThreadEntry {
805 metadata: ThreadMetadata {
806 session_id: acp::SessionId::new(Arc::from("t-5")),
807 agent_id: AgentId::new("zed-agent"),
808 folder_paths: PathList::default(),
809 main_worktree_paths: PathList::default(),
810 title: "Notified thread".into(),
811 updated_at: Utc::now(),
812 created_at: Some(Utc::now()),
813 archived: false,
814 },
815 icon: IconName::ZedAgent,
816 icon_from_external_svg: None,
817 status: AgentThreadStatus::Completed,
818 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
819 is_live: true,
820 is_background: true,
821 is_title_generating: false,
822 highlight_positions: Vec::new(),
823 worktrees: Vec::new(),
824 diff_stats: DiffStats::default(),
825 }),
826 // View More entry
827 ListEntry::ViewMore {
828 key: project::ProjectGroupKey::new(None, expanded_path.clone()),
829 is_fully_expanded: false,
830 },
831 // Collapsed project header
832 ListEntry::ProjectHeader {
833 key: project::ProjectGroupKey::new(None, collapsed_path.clone()),
834 label: "collapsed-project".into(),
835 highlight_positions: Vec::new(),
836 has_running_threads: false,
837 waiting_thread_count: 0,
838 is_active: false,
839 },
840 ];
841
842 // Select the Running thread (index 2)
843 s.selection = Some(2);
844 });
845
846 assert_eq!(
847 visible_entries_as_strings(&sidebar, cx),
848 vec![
849 "v [expanded-project]",
850 " Completed thread",
851 " Running thread * (running) <== selected",
852 " Error thread * (error)",
853 " Waiting thread (waiting)",
854 " Notified thread * (!)",
855 " + View More",
856 "> [collapsed-project]",
857 ]
858 );
859
860 // Move selection to the collapsed header
861 sidebar.update_in(cx, |s, _window, _cx| {
862 s.selection = Some(7);
863 });
864
865 assert_eq!(
866 visible_entries_as_strings(&sidebar, cx).last().cloned(),
867 Some("> [collapsed-project] <== selected".to_string()),
868 );
869
870 // Clear selection
871 sidebar.update_in(cx, |s, _window, _cx| {
872 s.selection = None;
873 });
874
875 // No entry should have the selected marker
876 let entries = visible_entries_as_strings(&sidebar, cx);
877 for entry in &entries {
878 assert!(
879 !entry.contains("<== selected"),
880 "unexpected selection marker in: {}",
881 entry
882 );
883 }
884}
885
886#[gpui::test]
887async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
888 let project = init_test_project("/my-project", cx).await;
889 let (multi_workspace, cx) =
890 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
891 let sidebar = setup_sidebar(&multi_workspace, cx);
892
893 save_n_test_threads(3, &project, cx).await;
894
895 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
896 cx.run_until_parked();
897
898 // Entries: [header, thread3, thread2, thread1]
899 // Focusing the sidebar does not set a selection; select_next/select_previous
900 // handle None gracefully by starting from the first or last entry.
901 focus_sidebar(&sidebar, cx);
902 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
903
904 // First SelectNext from None starts at index 0
905 cx.dispatch_action(SelectNext);
906 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
907
908 // Move down through remaining entries
909 cx.dispatch_action(SelectNext);
910 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
911
912 cx.dispatch_action(SelectNext);
913 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
914
915 cx.dispatch_action(SelectNext);
916 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
917
918 // At the end, wraps back to first entry
919 cx.dispatch_action(SelectNext);
920 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
921
922 // Navigate back to the end
923 cx.dispatch_action(SelectNext);
924 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
925 cx.dispatch_action(SelectNext);
926 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
927 cx.dispatch_action(SelectNext);
928 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
929
930 // Move back up
931 cx.dispatch_action(SelectPrevious);
932 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
933
934 cx.dispatch_action(SelectPrevious);
935 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
936
937 cx.dispatch_action(SelectPrevious);
938 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
939
940 // At the top, selection clears (focus returns to editor)
941 cx.dispatch_action(SelectPrevious);
942 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
943}
944
945#[gpui::test]
946async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
947 let project = init_test_project("/my-project", cx).await;
948 let (multi_workspace, cx) =
949 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
950 let sidebar = setup_sidebar(&multi_workspace, cx);
951
952 save_n_test_threads(3, &project, cx).await;
953 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
954 cx.run_until_parked();
955
956 focus_sidebar(&sidebar, cx);
957
958 // SelectLast jumps to the end
959 cx.dispatch_action(SelectLast);
960 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
961
962 // SelectFirst jumps to the beginning
963 cx.dispatch_action(SelectFirst);
964 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
965}
966
967#[gpui::test]
968async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
969 let project = init_test_project("/my-project", cx).await;
970 let (multi_workspace, cx) =
971 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
972 let sidebar = setup_sidebar(&multi_workspace, cx);
973
974 // Initially no selection
975 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
976
977 // Open the sidebar so it's rendered, then focus it to trigger focus_in.
978 // focus_in no longer sets a default selection.
979 focus_sidebar(&sidebar, cx);
980 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
981
982 // Manually set a selection, blur, then refocus — selection should be preserved
983 sidebar.update_in(cx, |sidebar, _window, _cx| {
984 sidebar.selection = Some(0);
985 });
986
987 cx.update(|window, _cx| {
988 window.blur();
989 });
990 cx.run_until_parked();
991
992 sidebar.update_in(cx, |_, window, cx| {
993 cx.focus_self(window);
994 });
995 cx.run_until_parked();
996 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
997}
998
999#[gpui::test]
1000async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
1001 let project = init_test_project("/my-project", cx).await;
1002 let (multi_workspace, cx) =
1003 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1004 let sidebar = setup_sidebar(&multi_workspace, cx);
1005
1006 save_n_test_threads(1, &project, cx).await;
1007 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1008 cx.run_until_parked();
1009
1010 assert_eq!(
1011 visible_entries_as_strings(&sidebar, cx),
1012 vec!["v [my-project]", " Thread 1"]
1013 );
1014
1015 // Focus the sidebar and select the header (index 0)
1016 focus_sidebar(&sidebar, cx);
1017 sidebar.update_in(cx, |sidebar, _window, _cx| {
1018 sidebar.selection = Some(0);
1019 });
1020
1021 // Confirm on project header collapses the group
1022 cx.dispatch_action(Confirm);
1023 cx.run_until_parked();
1024
1025 assert_eq!(
1026 visible_entries_as_strings(&sidebar, cx),
1027 vec!["> [my-project] <== selected"]
1028 );
1029
1030 // Confirm again expands the group
1031 cx.dispatch_action(Confirm);
1032 cx.run_until_parked();
1033
1034 assert_eq!(
1035 visible_entries_as_strings(&sidebar, cx),
1036 vec!["v [my-project] <== selected", " Thread 1",]
1037 );
1038}
1039
1040#[gpui::test]
1041async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
1042 let project = init_test_project("/my-project", cx).await;
1043 let (multi_workspace, cx) =
1044 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1045 let sidebar = setup_sidebar(&multi_workspace, cx);
1046
1047 save_n_test_threads(8, &project, cx).await;
1048 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1049 cx.run_until_parked();
1050
1051 // Should show header + 5 threads + "View More"
1052 let entries = visible_entries_as_strings(&sidebar, cx);
1053 assert_eq!(entries.len(), 7);
1054 assert!(entries.iter().any(|e| e.contains("View More")));
1055
1056 // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
1057 focus_sidebar(&sidebar, cx);
1058 for _ in 0..7 {
1059 cx.dispatch_action(SelectNext);
1060 }
1061 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
1062
1063 // Confirm on "View More" to expand
1064 cx.dispatch_action(Confirm);
1065 cx.run_until_parked();
1066
1067 // All 8 threads should now be visible with a "Collapse" button
1068 let entries = visible_entries_as_strings(&sidebar, cx);
1069 assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
1070 assert!(!entries.iter().any(|e| e.contains("View More")));
1071 assert!(entries.iter().any(|e| e.contains("Collapse")));
1072}
1073
1074#[gpui::test]
1075async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
1076 let project = init_test_project("/my-project", cx).await;
1077 let (multi_workspace, cx) =
1078 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1079 let sidebar = setup_sidebar(&multi_workspace, cx);
1080
1081 save_n_test_threads(1, &project, cx).await;
1082 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1083 cx.run_until_parked();
1084
1085 assert_eq!(
1086 visible_entries_as_strings(&sidebar, cx),
1087 vec!["v [my-project]", " Thread 1"]
1088 );
1089
1090 // Focus sidebar and manually select the header (index 0). Press left to collapse.
1091 focus_sidebar(&sidebar, cx);
1092 sidebar.update_in(cx, |sidebar, _window, _cx| {
1093 sidebar.selection = Some(0);
1094 });
1095
1096 cx.dispatch_action(SelectParent);
1097 cx.run_until_parked();
1098
1099 assert_eq!(
1100 visible_entries_as_strings(&sidebar, cx),
1101 vec!["> [my-project] <== selected"]
1102 );
1103
1104 // Press right to expand
1105 cx.dispatch_action(SelectChild);
1106 cx.run_until_parked();
1107
1108 assert_eq!(
1109 visible_entries_as_strings(&sidebar, cx),
1110 vec!["v [my-project] <== selected", " Thread 1",]
1111 );
1112
1113 // Press right again on already-expanded header moves selection down
1114 cx.dispatch_action(SelectChild);
1115 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1116}
1117
1118#[gpui::test]
1119async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
1120 let project = init_test_project("/my-project", cx).await;
1121 let (multi_workspace, cx) =
1122 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1123 let sidebar = setup_sidebar(&multi_workspace, cx);
1124
1125 save_n_test_threads(1, &project, cx).await;
1126 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1127 cx.run_until_parked();
1128
1129 // Focus sidebar (selection starts at None), then navigate down to the thread (child)
1130 focus_sidebar(&sidebar, cx);
1131 cx.dispatch_action(SelectNext);
1132 cx.dispatch_action(SelectNext);
1133 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1134
1135 assert_eq!(
1136 visible_entries_as_strings(&sidebar, cx),
1137 vec!["v [my-project]", " Thread 1 <== selected",]
1138 );
1139
1140 // Pressing left on a child collapses the parent group and selects it
1141 cx.dispatch_action(SelectParent);
1142 cx.run_until_parked();
1143
1144 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1145 assert_eq!(
1146 visible_entries_as_strings(&sidebar, cx),
1147 vec!["> [my-project] <== selected"]
1148 );
1149}
1150
1151#[gpui::test]
1152async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
1153 let project = init_test_project("/empty-project", cx).await;
1154 let (multi_workspace, cx) =
1155 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1156 let sidebar = setup_sidebar(&multi_workspace, cx);
1157
1158 // An empty project has the header and a new thread button.
1159 assert_eq!(
1160 visible_entries_as_strings(&sidebar, cx),
1161 vec!["v [empty-project]", " [+ New Thread]"]
1162 );
1163
1164 // Focus sidebar — focus_in does not set a selection
1165 focus_sidebar(&sidebar, cx);
1166 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1167
1168 // First SelectNext from None starts at index 0 (header)
1169 cx.dispatch_action(SelectNext);
1170 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1171
1172 // SelectNext moves to the new thread button
1173 cx.dispatch_action(SelectNext);
1174 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1175
1176 // At the end, wraps back to first entry
1177 cx.dispatch_action(SelectNext);
1178 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1179
1180 // SelectPrevious from first entry clears selection (returns to editor)
1181 cx.dispatch_action(SelectPrevious);
1182 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1183}
1184
1185#[gpui::test]
1186async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
1187 let project = init_test_project("/my-project", cx).await;
1188 let (multi_workspace, cx) =
1189 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1190 let sidebar = setup_sidebar(&multi_workspace, cx);
1191
1192 save_n_test_threads(1, &project, cx).await;
1193 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1194 cx.run_until_parked();
1195
1196 // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
1197 focus_sidebar(&sidebar, cx);
1198 cx.dispatch_action(SelectNext);
1199 cx.dispatch_action(SelectNext);
1200 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1201
1202 // Collapse the group, which removes the thread from the list
1203 cx.dispatch_action(SelectParent);
1204 cx.run_until_parked();
1205
1206 // Selection should be clamped to the last valid index (0 = header)
1207 let selection = sidebar.read_with(cx, |s, _| s.selection);
1208 let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
1209 assert!(
1210 selection.unwrap_or(0) < entry_count,
1211 "selection {} should be within bounds (entries: {})",
1212 selection.unwrap_or(0),
1213 entry_count,
1214 );
1215}
1216
1217async fn init_test_project_with_agent_panel(
1218 worktree_path: &str,
1219 cx: &mut TestAppContext,
1220) -> Entity<project::Project> {
1221 agent_ui::test_support::init_test(cx);
1222 cx.update(|cx| {
1223 cx.update_flags(false, vec!["agent-v2".into()]);
1224 ThreadStore::init_global(cx);
1225 ThreadMetadataStore::init_global(cx);
1226 language_model::LanguageModelRegistry::test(cx);
1227 prompt_store::init(cx);
1228 });
1229
1230 let fs = FakeFs::new(cx.executor());
1231 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1232 .await;
1233 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1234 project::Project::test(fs, [worktree_path.as_ref()], cx).await
1235}
1236
1237fn add_agent_panel(
1238 workspace: &Entity<Workspace>,
1239 cx: &mut gpui::VisualTestContext,
1240) -> Entity<AgentPanel> {
1241 workspace.update_in(cx, |workspace, window, cx| {
1242 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
1243 workspace.add_panel(panel.clone(), window, cx);
1244 panel
1245 })
1246}
1247
1248fn setup_sidebar_with_agent_panel(
1249 multi_workspace: &Entity<MultiWorkspace>,
1250 cx: &mut gpui::VisualTestContext,
1251) -> (Entity<Sidebar>, Entity<AgentPanel>) {
1252 let sidebar = setup_sidebar(multi_workspace, cx);
1253 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
1254 let panel = add_agent_panel(&workspace, cx);
1255 (sidebar, panel)
1256}
1257
1258#[gpui::test]
1259async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
1260 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1261 let (multi_workspace, cx) =
1262 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1263 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1264
1265 // Open thread A and keep it generating.
1266 let connection = StubAgentConnection::new();
1267 open_thread_with_connection(&panel, connection.clone(), cx);
1268 send_message(&panel, cx);
1269
1270 let session_id_a = active_session_id(&panel, cx);
1271 save_test_thread_metadata(&session_id_a, &project, cx).await;
1272
1273 cx.update(|_, cx| {
1274 connection.send_update(
1275 session_id_a.clone(),
1276 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
1277 cx,
1278 );
1279 });
1280 cx.run_until_parked();
1281
1282 // Open thread B (idle, default response) — thread A goes to background.
1283 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1284 acp::ContentChunk::new("Done".into()),
1285 )]);
1286 open_thread_with_connection(&panel, connection, cx);
1287 send_message(&panel, cx);
1288
1289 let session_id_b = active_session_id(&panel, cx);
1290 save_test_thread_metadata(&session_id_b, &project, cx).await;
1291
1292 cx.run_until_parked();
1293
1294 let mut entries = visible_entries_as_strings(&sidebar, cx);
1295 entries[1..].sort();
1296 assert_eq!(
1297 entries,
1298 vec!["v [my-project]", " Hello *", " Hello * (running)",]
1299 );
1300}
1301
1302#[gpui::test]
1303async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
1304 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
1305 let (multi_workspace, cx) =
1306 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1307 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1308
1309 // Open thread on workspace A and keep it generating.
1310 let connection_a = StubAgentConnection::new();
1311 open_thread_with_connection(&panel_a, connection_a.clone(), cx);
1312 send_message(&panel_a, cx);
1313
1314 let session_id_a = active_session_id(&panel_a, cx);
1315 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
1316
1317 cx.update(|_, cx| {
1318 connection_a.send_update(
1319 session_id_a.clone(),
1320 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
1321 cx,
1322 );
1323 });
1324 cx.run_until_parked();
1325
1326 // Add a second workspace and activate it (making workspace A the background).
1327 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
1328 let project_b = project::Project::test(fs, [], cx).await;
1329 multi_workspace.update_in(cx, |mw, window, cx| {
1330 mw.test_add_workspace(project_b, window, cx);
1331 });
1332 cx.run_until_parked();
1333
1334 // Thread A is still running; no notification yet.
1335 assert_eq!(
1336 visible_entries_as_strings(&sidebar, cx),
1337 vec!["v [project-a]", " Hello * (running)",]
1338 );
1339
1340 // Complete thread A's turn (transition Running → Completed).
1341 connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
1342 cx.run_until_parked();
1343
1344 // The completed background thread shows a notification indicator.
1345 assert_eq!(
1346 visible_entries_as_strings(&sidebar, cx),
1347 vec!["v [project-a]", " Hello * (!)",]
1348 );
1349}
1350
1351fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
1352 sidebar.update_in(cx, |sidebar, window, cx| {
1353 window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
1354 sidebar.filter_editor.update(cx, |editor, cx| {
1355 editor.set_text(query, window, cx);
1356 });
1357 });
1358 cx.run_until_parked();
1359}
1360
1361#[gpui::test]
1362async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
1363 let project = init_test_project("/my-project", cx).await;
1364 let (multi_workspace, cx) =
1365 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1366 let sidebar = setup_sidebar(&multi_workspace, cx);
1367
1368 for (id, title, hour) in [
1369 ("t-1", "Fix crash in project panel", 3),
1370 ("t-2", "Add inline diff view", 2),
1371 ("t-3", "Refactor settings module", 1),
1372 ] {
1373 save_thread_metadata(
1374 acp::SessionId::new(Arc::from(id)),
1375 title.into(),
1376 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1377 None,
1378 &project,
1379 cx,
1380 );
1381 }
1382 cx.run_until_parked();
1383
1384 assert_eq!(
1385 visible_entries_as_strings(&sidebar, cx),
1386 vec![
1387 "v [my-project]",
1388 " Fix crash in project panel",
1389 " Add inline diff view",
1390 " Refactor settings module",
1391 ]
1392 );
1393
1394 // User types "diff" in the search box — only the matching thread remains,
1395 // with its workspace header preserved for context.
1396 type_in_search(&sidebar, "diff", cx);
1397 assert_eq!(
1398 visible_entries_as_strings(&sidebar, cx),
1399 vec!["v [my-project]", " Add inline diff view <== selected",]
1400 );
1401
1402 // User changes query to something with no matches — list is empty.
1403 type_in_search(&sidebar, "nonexistent", cx);
1404 assert_eq!(
1405 visible_entries_as_strings(&sidebar, cx),
1406 Vec::<String>::new()
1407 );
1408}
1409
1410#[gpui::test]
1411async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
1412 // Scenario: A user remembers a thread title but not the exact casing.
1413 // Search should match case-insensitively so they can still find it.
1414 let project = init_test_project("/my-project", cx).await;
1415 let (multi_workspace, cx) =
1416 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1417 let sidebar = setup_sidebar(&multi_workspace, cx);
1418
1419 save_thread_metadata(
1420 acp::SessionId::new(Arc::from("thread-1")),
1421 "Fix Crash In Project Panel".into(),
1422 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1423 None,
1424 &project,
1425 cx,
1426 );
1427 cx.run_until_parked();
1428
1429 // Lowercase query matches mixed-case title.
1430 type_in_search(&sidebar, "fix crash", cx);
1431 assert_eq!(
1432 visible_entries_as_strings(&sidebar, cx),
1433 vec![
1434 "v [my-project]",
1435 " Fix Crash In Project Panel <== selected",
1436 ]
1437 );
1438
1439 // Uppercase query also matches the same title.
1440 type_in_search(&sidebar, "FIX CRASH", cx);
1441 assert_eq!(
1442 visible_entries_as_strings(&sidebar, cx),
1443 vec![
1444 "v [my-project]",
1445 " Fix Crash In Project Panel <== selected",
1446 ]
1447 );
1448}
1449
1450#[gpui::test]
1451async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
1452 // Scenario: A user searches, finds what they need, then presses Escape
1453 // to dismiss the filter and see the full list again.
1454 let project = init_test_project("/my-project", cx).await;
1455 let (multi_workspace, cx) =
1456 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1457 let sidebar = setup_sidebar(&multi_workspace, cx);
1458
1459 for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
1460 save_thread_metadata(
1461 acp::SessionId::new(Arc::from(id)),
1462 title.into(),
1463 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1464 None,
1465 &project,
1466 cx,
1467 )
1468 }
1469 cx.run_until_parked();
1470
1471 // Confirm the full list is showing.
1472 assert_eq!(
1473 visible_entries_as_strings(&sidebar, cx),
1474 vec!["v [my-project]", " Alpha thread", " Beta thread",]
1475 );
1476
1477 // User types a search query to filter down.
1478 focus_sidebar(&sidebar, cx);
1479 type_in_search(&sidebar, "alpha", cx);
1480 assert_eq!(
1481 visible_entries_as_strings(&sidebar, cx),
1482 vec!["v [my-project]", " Alpha thread <== selected",]
1483 );
1484
1485 // User presses Escape — filter clears, full list is restored.
1486 // The selection index (1) now points at the first thread entry.
1487 cx.dispatch_action(Cancel);
1488 cx.run_until_parked();
1489 assert_eq!(
1490 visible_entries_as_strings(&sidebar, cx),
1491 vec![
1492 "v [my-project]",
1493 " Alpha thread <== selected",
1494 " Beta thread",
1495 ]
1496 );
1497}
1498
1499#[gpui::test]
1500async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
1501 let project_a = init_test_project("/project-a", cx).await;
1502 let (multi_workspace, cx) =
1503 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1504 let sidebar = setup_sidebar(&multi_workspace, cx);
1505
1506 for (id, title, hour) in [
1507 ("a1", "Fix bug in sidebar", 2),
1508 ("a2", "Add tests for editor", 1),
1509 ] {
1510 save_thread_metadata(
1511 acp::SessionId::new(Arc::from(id)),
1512 title.into(),
1513 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1514 None,
1515 &project_a,
1516 cx,
1517 )
1518 }
1519
1520 // Add a second workspace.
1521 multi_workspace.update_in(cx, |mw, window, cx| {
1522 mw.create_test_workspace(window, cx).detach();
1523 });
1524 cx.run_until_parked();
1525
1526 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1527 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1528 });
1529
1530 for (id, title, hour) in [
1531 ("b1", "Refactor sidebar layout", 3),
1532 ("b2", "Fix typo in README", 1),
1533 ] {
1534 save_thread_metadata(
1535 acp::SessionId::new(Arc::from(id)),
1536 title.into(),
1537 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1538 None,
1539 &project_b,
1540 cx,
1541 )
1542 }
1543 cx.run_until_parked();
1544
1545 assert_eq!(
1546 visible_entries_as_strings(&sidebar, cx),
1547 vec![
1548 "v [project-a]",
1549 " Fix bug in sidebar",
1550 " Add tests for editor",
1551 ]
1552 );
1553
1554 // "sidebar" matches a thread in each workspace — both headers stay visible.
1555 type_in_search(&sidebar, "sidebar", cx);
1556 assert_eq!(
1557 visible_entries_as_strings(&sidebar, cx),
1558 vec!["v [project-a]", " Fix bug in sidebar <== selected",]
1559 );
1560
1561 // "typo" only matches in the second workspace — the first header disappears.
1562 type_in_search(&sidebar, "typo", cx);
1563 assert_eq!(
1564 visible_entries_as_strings(&sidebar, cx),
1565 Vec::<String>::new()
1566 );
1567
1568 // "project-a" matches the first workspace name — the header appears
1569 // with all child threads included.
1570 type_in_search(&sidebar, "project-a", cx);
1571 assert_eq!(
1572 visible_entries_as_strings(&sidebar, cx),
1573 vec![
1574 "v [project-a]",
1575 " Fix bug in sidebar <== selected",
1576 " Add tests for editor",
1577 ]
1578 );
1579}
1580
1581#[gpui::test]
1582async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
1583 let project_a = init_test_project("/alpha-project", cx).await;
1584 let (multi_workspace, cx) =
1585 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1586 let sidebar = setup_sidebar(&multi_workspace, cx);
1587
1588 for (id, title, hour) in [
1589 ("a1", "Fix bug in sidebar", 2),
1590 ("a2", "Add tests for editor", 1),
1591 ] {
1592 save_thread_metadata(
1593 acp::SessionId::new(Arc::from(id)),
1594 title.into(),
1595 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1596 None,
1597 &project_a,
1598 cx,
1599 )
1600 }
1601
1602 // Add a second workspace.
1603 multi_workspace.update_in(cx, |mw, window, cx| {
1604 mw.create_test_workspace(window, cx).detach();
1605 });
1606 cx.run_until_parked();
1607
1608 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1609 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1610 });
1611
1612 for (id, title, hour) in [
1613 ("b1", "Refactor sidebar layout", 3),
1614 ("b2", "Fix typo in README", 1),
1615 ] {
1616 save_thread_metadata(
1617 acp::SessionId::new(Arc::from(id)),
1618 title.into(),
1619 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1620 None,
1621 &project_b,
1622 cx,
1623 )
1624 }
1625 cx.run_until_parked();
1626
1627 // "alpha" matches the workspace name "alpha-project" but no thread titles.
1628 // The workspace header should appear with all child threads included.
1629 type_in_search(&sidebar, "alpha", cx);
1630 assert_eq!(
1631 visible_entries_as_strings(&sidebar, cx),
1632 vec![
1633 "v [alpha-project]",
1634 " Fix bug in sidebar <== selected",
1635 " Add tests for editor",
1636 ]
1637 );
1638
1639 // "sidebar" matches thread titles in both workspaces but not workspace names.
1640 // Both headers appear with their matching threads.
1641 type_in_search(&sidebar, "sidebar", cx);
1642 assert_eq!(
1643 visible_entries_as_strings(&sidebar, cx),
1644 vec!["v [alpha-project]", " Fix bug in sidebar <== selected",]
1645 );
1646
1647 // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
1648 // doesn't match) — but does not match either workspace name or any thread.
1649 // Actually let's test something simpler: a query that matches both a workspace
1650 // name AND some threads in that workspace. Matching threads should still appear.
1651 type_in_search(&sidebar, "fix", cx);
1652 assert_eq!(
1653 visible_entries_as_strings(&sidebar, cx),
1654 vec!["v [alpha-project]", " Fix bug in sidebar <== selected",]
1655 );
1656
1657 // A query that matches a workspace name AND a thread in that same workspace.
1658 // Both the header (highlighted) and all child threads should appear.
1659 type_in_search(&sidebar, "alpha", cx);
1660 assert_eq!(
1661 visible_entries_as_strings(&sidebar, cx),
1662 vec![
1663 "v [alpha-project]",
1664 " Fix bug in sidebar <== selected",
1665 " Add tests for editor",
1666 ]
1667 );
1668
1669 // Now search for something that matches only a workspace name when there
1670 // are also threads with matching titles — the non-matching workspace's
1671 // threads should still appear if their titles match.
1672 type_in_search(&sidebar, "alp", cx);
1673 assert_eq!(
1674 visible_entries_as_strings(&sidebar, cx),
1675 vec![
1676 "v [alpha-project]",
1677 " Fix bug in sidebar <== selected",
1678 " Add tests for editor",
1679 ]
1680 );
1681}
1682
1683#[gpui::test]
1684async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
1685 let project = init_test_project("/my-project", cx).await;
1686 let (multi_workspace, cx) =
1687 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1688 let sidebar = setup_sidebar(&multi_workspace, cx);
1689
1690 // Create 8 threads. The oldest one has a unique name and will be
1691 // behind View More (only 5 shown by default).
1692 for i in 0..8u32 {
1693 let title = if i == 0 {
1694 "Hidden gem thread".to_string()
1695 } else {
1696 format!("Thread {}", i + 1)
1697 };
1698 save_thread_metadata(
1699 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1700 title.into(),
1701 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1702 None,
1703 &project,
1704 cx,
1705 )
1706 }
1707 cx.run_until_parked();
1708
1709 // Confirm the thread is not visible and View More is shown.
1710 let entries = visible_entries_as_strings(&sidebar, cx);
1711 assert!(
1712 entries.iter().any(|e| e.contains("View More")),
1713 "should have View More button"
1714 );
1715 assert!(
1716 !entries.iter().any(|e| e.contains("Hidden gem")),
1717 "Hidden gem should be behind View More"
1718 );
1719
1720 // User searches for the hidden thread — it appears, and View More is gone.
1721 type_in_search(&sidebar, "hidden gem", cx);
1722 let filtered = visible_entries_as_strings(&sidebar, cx);
1723 assert_eq!(
1724 filtered,
1725 vec!["v [my-project]", " Hidden gem thread <== selected",]
1726 );
1727 assert!(
1728 !filtered.iter().any(|e| e.contains("View More")),
1729 "View More should not appear when filtering"
1730 );
1731}
1732
1733#[gpui::test]
1734async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
1735 let project = init_test_project("/my-project", cx).await;
1736 let (multi_workspace, cx) =
1737 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1738 let sidebar = setup_sidebar(&multi_workspace, cx);
1739
1740 save_thread_metadata(
1741 acp::SessionId::new(Arc::from("thread-1")),
1742 "Important thread".into(),
1743 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1744 None,
1745 &project,
1746 cx,
1747 );
1748 cx.run_until_parked();
1749
1750 // User focuses the sidebar and collapses the group using keyboard:
1751 // manually select the header, then press SelectParent to collapse.
1752 focus_sidebar(&sidebar, cx);
1753 sidebar.update_in(cx, |sidebar, _window, _cx| {
1754 sidebar.selection = Some(0);
1755 });
1756 cx.dispatch_action(SelectParent);
1757 cx.run_until_parked();
1758
1759 assert_eq!(
1760 visible_entries_as_strings(&sidebar, cx),
1761 vec!["> [my-project] <== selected"]
1762 );
1763
1764 // User types a search — the thread appears even though its group is collapsed.
1765 type_in_search(&sidebar, "important", cx);
1766 assert_eq!(
1767 visible_entries_as_strings(&sidebar, cx),
1768 vec!["> [my-project]", " Important thread <== selected",]
1769 );
1770}
1771
1772#[gpui::test]
1773async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
1774 let project = init_test_project("/my-project", cx).await;
1775 let (multi_workspace, cx) =
1776 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1777 let sidebar = setup_sidebar(&multi_workspace, cx);
1778
1779 for (id, title, hour) in [
1780 ("t-1", "Fix crash in panel", 3),
1781 ("t-2", "Fix lint warnings", 2),
1782 ("t-3", "Add new feature", 1),
1783 ] {
1784 save_thread_metadata(
1785 acp::SessionId::new(Arc::from(id)),
1786 title.into(),
1787 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1788 None,
1789 &project,
1790 cx,
1791 )
1792 }
1793 cx.run_until_parked();
1794
1795 focus_sidebar(&sidebar, cx);
1796
1797 // User types "fix" — two threads match.
1798 type_in_search(&sidebar, "fix", cx);
1799 assert_eq!(
1800 visible_entries_as_strings(&sidebar, cx),
1801 vec![
1802 "v [my-project]",
1803 " Fix crash in panel <== selected",
1804 " Fix lint warnings",
1805 ]
1806 );
1807
1808 // Selection starts on the first matching thread. User presses
1809 // SelectNext to move to the second match.
1810 cx.dispatch_action(SelectNext);
1811 assert_eq!(
1812 visible_entries_as_strings(&sidebar, cx),
1813 vec![
1814 "v [my-project]",
1815 " Fix crash in panel",
1816 " Fix lint warnings <== selected",
1817 ]
1818 );
1819
1820 // User can also jump back with SelectPrevious.
1821 cx.dispatch_action(SelectPrevious);
1822 assert_eq!(
1823 visible_entries_as_strings(&sidebar, cx),
1824 vec![
1825 "v [my-project]",
1826 " Fix crash in panel <== selected",
1827 " Fix lint warnings",
1828 ]
1829 );
1830}
1831
1832#[gpui::test]
1833async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
1834 let project = init_test_project("/my-project", cx).await;
1835 let (multi_workspace, cx) =
1836 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1837 let sidebar = setup_sidebar(&multi_workspace, cx);
1838
1839 multi_workspace.update_in(cx, |mw, window, cx| {
1840 mw.create_test_workspace(window, cx).detach();
1841 });
1842 cx.run_until_parked();
1843
1844 let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
1845 (
1846 mw.workspaces().next().unwrap().clone(),
1847 mw.workspaces().nth(1).unwrap().clone(),
1848 )
1849 });
1850
1851 save_thread_metadata(
1852 acp::SessionId::new(Arc::from("hist-1")),
1853 "Historical Thread".into(),
1854 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
1855 None,
1856 &project,
1857 cx,
1858 );
1859 cx.run_until_parked();
1860 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1861 cx.run_until_parked();
1862
1863 assert_eq!(
1864 visible_entries_as_strings(&sidebar, cx),
1865 vec!["v [my-project]", " Historical Thread",]
1866 );
1867
1868 // Switch to workspace 1 so we can verify the confirm switches back.
1869 multi_workspace.update_in(cx, |mw, window, cx| {
1870 let workspace = mw.workspaces().nth(1).unwrap().clone();
1871 mw.activate(workspace, window, cx);
1872 });
1873 cx.run_until_parked();
1874 assert_eq!(
1875 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
1876 workspace_1
1877 );
1878
1879 // Confirm on the historical (non-live) thread at index 1.
1880 // Before a previous fix, the workspace field was Option<usize> and
1881 // historical threads had None, so activate_thread early-returned
1882 // without switching the workspace.
1883 sidebar.update_in(cx, |sidebar, window, cx| {
1884 sidebar.selection = Some(1);
1885 sidebar.confirm(&Confirm, window, cx);
1886 });
1887 cx.run_until_parked();
1888
1889 assert_eq!(
1890 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
1891 workspace_0
1892 );
1893}
1894
1895#[gpui::test]
1896async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
1897 let project = init_test_project("/my-project", cx).await;
1898 let (multi_workspace, cx) =
1899 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1900 let sidebar = setup_sidebar(&multi_workspace, cx);
1901
1902 save_thread_metadata(
1903 acp::SessionId::new(Arc::from("t-1")),
1904 "Thread A".into(),
1905 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
1906 None,
1907 &project,
1908 cx,
1909 );
1910
1911 save_thread_metadata(
1912 acp::SessionId::new(Arc::from("t-2")),
1913 "Thread B".into(),
1914 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1915 None,
1916 &project,
1917 cx,
1918 );
1919
1920 cx.run_until_parked();
1921 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1922 cx.run_until_parked();
1923
1924 assert_eq!(
1925 visible_entries_as_strings(&sidebar, cx),
1926 vec!["v [my-project]", " Thread A", " Thread B",]
1927 );
1928
1929 // Keyboard confirm preserves selection.
1930 sidebar.update_in(cx, |sidebar, window, cx| {
1931 sidebar.selection = Some(1);
1932 sidebar.confirm(&Confirm, window, cx);
1933 });
1934 assert_eq!(
1935 sidebar.read_with(cx, |sidebar, _| sidebar.selection),
1936 Some(1)
1937 );
1938
1939 // Click handlers clear selection to None so no highlight lingers
1940 // after a click regardless of focus state. The hover style provides
1941 // visual feedback during mouse interaction instead.
1942 sidebar.update_in(cx, |sidebar, window, cx| {
1943 sidebar.selection = None;
1944 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1945 sidebar.toggle_collapse(&path_list, window, cx);
1946 });
1947 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
1948
1949 // When the user tabs back into the sidebar, focus_in no longer
1950 // restores selection — it stays None.
1951 sidebar.update_in(cx, |sidebar, window, cx| {
1952 sidebar.focus_in(window, cx);
1953 });
1954 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
1955}
1956
1957#[gpui::test]
1958async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
1959 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1960 let (multi_workspace, cx) =
1961 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1962 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1963
1964 let connection = StubAgentConnection::new();
1965 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1966 acp::ContentChunk::new("Hi there!".into()),
1967 )]);
1968 open_thread_with_connection(&panel, connection, cx);
1969 send_message(&panel, cx);
1970
1971 let session_id = active_session_id(&panel, cx);
1972 save_test_thread_metadata(&session_id, &project, cx).await;
1973 cx.run_until_parked();
1974
1975 assert_eq!(
1976 visible_entries_as_strings(&sidebar, cx),
1977 vec!["v [my-project]", " Hello *"]
1978 );
1979
1980 // Simulate the agent generating a title. The notification chain is:
1981 // AcpThread::set_title emits TitleUpdated →
1982 // ConnectionView::handle_thread_event calls cx.notify() →
1983 // AgentPanel observer fires and emits AgentPanelEvent →
1984 // Sidebar subscription calls update_entries / rebuild_contents.
1985 //
1986 // Before the fix, handle_thread_event did NOT call cx.notify() for
1987 // TitleUpdated, so the AgentPanel observer never fired and the
1988 // sidebar kept showing the old title.
1989 let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
1990 thread.update(cx, |thread, cx| {
1991 thread
1992 .set_title("Friendly Greeting with AI".into(), cx)
1993 .detach();
1994 });
1995 cx.run_until_parked();
1996
1997 assert_eq!(
1998 visible_entries_as_strings(&sidebar, cx),
1999 vec!["v [my-project]", " Friendly Greeting with AI *"]
2000 );
2001}
2002
2003#[gpui::test]
2004async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
2005 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2006 let (multi_workspace, cx) =
2007 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2008 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2009
2010 // Save a thread so it appears in the list.
2011 let connection_a = StubAgentConnection::new();
2012 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2013 acp::ContentChunk::new("Done".into()),
2014 )]);
2015 open_thread_with_connection(&panel_a, connection_a, cx);
2016 send_message(&panel_a, cx);
2017 let session_id_a = active_session_id(&panel_a, cx);
2018 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
2019
2020 // Add a second workspace with its own agent panel.
2021 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2022 fs.as_fake()
2023 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2024 .await;
2025 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2026 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2027 mw.test_add_workspace(project_b.clone(), window, cx)
2028 });
2029 let panel_b = add_agent_panel(&workspace_b, cx);
2030 cx.run_until_parked();
2031
2032 let workspace_a =
2033 multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2034
2035 // ── 1. Initial state: focused thread derived from active panel ─────
2036 sidebar.read_with(cx, |sidebar, _cx| {
2037 assert_active_thread(
2038 sidebar,
2039 &session_id_a,
2040 "The active panel's thread should be focused on startup",
2041 );
2042 });
2043
2044 sidebar.update_in(cx, |sidebar, window, cx| {
2045 sidebar.activate_thread(
2046 ThreadMetadata {
2047 session_id: session_id_a.clone(),
2048 agent_id: agent::ZED_AGENT_ID.clone(),
2049 title: "Test".into(),
2050 updated_at: Utc::now(),
2051 created_at: None,
2052 folder_paths: PathList::default(),
2053 main_worktree_paths: PathList::default(),
2054 archived: false,
2055 },
2056 &workspace_a,
2057 false,
2058 window,
2059 cx,
2060 );
2061 });
2062 cx.run_until_parked();
2063
2064 sidebar.read_with(cx, |sidebar, _cx| {
2065 assert_active_thread(
2066 sidebar,
2067 &session_id_a,
2068 "After clicking a thread, it should be the focused thread",
2069 );
2070 assert!(
2071 has_thread_entry(sidebar, &session_id_a),
2072 "The clicked thread should be present in the entries"
2073 );
2074 });
2075
2076 workspace_a.read_with(cx, |workspace, cx| {
2077 assert!(
2078 workspace.panel::<AgentPanel>(cx).is_some(),
2079 "Agent panel should exist"
2080 );
2081 let dock = workspace.right_dock().read(cx);
2082 assert!(
2083 dock.is_open(),
2084 "Clicking a thread should open the agent panel dock"
2085 );
2086 });
2087
2088 let connection_b = StubAgentConnection::new();
2089 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2090 acp::ContentChunk::new("Thread B".into()),
2091 )]);
2092 open_thread_with_connection(&panel_b, connection_b, cx);
2093 send_message(&panel_b, cx);
2094 let session_id_b = active_session_id(&panel_b, cx);
2095 save_test_thread_metadata(&session_id_b, &project_b, cx).await;
2096 cx.run_until_parked();
2097
2098 // Workspace A is currently active. Click a thread in workspace B,
2099 // which also triggers a workspace switch.
2100 sidebar.update_in(cx, |sidebar, window, cx| {
2101 sidebar.activate_thread(
2102 ThreadMetadata {
2103 session_id: session_id_b.clone(),
2104 agent_id: agent::ZED_AGENT_ID.clone(),
2105 title: "Thread B".into(),
2106 updated_at: Utc::now(),
2107 created_at: None,
2108 folder_paths: PathList::default(),
2109 main_worktree_paths: PathList::default(),
2110 archived: false,
2111 },
2112 &workspace_b,
2113 false,
2114 window,
2115 cx,
2116 );
2117 });
2118 cx.run_until_parked();
2119
2120 sidebar.read_with(cx, |sidebar, _cx| {
2121 assert_active_thread(
2122 sidebar,
2123 &session_id_b,
2124 "Clicking a thread in another workspace should focus that thread",
2125 );
2126 assert!(
2127 has_thread_entry(sidebar, &session_id_b),
2128 "The cross-workspace thread should be present in the entries"
2129 );
2130 });
2131
2132 multi_workspace.update_in(cx, |mw, window, cx| {
2133 let workspace = mw.workspaces().next().unwrap().clone();
2134 mw.activate(workspace, window, cx);
2135 });
2136 cx.run_until_parked();
2137
2138 sidebar.read_with(cx, |sidebar, _cx| {
2139 assert_active_thread(
2140 sidebar,
2141 &session_id_a,
2142 "Switching workspace should seed focused_thread from the new active panel",
2143 );
2144 assert!(
2145 has_thread_entry(sidebar, &session_id_a),
2146 "The seeded thread should be present in the entries"
2147 );
2148 });
2149
2150 let connection_b2 = StubAgentConnection::new();
2151 connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2152 acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
2153 )]);
2154 open_thread_with_connection(&panel_b, connection_b2, cx);
2155 send_message(&panel_b, cx);
2156 let session_id_b2 = active_session_id(&panel_b, cx);
2157 save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
2158 cx.run_until_parked();
2159
2160 // Panel B is not the active workspace's panel (workspace A is
2161 // active), so opening a thread there should not change focused_thread.
2162 // This prevents running threads in background workspaces from causing
2163 // the selection highlight to jump around.
2164 sidebar.read_with(cx, |sidebar, _cx| {
2165 assert_active_thread(
2166 sidebar,
2167 &session_id_a,
2168 "Opening a thread in a non-active panel should not change focused_thread",
2169 );
2170 });
2171
2172 workspace_b.update_in(cx, |workspace, window, cx| {
2173 workspace.focus_handle(cx).focus(window, cx);
2174 });
2175 cx.run_until_parked();
2176
2177 sidebar.read_with(cx, |sidebar, _cx| {
2178 assert_active_thread(
2179 sidebar,
2180 &session_id_a,
2181 "Defocusing the sidebar should not change focused_thread",
2182 );
2183 });
2184
2185 // Switching workspaces via the multi_workspace (simulates clicking
2186 // a workspace header) should clear focused_thread.
2187 multi_workspace.update_in(cx, |mw, window, cx| {
2188 let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
2189 if let Some(workspace) = workspace {
2190 mw.activate(workspace, window, cx);
2191 }
2192 });
2193 cx.run_until_parked();
2194
2195 sidebar.read_with(cx, |sidebar, _cx| {
2196 assert_active_thread(
2197 sidebar,
2198 &session_id_b2,
2199 "Switching workspace should seed focused_thread from the new active panel",
2200 );
2201 assert!(
2202 has_thread_entry(sidebar, &session_id_b2),
2203 "The seeded thread should be present in the entries"
2204 );
2205 });
2206
2207 // ── 8. Focusing the agent panel thread keeps focused_thread ────
2208 // Workspace B still has session_id_b2 loaded in the agent panel.
2209 // Clicking into the thread (simulated by focusing its view) should
2210 // keep focused_thread since it was already seeded on workspace switch.
2211 panel_b.update_in(cx, |panel, window, cx| {
2212 if let Some(thread_view) = panel.active_conversation_view() {
2213 thread_view.read(cx).focus_handle(cx).focus(window, cx);
2214 }
2215 });
2216 cx.run_until_parked();
2217
2218 sidebar.read_with(cx, |sidebar, _cx| {
2219 assert_active_thread(
2220 sidebar,
2221 &session_id_b2,
2222 "Focusing the agent panel thread should set focused_thread",
2223 );
2224 assert!(
2225 has_thread_entry(sidebar, &session_id_b2),
2226 "The focused thread should be present in the entries"
2227 );
2228 });
2229}
2230
2231#[gpui::test]
2232async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
2233 let project = init_test_project_with_agent_panel("/project-a", cx).await;
2234 let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
2235 let (multi_workspace, cx) =
2236 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2237 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2238
2239 // Start a thread and send a message so it has history.
2240 let connection = StubAgentConnection::new();
2241 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2242 acp::ContentChunk::new("Done".into()),
2243 )]);
2244 open_thread_with_connection(&panel, connection, cx);
2245 send_message(&panel, cx);
2246 let session_id = active_session_id(&panel, cx);
2247 save_test_thread_metadata(&session_id, &project, cx).await;
2248 cx.run_until_parked();
2249
2250 // Verify the thread appears in the sidebar.
2251 assert_eq!(
2252 visible_entries_as_strings(&sidebar, cx),
2253 vec!["v [project-a]", " Hello *",]
2254 );
2255
2256 // The "New Thread" button should NOT be in "active/draft" state
2257 // because the panel has a thread with messages.
2258 sidebar.read_with(cx, |sidebar, _cx| {
2259 assert!(
2260 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
2261 "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
2262 sidebar.active_entry,
2263 );
2264 });
2265
2266 // Now add a second folder to the workspace, changing the path_list.
2267 fs.as_fake()
2268 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2269 .await;
2270 project
2271 .update(cx, |project, cx| {
2272 project.find_or_create_worktree("/project-b", true, cx)
2273 })
2274 .await
2275 .expect("should add worktree");
2276 cx.run_until_parked();
2277
2278 // The workspace path_list is now [project-a, project-b]. The active
2279 // thread's metadata was re-saved with the new paths by the agent panel's
2280 // project subscription, so it stays visible under the updated group.
2281 // The old [project-a] group persists in the sidebar (empty) because
2282 // project_group_keys is append-only.
2283 assert_eq!(
2284 visible_entries_as_strings(&sidebar, cx),
2285 vec![
2286 "v [project-a, project-b]", //
2287 " Hello *",
2288 "v [project-a]",
2289 ]
2290 );
2291
2292 // The "New Thread" button must still be clickable (not stuck in
2293 // "active/draft" state). Verify that `active_thread_is_draft` is
2294 // false — the panel still has the old thread with messages.
2295 sidebar.read_with(cx, |sidebar, _cx| {
2296 assert!(
2297 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
2298 "After adding a folder the panel still has a thread with messages, \
2299 so active_entry should be Thread, got {:?}",
2300 sidebar.active_entry,
2301 );
2302 });
2303
2304 // Actually click "New Thread" by calling create_new_thread and
2305 // verify a new draft is created.
2306 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2307 sidebar.update_in(cx, |sidebar, window, cx| {
2308 sidebar.create_new_thread(&workspace, window, cx);
2309 });
2310 cx.run_until_parked();
2311
2312 // After creating a new thread, the panel should now be in draft
2313 // state (no messages on the new thread).
2314 sidebar.read_with(cx, |sidebar, _cx| {
2315 assert_active_draft(
2316 sidebar,
2317 &workspace,
2318 "After creating a new thread active_entry should be Draft",
2319 );
2320 });
2321}
2322
2323#[gpui::test]
2324async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
2325 // When the user presses Cmd-N (NewThread action) while viewing a
2326 // non-empty thread, the sidebar should show the "New Thread" entry.
2327 // This exercises the same code path as the workspace action handler
2328 // (which bypasses the sidebar's create_new_thread method).
2329 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2330 let (multi_workspace, cx) =
2331 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2332 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2333
2334 // Create a non-empty thread (has messages).
2335 let connection = StubAgentConnection::new();
2336 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2337 acp::ContentChunk::new("Done".into()),
2338 )]);
2339 open_thread_with_connection(&panel, connection, cx);
2340 send_message(&panel, cx);
2341
2342 let session_id = active_session_id(&panel, cx);
2343 save_test_thread_metadata(&session_id, &project, cx).await;
2344 cx.run_until_parked();
2345
2346 assert_eq!(
2347 visible_entries_as_strings(&sidebar, cx),
2348 vec!["v [my-project]", " Hello *"]
2349 );
2350
2351 // Simulate cmd-n
2352 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2353 panel.update_in(cx, |panel, window, cx| {
2354 panel.new_thread(&NewThread, window, cx);
2355 });
2356 workspace.update_in(cx, |workspace, window, cx| {
2357 workspace.focus_panel::<AgentPanel>(window, cx);
2358 });
2359 cx.run_until_parked();
2360
2361 assert_eq!(
2362 visible_entries_as_strings(&sidebar, cx),
2363 vec!["v [my-project]", " [~ Draft]", " Hello *"],
2364 "After Cmd-N the sidebar should show a highlighted Draft entry"
2365 );
2366
2367 sidebar.read_with(cx, |sidebar, _cx| {
2368 assert_active_draft(
2369 sidebar,
2370 &workspace,
2371 "active_entry should be Draft after Cmd-N",
2372 );
2373 });
2374}
2375
2376#[gpui::test]
2377async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
2378 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2379 let (multi_workspace, cx) =
2380 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2381 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2382
2383 // Create a saved thread so the workspace has history.
2384 let connection = StubAgentConnection::new();
2385 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2386 acp::ContentChunk::new("Done".into()),
2387 )]);
2388 open_thread_with_connection(&panel, connection, cx);
2389 send_message(&panel, cx);
2390 let saved_session_id = active_session_id(&panel, cx);
2391 save_test_thread_metadata(&saved_session_id, &project, cx).await;
2392 cx.run_until_parked();
2393
2394 assert_eq!(
2395 visible_entries_as_strings(&sidebar, cx),
2396 vec!["v [my-project]", " Hello *"]
2397 );
2398
2399 // Open a new draft thread via a server connection. This gives the
2400 // conversation a parent_id (session assigned by the server) but
2401 // no messages have been sent, so active_thread_is_draft() is true.
2402 let draft_connection = StubAgentConnection::new();
2403 open_thread_with_connection(&panel, draft_connection, cx);
2404 cx.run_until_parked();
2405
2406 assert_eq!(
2407 visible_entries_as_strings(&sidebar, cx),
2408 vec!["v [my-project]", " [~ Draft]", " Hello *"],
2409 );
2410
2411 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2412 sidebar.read_with(cx, |sidebar, _cx| {
2413 assert_active_draft(
2414 sidebar,
2415 &workspace,
2416 "Draft with server session should be Draft, not Thread",
2417 );
2418 });
2419}
2420
2421#[gpui::test]
2422async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
2423 // When the active workspace is an absorbed git worktree, cmd-n
2424 // should still show the "New Thread" entry under the main repo's
2425 // header and highlight it as active.
2426 agent_ui::test_support::init_test(cx);
2427 cx.update(|cx| {
2428 cx.update_flags(false, vec!["agent-v2".into()]);
2429 ThreadStore::init_global(cx);
2430 ThreadMetadataStore::init_global(cx);
2431 language_model::LanguageModelRegistry::test(cx);
2432 prompt_store::init(cx);
2433 });
2434
2435 let fs = FakeFs::new(cx.executor());
2436
2437 // Main repo with a linked worktree.
2438 fs.insert_tree(
2439 "/project",
2440 serde_json::json!({
2441 ".git": {},
2442 "src": {},
2443 }),
2444 )
2445 .await;
2446
2447 // Worktree checkout pointing back to the main repo.
2448 fs.add_linked_worktree_for_repo(
2449 Path::new("/project/.git"),
2450 false,
2451 git::repository::Worktree {
2452 path: std::path::PathBuf::from("/wt-feature-a"),
2453 ref_name: Some("refs/heads/feature-a".into()),
2454 sha: "aaa".into(),
2455 is_main: false,
2456 },
2457 )
2458 .await;
2459
2460 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2461
2462 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2463 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2464
2465 main_project
2466 .update(cx, |p, cx| p.git_scans_complete(cx))
2467 .await;
2468 worktree_project
2469 .update(cx, |p, cx| p.git_scans_complete(cx))
2470 .await;
2471
2472 let (multi_workspace, cx) =
2473 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
2474
2475 let sidebar = setup_sidebar(&multi_workspace, cx);
2476
2477 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
2478 mw.test_add_workspace(worktree_project.clone(), window, cx)
2479 });
2480
2481 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
2482
2483 // Switch to the worktree workspace.
2484 multi_workspace.update_in(cx, |mw, window, cx| {
2485 let workspace = mw.workspaces().nth(1).unwrap().clone();
2486 mw.activate(workspace, window, cx);
2487 });
2488
2489 // Create a non-empty thread in the worktree workspace.
2490 let connection = StubAgentConnection::new();
2491 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2492 acp::ContentChunk::new("Done".into()),
2493 )]);
2494 open_thread_with_connection(&worktree_panel, connection, cx);
2495 send_message(&worktree_panel, cx);
2496
2497 let session_id = active_session_id(&worktree_panel, cx);
2498 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
2499 cx.run_until_parked();
2500
2501 assert_eq!(
2502 visible_entries_as_strings(&sidebar, cx),
2503 vec!["v [project]", " Hello {wt-feature-a} *"]
2504 );
2505
2506 // Simulate Cmd-N in the worktree workspace.
2507 worktree_panel.update_in(cx, |panel, window, cx| {
2508 panel.new_thread(&NewThread, window, cx);
2509 });
2510 worktree_workspace.update_in(cx, |workspace, window, cx| {
2511 workspace.focus_panel::<AgentPanel>(window, cx);
2512 });
2513 cx.run_until_parked();
2514
2515 assert_eq!(
2516 visible_entries_as_strings(&sidebar, cx),
2517 vec![
2518 "v [project]",
2519 " [~ Draft {wt-feature-a}]",
2520 " Hello {wt-feature-a} *"
2521 ],
2522 "After Cmd-N in an absorbed worktree, the sidebar should show \
2523 a highlighted Draft entry under the main repo header"
2524 );
2525
2526 sidebar.read_with(cx, |sidebar, _cx| {
2527 assert_active_draft(
2528 sidebar,
2529 &worktree_workspace,
2530 "active_entry should be Draft after Cmd-N",
2531 );
2532 });
2533}
2534
2535async fn init_test_project_with_git(
2536 worktree_path: &str,
2537 cx: &mut TestAppContext,
2538) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
2539 init_test(cx);
2540 let fs = FakeFs::new(cx.executor());
2541 fs.insert_tree(
2542 worktree_path,
2543 serde_json::json!({
2544 ".git": {},
2545 "src": {},
2546 }),
2547 )
2548 .await;
2549 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2550 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
2551 (project, fs)
2552}
2553
2554#[gpui::test]
2555async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
2556 let (project, fs) = init_test_project_with_git("/project", cx).await;
2557
2558 fs.as_fake()
2559 .add_linked_worktree_for_repo(
2560 Path::new("/project/.git"),
2561 false,
2562 git::repository::Worktree {
2563 path: std::path::PathBuf::from("/wt/rosewood"),
2564 ref_name: Some("refs/heads/rosewood".into()),
2565 sha: "abc".into(),
2566 is_main: false,
2567 },
2568 )
2569 .await;
2570
2571 project
2572 .update(cx, |project, cx| project.git_scans_complete(cx))
2573 .await;
2574
2575 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
2576 worktree_project
2577 .update(cx, |p, cx| p.git_scans_complete(cx))
2578 .await;
2579
2580 let (multi_workspace, cx) =
2581 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2582 let sidebar = setup_sidebar(&multi_workspace, cx);
2583
2584 save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
2585 save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
2586
2587 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2588 cx.run_until_parked();
2589
2590 // Search for "rosewood" — should match the worktree name, not the title.
2591 type_in_search(&sidebar, "rosewood", cx);
2592
2593 assert_eq!(
2594 visible_entries_as_strings(&sidebar, cx),
2595 vec!["v [project]", " Fix Bug {rosewood} <== selected"],
2596 );
2597}
2598
2599#[gpui::test]
2600async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
2601 let (project, fs) = init_test_project_with_git("/project", cx).await;
2602
2603 project
2604 .update(cx, |project, cx| project.git_scans_complete(cx))
2605 .await;
2606
2607 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
2608 worktree_project
2609 .update(cx, |p, cx| p.git_scans_complete(cx))
2610 .await;
2611
2612 let (multi_workspace, cx) =
2613 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2614 let sidebar = setup_sidebar(&multi_workspace, cx);
2615
2616 // Save a thread against a worktree path that doesn't exist yet.
2617 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
2618
2619 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2620 cx.run_until_parked();
2621
2622 // Thread is not visible yet — no worktree knows about this path.
2623 assert_eq!(
2624 visible_entries_as_strings(&sidebar, cx),
2625 vec!["v [project]", " [+ New Thread]"]
2626 );
2627
2628 // Now add the worktree to the git state and trigger a rescan.
2629 fs.as_fake()
2630 .add_linked_worktree_for_repo(
2631 Path::new("/project/.git"),
2632 true,
2633 git::repository::Worktree {
2634 path: std::path::PathBuf::from("/wt/rosewood"),
2635 ref_name: Some("refs/heads/rosewood".into()),
2636 sha: "abc".into(),
2637 is_main: false,
2638 },
2639 )
2640 .await;
2641
2642 cx.run_until_parked();
2643
2644 assert_eq!(
2645 visible_entries_as_strings(&sidebar, cx),
2646 vec!["v [project]", " Worktree Thread {rosewood}",]
2647 );
2648}
2649
2650#[gpui::test]
2651async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
2652 init_test(cx);
2653 let fs = FakeFs::new(cx.executor());
2654
2655 // Create the main repo directory (not opened as a workspace yet).
2656 fs.insert_tree(
2657 "/project",
2658 serde_json::json!({
2659 ".git": {
2660 },
2661 "src": {},
2662 }),
2663 )
2664 .await;
2665
2666 // Two worktree checkouts whose .git files point back to the main repo.
2667 fs.add_linked_worktree_for_repo(
2668 Path::new("/project/.git"),
2669 false,
2670 git::repository::Worktree {
2671 path: std::path::PathBuf::from("/wt-feature-a"),
2672 ref_name: Some("refs/heads/feature-a".into()),
2673 sha: "aaa".into(),
2674 is_main: false,
2675 },
2676 )
2677 .await;
2678 fs.add_linked_worktree_for_repo(
2679 Path::new("/project/.git"),
2680 false,
2681 git::repository::Worktree {
2682 path: std::path::PathBuf::from("/wt-feature-b"),
2683 ref_name: Some("refs/heads/feature-b".into()),
2684 sha: "bbb".into(),
2685 is_main: false,
2686 },
2687 )
2688 .await;
2689
2690 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2691
2692 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2693 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
2694
2695 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2696 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2697
2698 // Open both worktrees as workspaces — no main repo yet.
2699 let (multi_workspace, cx) =
2700 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2701 multi_workspace.update_in(cx, |mw, window, cx| {
2702 mw.test_add_workspace(project_b.clone(), window, cx);
2703 });
2704 let sidebar = setup_sidebar(&multi_workspace, cx);
2705
2706 save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
2707 save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
2708
2709 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2710 cx.run_until_parked();
2711
2712 // Without the main repo, each worktree has its own header.
2713 assert_eq!(
2714 visible_entries_as_strings(&sidebar, cx),
2715 vec![
2716 "v [project]",
2717 " Thread A {wt-feature-a}",
2718 " Thread B {wt-feature-b}",
2719 ]
2720 );
2721
2722 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2723 main_project
2724 .update(cx, |p, cx| p.git_scans_complete(cx))
2725 .await;
2726
2727 multi_workspace.update_in(cx, |mw, window, cx| {
2728 mw.test_add_workspace(main_project.clone(), window, cx);
2729 });
2730 cx.run_until_parked();
2731
2732 // Both worktree workspaces should now be absorbed under the main
2733 // repo header, with worktree chips.
2734 assert_eq!(
2735 visible_entries_as_strings(&sidebar, cx),
2736 vec![
2737 "v [project]",
2738 " Thread A {wt-feature-a}",
2739 " Thread B {wt-feature-b}",
2740 ]
2741 );
2742}
2743
2744#[gpui::test]
2745async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
2746 // When a group has two workspaces — one with threads and one
2747 // without — the threadless workspace should appear as a
2748 // "New Thread" button with its worktree chip.
2749 init_test(cx);
2750 let fs = FakeFs::new(cx.executor());
2751
2752 // Main repo with two linked worktrees.
2753 fs.insert_tree(
2754 "/project",
2755 serde_json::json!({
2756 ".git": {},
2757 "src": {},
2758 }),
2759 )
2760 .await;
2761 fs.add_linked_worktree_for_repo(
2762 Path::new("/project/.git"),
2763 false,
2764 git::repository::Worktree {
2765 path: std::path::PathBuf::from("/wt-feature-a"),
2766 ref_name: Some("refs/heads/feature-a".into()),
2767 sha: "aaa".into(),
2768 is_main: false,
2769 },
2770 )
2771 .await;
2772 fs.add_linked_worktree_for_repo(
2773 Path::new("/project/.git"),
2774 false,
2775 git::repository::Worktree {
2776 path: std::path::PathBuf::from("/wt-feature-b"),
2777 ref_name: Some("refs/heads/feature-b".into()),
2778 sha: "bbb".into(),
2779 is_main: false,
2780 },
2781 )
2782 .await;
2783
2784 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2785
2786 // Workspace A: worktree feature-a (has threads).
2787 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2788 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2789
2790 // Workspace B: worktree feature-b (no threads).
2791 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
2792 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2793
2794 let (multi_workspace, cx) =
2795 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2796 multi_workspace.update_in(cx, |mw, window, cx| {
2797 mw.test_add_workspace(project_b.clone(), window, cx);
2798 });
2799 let sidebar = setup_sidebar(&multi_workspace, cx);
2800
2801 // Only save a thread for workspace A.
2802 save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
2803
2804 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2805 cx.run_until_parked();
2806
2807 // Workspace A's thread appears normally. Workspace B (threadless)
2808 // appears as a "New Thread" button with its worktree chip.
2809 assert_eq!(
2810 visible_entries_as_strings(&sidebar, cx),
2811 vec![
2812 "v [project]",
2813 " [+ New Thread {wt-feature-b}]",
2814 " Thread A {wt-feature-a}",
2815 ]
2816 );
2817}
2818
2819#[gpui::test]
2820async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
2821 // A thread created in a workspace with roots from different git
2822 // worktrees should show a chip for each distinct worktree name.
2823 init_test(cx);
2824 let fs = FakeFs::new(cx.executor());
2825
2826 // Two main repos.
2827 fs.insert_tree(
2828 "/project_a",
2829 serde_json::json!({
2830 ".git": {},
2831 "src": {},
2832 }),
2833 )
2834 .await;
2835 fs.insert_tree(
2836 "/project_b",
2837 serde_json::json!({
2838 ".git": {},
2839 "src": {},
2840 }),
2841 )
2842 .await;
2843
2844 // Worktree checkouts.
2845 for repo in &["project_a", "project_b"] {
2846 let git_path = format!("/{repo}/.git");
2847 for branch in &["olivetti", "selectric"] {
2848 fs.add_linked_worktree_for_repo(
2849 Path::new(&git_path),
2850 false,
2851 git::repository::Worktree {
2852 path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
2853 ref_name: Some(format!("refs/heads/{branch}").into()),
2854 sha: "aaa".into(),
2855 is_main: false,
2856 },
2857 )
2858 .await;
2859 }
2860 }
2861
2862 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2863
2864 // Open a workspace with the worktree checkout paths as roots
2865 // (this is the workspace the thread was created in).
2866 let project = project::Project::test(
2867 fs.clone(),
2868 [
2869 "/worktrees/project_a/olivetti/project_a".as_ref(),
2870 "/worktrees/project_b/selectric/project_b".as_ref(),
2871 ],
2872 cx,
2873 )
2874 .await;
2875 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2876
2877 let (multi_workspace, cx) =
2878 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2879 let sidebar = setup_sidebar(&multi_workspace, cx);
2880
2881 // Save a thread under the same paths as the workspace roots.
2882 save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
2883
2884 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2885 cx.run_until_parked();
2886
2887 // Should show two distinct worktree chips.
2888 assert_eq!(
2889 visible_entries_as_strings(&sidebar, cx),
2890 vec![
2891 "v [project_a, project_b]",
2892 " Cross Worktree Thread {olivetti}, {selectric}",
2893 ]
2894 );
2895}
2896
2897#[gpui::test]
2898async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
2899 // When a thread's roots span multiple repos but share the same
2900 // worktree name (e.g. both in "olivetti"), only one chip should
2901 // appear.
2902 init_test(cx);
2903 let fs = FakeFs::new(cx.executor());
2904
2905 fs.insert_tree(
2906 "/project_a",
2907 serde_json::json!({
2908 ".git": {},
2909 "src": {},
2910 }),
2911 )
2912 .await;
2913 fs.insert_tree(
2914 "/project_b",
2915 serde_json::json!({
2916 ".git": {},
2917 "src": {},
2918 }),
2919 )
2920 .await;
2921
2922 for repo in &["project_a", "project_b"] {
2923 let git_path = format!("/{repo}/.git");
2924 fs.add_linked_worktree_for_repo(
2925 Path::new(&git_path),
2926 false,
2927 git::repository::Worktree {
2928 path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
2929 ref_name: Some("refs/heads/olivetti".into()),
2930 sha: "aaa".into(),
2931 is_main: false,
2932 },
2933 )
2934 .await;
2935 }
2936
2937 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2938
2939 let project = project::Project::test(
2940 fs.clone(),
2941 [
2942 "/worktrees/project_a/olivetti/project_a".as_ref(),
2943 "/worktrees/project_b/olivetti/project_b".as_ref(),
2944 ],
2945 cx,
2946 )
2947 .await;
2948 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2949
2950 let (multi_workspace, cx) =
2951 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2952 let sidebar = setup_sidebar(&multi_workspace, cx);
2953
2954 // Thread with roots in both repos' "olivetti" worktrees.
2955 save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
2956
2957 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2958 cx.run_until_parked();
2959
2960 // Both worktree paths have the name "olivetti", so only one chip.
2961 assert_eq!(
2962 visible_entries_as_strings(&sidebar, cx),
2963 vec![
2964 "v [project_a, project_b]",
2965 " Same Branch Thread {olivetti}",
2966 ]
2967 );
2968}
2969
2970#[gpui::test]
2971async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
2972 // When a worktree workspace is absorbed under the main repo, a
2973 // running thread in the worktree's agent panel should still show
2974 // live status (spinner + "(running)") in the sidebar.
2975 agent_ui::test_support::init_test(cx);
2976 cx.update(|cx| {
2977 cx.update_flags(false, vec!["agent-v2".into()]);
2978 ThreadStore::init_global(cx);
2979 ThreadMetadataStore::init_global(cx);
2980 language_model::LanguageModelRegistry::test(cx);
2981 prompt_store::init(cx);
2982 });
2983
2984 let fs = FakeFs::new(cx.executor());
2985
2986 // Main repo with a linked worktree.
2987 fs.insert_tree(
2988 "/project",
2989 serde_json::json!({
2990 ".git": {},
2991 "src": {},
2992 }),
2993 )
2994 .await;
2995
2996 // Worktree checkout pointing back to the main repo.
2997 fs.add_linked_worktree_for_repo(
2998 Path::new("/project/.git"),
2999 false,
3000 git::repository::Worktree {
3001 path: std::path::PathBuf::from("/wt-feature-a"),
3002 ref_name: Some("refs/heads/feature-a".into()),
3003 sha: "aaa".into(),
3004 is_main: false,
3005 },
3006 )
3007 .await;
3008
3009 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3010
3011 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3012 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3013
3014 main_project
3015 .update(cx, |p, cx| p.git_scans_complete(cx))
3016 .await;
3017 worktree_project
3018 .update(cx, |p, cx| p.git_scans_complete(cx))
3019 .await;
3020
3021 // Create the MultiWorkspace with both projects.
3022 let (multi_workspace, cx) =
3023 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3024
3025 let sidebar = setup_sidebar(&multi_workspace, cx);
3026
3027 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3028 mw.test_add_workspace(worktree_project.clone(), window, cx)
3029 });
3030
3031 // Add an agent panel to the worktree workspace so we can run a
3032 // thread inside it.
3033 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3034
3035 // Switch back to the main workspace before setting up the sidebar.
3036 multi_workspace.update_in(cx, |mw, window, cx| {
3037 let workspace = mw.workspaces().next().unwrap().clone();
3038 mw.activate(workspace, window, cx);
3039 });
3040
3041 // Start a thread in the worktree workspace's panel and keep it
3042 // generating (don't resolve it).
3043 let connection = StubAgentConnection::new();
3044 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3045 send_message(&worktree_panel, cx);
3046
3047 let session_id = active_session_id(&worktree_panel, cx);
3048
3049 // Save metadata so the sidebar knows about this thread.
3050 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3051
3052 // Keep the thread generating by sending a chunk without ending
3053 // the turn.
3054 cx.update(|_, cx| {
3055 connection.send_update(
3056 session_id.clone(),
3057 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3058 cx,
3059 );
3060 });
3061 cx.run_until_parked();
3062
3063 // The worktree thread should be absorbed under the main project
3064 // and show live running status.
3065 let entries = visible_entries_as_strings(&sidebar, cx);
3066 assert_eq!(
3067 entries,
3068 vec![
3069 "v [project]",
3070 " [~ Draft]",
3071 " Hello {wt-feature-a} * (running)",
3072 ]
3073 );
3074}
3075
3076#[gpui::test]
3077async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
3078 agent_ui::test_support::init_test(cx);
3079 cx.update(|cx| {
3080 cx.update_flags(false, vec!["agent-v2".into()]);
3081 ThreadStore::init_global(cx);
3082 ThreadMetadataStore::init_global(cx);
3083 language_model::LanguageModelRegistry::test(cx);
3084 prompt_store::init(cx);
3085 });
3086
3087 let fs = FakeFs::new(cx.executor());
3088
3089 fs.insert_tree(
3090 "/project",
3091 serde_json::json!({
3092 ".git": {},
3093 "src": {},
3094 }),
3095 )
3096 .await;
3097
3098 fs.add_linked_worktree_for_repo(
3099 Path::new("/project/.git"),
3100 false,
3101 git::repository::Worktree {
3102 path: std::path::PathBuf::from("/wt-feature-a"),
3103 ref_name: Some("refs/heads/feature-a".into()),
3104 sha: "aaa".into(),
3105 is_main: false,
3106 },
3107 )
3108 .await;
3109
3110 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3111
3112 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3113 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3114
3115 main_project
3116 .update(cx, |p, cx| p.git_scans_complete(cx))
3117 .await;
3118 worktree_project
3119 .update(cx, |p, cx| p.git_scans_complete(cx))
3120 .await;
3121
3122 let (multi_workspace, cx) =
3123 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3124
3125 let sidebar = setup_sidebar(&multi_workspace, cx);
3126
3127 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3128 mw.test_add_workspace(worktree_project.clone(), window, cx)
3129 });
3130
3131 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3132
3133 multi_workspace.update_in(cx, |mw, window, cx| {
3134 let workspace = mw.workspaces().next().unwrap().clone();
3135 mw.activate(workspace, window, cx);
3136 });
3137
3138 let connection = StubAgentConnection::new();
3139 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3140 send_message(&worktree_panel, cx);
3141
3142 let session_id = active_session_id(&worktree_panel, cx);
3143 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3144
3145 cx.update(|_, cx| {
3146 connection.send_update(
3147 session_id.clone(),
3148 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3149 cx,
3150 );
3151 });
3152 cx.run_until_parked();
3153
3154 assert_eq!(
3155 visible_entries_as_strings(&sidebar, cx),
3156 vec![
3157 "v [project]",
3158 " [~ Draft]",
3159 " Hello {wt-feature-a} * (running)",
3160 ]
3161 );
3162
3163 connection.end_turn(session_id, acp::StopReason::EndTurn);
3164 cx.run_until_parked();
3165
3166 assert_eq!(
3167 visible_entries_as_strings(&sidebar, cx),
3168 vec!["v [project]", " [~ Draft]", " Hello {wt-feature-a} * (!)",]
3169 );
3170}
3171
3172#[gpui::test]
3173async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
3174 init_test(cx);
3175 let fs = FakeFs::new(cx.executor());
3176
3177 fs.insert_tree(
3178 "/project",
3179 serde_json::json!({
3180 ".git": {},
3181 "src": {},
3182 }),
3183 )
3184 .await;
3185
3186 fs.add_linked_worktree_for_repo(
3187 Path::new("/project/.git"),
3188 false,
3189 git::repository::Worktree {
3190 path: std::path::PathBuf::from("/wt-feature-a"),
3191 ref_name: Some("refs/heads/feature-a".into()),
3192 sha: "aaa".into(),
3193 is_main: false,
3194 },
3195 )
3196 .await;
3197
3198 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3199
3200 // Only open the main repo — no workspace for the worktree.
3201 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3202 main_project
3203 .update(cx, |p, cx| p.git_scans_complete(cx))
3204 .await;
3205
3206 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3207 worktree_project
3208 .update(cx, |p, cx| p.git_scans_complete(cx))
3209 .await;
3210
3211 let (multi_workspace, cx) =
3212 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3213 let sidebar = setup_sidebar(&multi_workspace, cx);
3214
3215 // Save a thread for the worktree path (no workspace for it).
3216 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3217
3218 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3219 cx.run_until_parked();
3220
3221 // Thread should appear under the main repo with a worktree chip.
3222 assert_eq!(
3223 visible_entries_as_strings(&sidebar, cx),
3224 vec!["v [project]", " WT Thread {wt-feature-a}"],
3225 );
3226
3227 // Only 1 workspace should exist.
3228 assert_eq!(
3229 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3230 1,
3231 );
3232
3233 // Focus the sidebar and select the worktree thread.
3234 focus_sidebar(&sidebar, cx);
3235 sidebar.update_in(cx, |sidebar, _window, _cx| {
3236 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3237 });
3238
3239 // Confirm to open the worktree thread.
3240 cx.dispatch_action(Confirm);
3241 cx.run_until_parked();
3242
3243 // A new workspace should have been created for the worktree path.
3244 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
3245 assert_eq!(
3246 mw.workspaces().count(),
3247 2,
3248 "confirming a worktree thread without a workspace should open one",
3249 );
3250 mw.workspaces().nth(1).unwrap().clone()
3251 });
3252
3253 let new_path_list =
3254 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
3255 assert_eq!(
3256 new_path_list,
3257 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
3258 "the new workspace should have been opened for the worktree path",
3259 );
3260}
3261
3262#[gpui::test]
3263async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
3264 cx: &mut TestAppContext,
3265) {
3266 init_test(cx);
3267 let fs = FakeFs::new(cx.executor());
3268
3269 fs.insert_tree(
3270 "/project",
3271 serde_json::json!({
3272 ".git": {},
3273 "src": {},
3274 }),
3275 )
3276 .await;
3277
3278 fs.add_linked_worktree_for_repo(
3279 Path::new("/project/.git"),
3280 false,
3281 git::repository::Worktree {
3282 path: std::path::PathBuf::from("/wt-feature-a"),
3283 ref_name: Some("refs/heads/feature-a".into()),
3284 sha: "aaa".into(),
3285 is_main: false,
3286 },
3287 )
3288 .await;
3289
3290 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3291
3292 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3293 main_project
3294 .update(cx, |p, cx| p.git_scans_complete(cx))
3295 .await;
3296
3297 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3298 worktree_project
3299 .update(cx, |p, cx| p.git_scans_complete(cx))
3300 .await;
3301
3302 let (multi_workspace, cx) =
3303 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3304 let sidebar = setup_sidebar(&multi_workspace, cx);
3305
3306 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3307
3308 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3309 cx.run_until_parked();
3310
3311 assert_eq!(
3312 visible_entries_as_strings(&sidebar, cx),
3313 vec!["v [project]", " WT Thread {wt-feature-a}"],
3314 );
3315
3316 focus_sidebar(&sidebar, cx);
3317 sidebar.update_in(cx, |sidebar, _window, _cx| {
3318 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3319 });
3320
3321 let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
3322 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
3323 if let ListEntry::ProjectHeader { label, .. } = entry {
3324 Some(label.as_ref())
3325 } else {
3326 None
3327 }
3328 });
3329
3330 let Some(project_header) = project_headers.next() else {
3331 panic!("expected exactly one sidebar project header named `project`, found none");
3332 };
3333 assert_eq!(
3334 project_header, "project",
3335 "expected the only sidebar project header to be `project`"
3336 );
3337 if let Some(unexpected_header) = project_headers.next() {
3338 panic!(
3339 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
3340 );
3341 }
3342
3343 let mut saw_expected_thread = false;
3344 for entry in &sidebar.contents.entries {
3345 match entry {
3346 ListEntry::ProjectHeader { label, .. } => {
3347 assert_eq!(
3348 label.as_ref(),
3349 "project",
3350 "expected the only sidebar project header to be `project`"
3351 );
3352 }
3353 ListEntry::Thread(thread)
3354 if thread.metadata.title.as_ref() == "WT Thread"
3355 && thread.worktrees.first().map(|wt| wt.name.as_ref())
3356 == Some("wt-feature-a") =>
3357 {
3358 saw_expected_thread = true;
3359 }
3360 ListEntry::Thread(thread) => {
3361 let title = thread.metadata.title.as_ref();
3362 let worktree_name = thread
3363 .worktrees
3364 .first()
3365 .map(|wt| wt.name.as_ref())
3366 .unwrap_or("<none>");
3367 panic!(
3368 "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`"
3369 );
3370 }
3371 ListEntry::ViewMore { .. } => {
3372 panic!("unexpected `View More` entry while opening linked worktree thread");
3373 }
3374 ListEntry::DraftThread { .. } | ListEntry::NewThread { .. } => {}
3375 }
3376 }
3377
3378 assert!(
3379 saw_expected_thread,
3380 "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
3381 );
3382 };
3383
3384 sidebar
3385 .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
3386 .detach();
3387
3388 let window = cx.windows()[0];
3389 cx.update_window(window, |_, window, cx| {
3390 window.dispatch_action(Confirm.boxed_clone(), cx);
3391 })
3392 .unwrap();
3393
3394 cx.run_until_parked();
3395
3396 sidebar.update(cx, assert_sidebar_state);
3397}
3398
3399#[gpui::test]
3400async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
3401 cx: &mut TestAppContext,
3402) {
3403 init_test(cx);
3404 let fs = FakeFs::new(cx.executor());
3405
3406 fs.insert_tree(
3407 "/project",
3408 serde_json::json!({
3409 ".git": {},
3410 "src": {},
3411 }),
3412 )
3413 .await;
3414
3415 fs.add_linked_worktree_for_repo(
3416 Path::new("/project/.git"),
3417 false,
3418 git::repository::Worktree {
3419 path: std::path::PathBuf::from("/wt-feature-a"),
3420 ref_name: Some("refs/heads/feature-a".into()),
3421 sha: "aaa".into(),
3422 is_main: false,
3423 },
3424 )
3425 .await;
3426
3427 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3428
3429 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3430 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3431
3432 main_project
3433 .update(cx, |p, cx| p.git_scans_complete(cx))
3434 .await;
3435 worktree_project
3436 .update(cx, |p, cx| p.git_scans_complete(cx))
3437 .await;
3438
3439 let (multi_workspace, cx) =
3440 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3441
3442 let sidebar = setup_sidebar(&multi_workspace, cx);
3443
3444 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3445 mw.test_add_workspace(worktree_project.clone(), window, cx)
3446 });
3447
3448 // Activate the main workspace before setting up the sidebar.
3449 let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3450 let workspace = mw.workspaces().next().unwrap().clone();
3451 mw.activate(workspace.clone(), window, cx);
3452 workspace
3453 });
3454
3455 save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
3456 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3457
3458 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3459 cx.run_until_parked();
3460
3461 // The worktree workspace should be absorbed under the main repo.
3462 let entries = visible_entries_as_strings(&sidebar, cx);
3463 assert_eq!(entries.len(), 3);
3464 assert_eq!(entries[0], "v [project]");
3465 assert!(entries.contains(&" Main Thread".to_string()));
3466 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
3467
3468 let wt_thread_index = entries
3469 .iter()
3470 .position(|e| e.contains("WT Thread"))
3471 .expect("should find the worktree thread entry");
3472
3473 assert_eq!(
3474 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3475 main_workspace,
3476 "main workspace should be active initially"
3477 );
3478
3479 // Focus the sidebar and select the absorbed worktree thread.
3480 focus_sidebar(&sidebar, cx);
3481 sidebar.update_in(cx, |sidebar, _window, _cx| {
3482 sidebar.selection = Some(wt_thread_index);
3483 });
3484
3485 // Confirm to activate the worktree thread.
3486 cx.dispatch_action(Confirm);
3487 cx.run_until_parked();
3488
3489 // The worktree workspace should now be active, not the main one.
3490 let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3491 assert_eq!(
3492 active_workspace, worktree_workspace,
3493 "clicking an absorbed worktree thread should activate the worktree workspace"
3494 );
3495}
3496
3497#[gpui::test]
3498async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
3499 cx: &mut TestAppContext,
3500) {
3501 // Thread has saved metadata in ThreadStore. A matching workspace is
3502 // already open. Expected: activates the matching workspace.
3503 init_test(cx);
3504 let fs = FakeFs::new(cx.executor());
3505 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3506 .await;
3507 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3508 .await;
3509 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3510
3511 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3512 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3513
3514 let (multi_workspace, cx) =
3515 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3516
3517 let sidebar = setup_sidebar(&multi_workspace, cx);
3518
3519 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3520 mw.test_add_workspace(project_b.clone(), window, cx)
3521 });
3522 let workspace_a =
3523 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
3524
3525 // Save a thread with path_list pointing to project-b.
3526 let session_id = acp::SessionId::new(Arc::from("archived-1"));
3527 save_test_thread_metadata(&session_id, &project_b, cx).await;
3528
3529 // Ensure workspace A is active.
3530 multi_workspace.update_in(cx, |mw, window, cx| {
3531 let workspace = mw.workspaces().next().unwrap().clone();
3532 mw.activate(workspace, window, cx);
3533 });
3534 cx.run_until_parked();
3535 assert_eq!(
3536 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3537 workspace_a
3538 );
3539
3540 // Call activate_archived_thread – should resolve saved paths and
3541 // switch to the workspace for project-b.
3542 sidebar.update_in(cx, |sidebar, window, cx| {
3543 sidebar.activate_archived_thread(
3544 ThreadMetadata {
3545 session_id: session_id.clone(),
3546 agent_id: agent::ZED_AGENT_ID.clone(),
3547 title: "Archived Thread".into(),
3548 updated_at: Utc::now(),
3549 created_at: None,
3550 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3551 main_worktree_paths: PathList::default(),
3552 archived: false,
3553 },
3554 window,
3555 cx,
3556 );
3557 });
3558 cx.run_until_parked();
3559
3560 assert_eq!(
3561 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3562 workspace_b,
3563 "should have activated the workspace matching the saved path_list"
3564 );
3565}
3566
3567#[gpui::test]
3568async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
3569 cx: &mut TestAppContext,
3570) {
3571 // Thread has no saved metadata but session_info has cwd. A matching
3572 // workspace is open. Expected: uses cwd to find and activate it.
3573 init_test(cx);
3574 let fs = FakeFs::new(cx.executor());
3575 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3576 .await;
3577 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3578 .await;
3579 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3580
3581 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3582 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3583
3584 let (multi_workspace, cx) =
3585 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3586
3587 let sidebar = setup_sidebar(&multi_workspace, cx);
3588
3589 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3590 mw.test_add_workspace(project_b, window, cx)
3591 });
3592 let workspace_a =
3593 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
3594
3595 // Start with workspace A active.
3596 multi_workspace.update_in(cx, |mw, window, cx| {
3597 let workspace = mw.workspaces().next().unwrap().clone();
3598 mw.activate(workspace, window, cx);
3599 });
3600 cx.run_until_parked();
3601 assert_eq!(
3602 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3603 workspace_a
3604 );
3605
3606 // No thread saved to the store – cwd is the only path hint.
3607 sidebar.update_in(cx, |sidebar, window, cx| {
3608 sidebar.activate_archived_thread(
3609 ThreadMetadata {
3610 session_id: acp::SessionId::new(Arc::from("unknown-session")),
3611 agent_id: agent::ZED_AGENT_ID.clone(),
3612 title: "CWD Thread".into(),
3613 updated_at: Utc::now(),
3614 created_at: None,
3615 folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]),
3616 main_worktree_paths: PathList::default(),
3617 archived: false,
3618 },
3619 window,
3620 cx,
3621 );
3622 });
3623 cx.run_until_parked();
3624
3625 assert_eq!(
3626 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3627 workspace_b,
3628 "should have activated the workspace matching the cwd"
3629 );
3630}
3631
3632#[gpui::test]
3633async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
3634 cx: &mut TestAppContext,
3635) {
3636 // Thread has no saved metadata and no cwd. Expected: falls back to
3637 // the currently active workspace.
3638 init_test(cx);
3639 let fs = FakeFs::new(cx.executor());
3640 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3641 .await;
3642 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3643 .await;
3644 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3645
3646 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3647 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3648
3649 let (multi_workspace, cx) =
3650 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3651
3652 let sidebar = setup_sidebar(&multi_workspace, cx);
3653
3654 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3655 mw.test_add_workspace(project_b, window, cx)
3656 });
3657
3658 // Activate workspace B (index 1) to make it the active one.
3659 multi_workspace.update_in(cx, |mw, window, cx| {
3660 let workspace = mw.workspaces().nth(1).unwrap().clone();
3661 mw.activate(workspace, window, cx);
3662 });
3663 cx.run_until_parked();
3664 assert_eq!(
3665 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3666 workspace_b
3667 );
3668
3669 // No saved thread, no cwd – should fall back to the active workspace.
3670 sidebar.update_in(cx, |sidebar, window, cx| {
3671 sidebar.activate_archived_thread(
3672 ThreadMetadata {
3673 session_id: acp::SessionId::new(Arc::from("no-context-session")),
3674 agent_id: agent::ZED_AGENT_ID.clone(),
3675 title: "Contextless Thread".into(),
3676 updated_at: Utc::now(),
3677 created_at: None,
3678 folder_paths: PathList::default(),
3679 main_worktree_paths: PathList::default(),
3680 archived: false,
3681 },
3682 window,
3683 cx,
3684 );
3685 });
3686 cx.run_until_parked();
3687
3688 assert_eq!(
3689 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3690 workspace_b,
3691 "should have stayed on the active workspace when no path info is available"
3692 );
3693}
3694
3695#[gpui::test]
3696async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
3697 // Thread has saved metadata pointing to a path with no open workspace.
3698 // Expected: opens a new workspace for that path.
3699 init_test(cx);
3700 let fs = FakeFs::new(cx.executor());
3701 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3702 .await;
3703 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3704 .await;
3705 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3706
3707 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3708
3709 let (multi_workspace, cx) =
3710 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3711
3712 let sidebar = setup_sidebar(&multi_workspace, cx);
3713
3714 // Save a thread with path_list pointing to project-b – which has no
3715 // open workspace.
3716 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
3717 let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
3718
3719 assert_eq!(
3720 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3721 1,
3722 "should start with one workspace"
3723 );
3724
3725 sidebar.update_in(cx, |sidebar, window, cx| {
3726 sidebar.activate_archived_thread(
3727 ThreadMetadata {
3728 session_id: session_id.clone(),
3729 agent_id: agent::ZED_AGENT_ID.clone(),
3730 title: "New WS Thread".into(),
3731 updated_at: Utc::now(),
3732 created_at: None,
3733 folder_paths: path_list_b,
3734 main_worktree_paths: PathList::default(),
3735 archived: false,
3736 },
3737 window,
3738 cx,
3739 );
3740 });
3741 cx.run_until_parked();
3742
3743 assert_eq!(
3744 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3745 2,
3746 "should have opened a second workspace for the archived thread's saved paths"
3747 );
3748}
3749
3750#[gpui::test]
3751async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
3752 init_test(cx);
3753 let fs = FakeFs::new(cx.executor());
3754 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3755 .await;
3756 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3757 .await;
3758 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3759
3760 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3761 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3762
3763 let multi_workspace_a =
3764 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3765 let multi_workspace_b =
3766 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
3767
3768 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3769 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
3770
3771 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
3772 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
3773
3774 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
3775 let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
3776
3777 let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
3778
3779 sidebar.update_in(cx_a, |sidebar, window, cx| {
3780 sidebar.activate_archived_thread(
3781 ThreadMetadata {
3782 session_id: session_id.clone(),
3783 agent_id: agent::ZED_AGENT_ID.clone(),
3784 title: "Cross Window Thread".into(),
3785 updated_at: Utc::now(),
3786 created_at: None,
3787 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3788 main_worktree_paths: PathList::default(),
3789 archived: false,
3790 },
3791 window,
3792 cx,
3793 );
3794 });
3795 cx_a.run_until_parked();
3796
3797 assert_eq!(
3798 multi_workspace_a
3799 .read_with(cx_a, |mw, _| mw.workspaces().count())
3800 .unwrap(),
3801 1,
3802 "should not add the other window's workspace into the current window"
3803 );
3804 assert_eq!(
3805 multi_workspace_b
3806 .read_with(cx_a, |mw, _| mw.workspaces().count())
3807 .unwrap(),
3808 1,
3809 "should reuse the existing workspace in the other window"
3810 );
3811 assert!(
3812 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
3813 "should activate the window that already owns the matching workspace"
3814 );
3815 sidebar.read_with(cx_a, |sidebar, _| {
3816 assert!(
3817 !matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id: id, .. }) if id == &session_id),
3818 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
3819 );
3820 });
3821}
3822
3823#[gpui::test]
3824async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
3825 cx: &mut TestAppContext,
3826) {
3827 init_test(cx);
3828 let fs = FakeFs::new(cx.executor());
3829 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3830 .await;
3831 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3832 .await;
3833 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3834
3835 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3836 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3837
3838 let multi_workspace_a =
3839 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3840 let multi_workspace_b =
3841 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
3842
3843 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3844 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
3845
3846 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
3847 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
3848
3849 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
3850 let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
3851 let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
3852 let _panel_b = add_agent_panel(&workspace_b, cx_b);
3853
3854 let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
3855
3856 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
3857 sidebar.activate_archived_thread(
3858 ThreadMetadata {
3859 session_id: session_id.clone(),
3860 agent_id: agent::ZED_AGENT_ID.clone(),
3861 title: "Cross Window Thread".into(),
3862 updated_at: Utc::now(),
3863 created_at: None,
3864 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3865 main_worktree_paths: PathList::default(),
3866 archived: false,
3867 },
3868 window,
3869 cx,
3870 );
3871 });
3872 cx_a.run_until_parked();
3873
3874 assert_eq!(
3875 multi_workspace_a
3876 .read_with(cx_a, |mw, _| mw.workspaces().count())
3877 .unwrap(),
3878 1,
3879 "should not add the other window's workspace into the current window"
3880 );
3881 assert_eq!(
3882 multi_workspace_b
3883 .read_with(cx_a, |mw, _| mw.workspaces().count())
3884 .unwrap(),
3885 1,
3886 "should reuse the existing workspace in the other window"
3887 );
3888 assert!(
3889 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
3890 "should activate the window that already owns the matching workspace"
3891 );
3892 sidebar_a.read_with(cx_a, |sidebar, _| {
3893 assert!(
3894 !matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id: id, .. }) if id == &session_id),
3895 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
3896 );
3897 });
3898 sidebar_b.read_with(cx_b, |sidebar, _| {
3899 assert_active_thread(
3900 sidebar,
3901 &session_id,
3902 "target window's sidebar should eagerly focus the activated archived thread",
3903 );
3904 });
3905}
3906
3907#[gpui::test]
3908async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
3909 cx: &mut TestAppContext,
3910) {
3911 init_test(cx);
3912 let fs = FakeFs::new(cx.executor());
3913 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3914 .await;
3915 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3916
3917 let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3918 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3919
3920 let multi_workspace_b =
3921 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
3922 let multi_workspace_a =
3923 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3924
3925 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3926 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
3927
3928 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
3929 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
3930
3931 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
3932 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
3933
3934 let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
3935
3936 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
3937 sidebar.activate_archived_thread(
3938 ThreadMetadata {
3939 session_id: session_id.clone(),
3940 agent_id: agent::ZED_AGENT_ID.clone(),
3941 title: "Current Window Thread".into(),
3942 updated_at: Utc::now(),
3943 created_at: None,
3944 folder_paths: PathList::new(&[PathBuf::from("/project-a")]),
3945 main_worktree_paths: PathList::default(),
3946 archived: false,
3947 },
3948 window,
3949 cx,
3950 );
3951 });
3952 cx_a.run_until_parked();
3953
3954 assert!(
3955 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
3956 "should keep activation in the current window when it already has a matching workspace"
3957 );
3958 sidebar_a.read_with(cx_a, |sidebar, _| {
3959 assert_active_thread(
3960 sidebar,
3961 &session_id,
3962 "current window's sidebar should eagerly focus the activated archived thread",
3963 );
3964 });
3965 assert_eq!(
3966 multi_workspace_a
3967 .read_with(cx_a, |mw, _| mw.workspaces().count())
3968 .unwrap(),
3969 1,
3970 "current window should continue reusing its existing workspace"
3971 );
3972 assert_eq!(
3973 multi_workspace_b
3974 .read_with(cx_a, |mw, _| mw.workspaces().count())
3975 .unwrap(),
3976 1,
3977 "other windows should not be activated just because they also match the saved paths"
3978 );
3979}
3980
3981#[gpui::test]
3982async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
3983 // Regression test: archive_thread previously always loaded the next thread
3984 // through group_workspace (the main workspace's ProjectHeader), even when
3985 // the next thread belonged to an absorbed linked-worktree workspace. That
3986 // caused the worktree thread to be loaded in the main panel, which bound it
3987 // to the main project and corrupted its stored folder_paths.
3988 //
3989 // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
3990 // falling back to group_workspace only for Closed workspaces.
3991 agent_ui::test_support::init_test(cx);
3992 cx.update(|cx| {
3993 cx.update_flags(false, vec!["agent-v2".into()]);
3994 ThreadStore::init_global(cx);
3995 ThreadMetadataStore::init_global(cx);
3996 language_model::LanguageModelRegistry::test(cx);
3997 prompt_store::init(cx);
3998 });
3999
4000 let fs = FakeFs::new(cx.executor());
4001
4002 fs.insert_tree(
4003 "/project",
4004 serde_json::json!({
4005 ".git": {},
4006 "src": {},
4007 }),
4008 )
4009 .await;
4010
4011 fs.add_linked_worktree_for_repo(
4012 Path::new("/project/.git"),
4013 false,
4014 git::repository::Worktree {
4015 path: std::path::PathBuf::from("/wt-feature-a"),
4016 ref_name: Some("refs/heads/feature-a".into()),
4017 sha: "aaa".into(),
4018 is_main: false,
4019 },
4020 )
4021 .await;
4022
4023 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4024
4025 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4026 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4027
4028 main_project
4029 .update(cx, |p, cx| p.git_scans_complete(cx))
4030 .await;
4031 worktree_project
4032 .update(cx, |p, cx| p.git_scans_complete(cx))
4033 .await;
4034
4035 let (multi_workspace, cx) =
4036 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4037
4038 let sidebar = setup_sidebar(&multi_workspace, cx);
4039
4040 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4041 mw.test_add_workspace(worktree_project.clone(), window, cx)
4042 });
4043
4044 // Activate main workspace so the sidebar tracks the main panel.
4045 multi_workspace.update_in(cx, |mw, window, cx| {
4046 let workspace = mw.workspaces().next().unwrap().clone();
4047 mw.activate(workspace, window, cx);
4048 });
4049
4050 let main_workspace =
4051 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4052 let main_panel = add_agent_panel(&main_workspace, cx);
4053 let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
4054
4055 // Open Thread 2 in the main panel and keep it running.
4056 let connection = StubAgentConnection::new();
4057 open_thread_with_connection(&main_panel, connection.clone(), cx);
4058 send_message(&main_panel, cx);
4059
4060 let thread2_session_id = active_session_id(&main_panel, cx);
4061
4062 cx.update(|_, cx| {
4063 connection.send_update(
4064 thread2_session_id.clone(),
4065 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4066 cx,
4067 );
4068 });
4069
4070 // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
4071 save_thread_metadata(
4072 thread2_session_id.clone(),
4073 "Thread 2".into(),
4074 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4075 None,
4076 &main_project,
4077 cx,
4078 );
4079
4080 // Save thread 1's metadata with the worktree path and an older timestamp so
4081 // it sorts below thread 2. archive_thread will find it as the "next" candidate.
4082 let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
4083 save_thread_metadata(
4084 thread1_session_id,
4085 "Thread 1".into(),
4086 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4087 None,
4088 &worktree_project,
4089 cx,
4090 );
4091
4092 cx.run_until_parked();
4093
4094 // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
4095 let entries_before = visible_entries_as_strings(&sidebar, cx);
4096 assert!(
4097 entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
4098 "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
4099 entries_before
4100 );
4101
4102 // The sidebar should track T2 as the focused thread (derived from the
4103 // main panel's active view).
4104 sidebar.read_with(cx, |s, _| {
4105 assert_active_thread(
4106 s,
4107 &thread2_session_id,
4108 "focused thread should be Thread 2 before archiving",
4109 );
4110 });
4111
4112 // Archive thread 2.
4113 sidebar.update_in(cx, |sidebar, window, cx| {
4114 sidebar.archive_thread(&thread2_session_id, window, cx);
4115 });
4116
4117 cx.run_until_parked();
4118
4119 // The main panel's active thread must still be thread 2.
4120 let main_active = main_panel.read_with(cx, |panel, cx| {
4121 panel
4122 .active_agent_thread(cx)
4123 .map(|t| t.read(cx).session_id().clone())
4124 });
4125 assert_eq!(
4126 main_active,
4127 Some(thread2_session_id.clone()),
4128 "main panel should not have been taken over by loading the linked-worktree thread T1; \
4129 before the fix, archive_thread used group_workspace instead of next.workspace, \
4130 causing T1 to be loaded in the wrong panel"
4131 );
4132
4133 // Thread 1 should still appear in the sidebar with its worktree chip
4134 // (Thread 2 was archived so it is gone from the list).
4135 let entries_after = visible_entries_as_strings(&sidebar, cx);
4136 assert!(
4137 entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
4138 "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
4139 entries_after
4140 );
4141}
4142
4143#[gpui::test]
4144async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
4145 // When a multi-root workspace (e.g. [/other, /project]) shares a
4146 // repo with a single-root workspace (e.g. [/project]), linked
4147 // worktree threads from the shared repo should only appear under
4148 // the dedicated group [project], not under [other, project].
4149 init_test(cx);
4150 let fs = FakeFs::new(cx.executor());
4151
4152 // Two independent repos, each with their own git history.
4153 fs.insert_tree(
4154 "/project",
4155 serde_json::json!({
4156 ".git": {},
4157 "src": {},
4158 }),
4159 )
4160 .await;
4161 fs.insert_tree(
4162 "/other",
4163 serde_json::json!({
4164 ".git": {},
4165 "src": {},
4166 }),
4167 )
4168 .await;
4169
4170 // Register the linked worktree in the main repo.
4171 fs.add_linked_worktree_for_repo(
4172 Path::new("/project/.git"),
4173 false,
4174 git::repository::Worktree {
4175 path: std::path::PathBuf::from("/wt-feature-a"),
4176 ref_name: Some("refs/heads/feature-a".into()),
4177 sha: "aaa".into(),
4178 is_main: false,
4179 },
4180 )
4181 .await;
4182
4183 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4184
4185 // Workspace 1: just /project.
4186 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4187 project_only
4188 .update(cx, |p, cx| p.git_scans_complete(cx))
4189 .await;
4190
4191 // Workspace 2: /other and /project together (multi-root).
4192 let multi_root =
4193 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
4194 multi_root
4195 .update(cx, |p, cx| p.git_scans_complete(cx))
4196 .await;
4197
4198 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4199 worktree_project
4200 .update(cx, |p, cx| p.git_scans_complete(cx))
4201 .await;
4202
4203 let (multi_workspace, cx) =
4204 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
4205 let sidebar = setup_sidebar(&multi_workspace, cx);
4206 multi_workspace.update_in(cx, |mw, window, cx| {
4207 mw.test_add_workspace(multi_root.clone(), window, cx);
4208 });
4209
4210 // Save a thread under the linked worktree path.
4211 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
4212
4213 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4214 cx.run_until_parked();
4215
4216 // The thread should appear only under [project] (the dedicated
4217 // group for the /project repo), not under [other, project].
4218 assert_eq!(
4219 visible_entries_as_strings(&sidebar, cx),
4220 vec![
4221 "v [other, project]",
4222 " [+ New Thread]",
4223 "v [project]",
4224 " Worktree Thread {wt-feature-a}",
4225 ]
4226 );
4227}
4228
4229#[gpui::test]
4230async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
4231 let project = init_test_project_with_agent_panel("/my-project", cx).await;
4232 let (multi_workspace, cx) =
4233 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4234 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4235
4236 let switcher_ids =
4237 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
4238 sidebar.read_with(cx, |sidebar, cx| {
4239 let switcher = sidebar
4240 .thread_switcher
4241 .as_ref()
4242 .expect("switcher should be open");
4243 switcher
4244 .read(cx)
4245 .entries()
4246 .iter()
4247 .map(|e| e.session_id.clone())
4248 .collect()
4249 })
4250 };
4251
4252 let switcher_selected_id =
4253 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
4254 sidebar.read_with(cx, |sidebar, cx| {
4255 let switcher = sidebar
4256 .thread_switcher
4257 .as_ref()
4258 .expect("switcher should be open");
4259 let s = switcher.read(cx);
4260 s.selected_entry()
4261 .expect("should have selection")
4262 .session_id
4263 .clone()
4264 })
4265 };
4266
4267 // ── Setup: create three threads with distinct created_at times ──────
4268 // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
4269 // We send messages in each so they also get last_message_sent_or_queued timestamps.
4270 let connection_c = StubAgentConnection::new();
4271 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4272 acp::ContentChunk::new("Done C".into()),
4273 )]);
4274 open_thread_with_connection(&panel, connection_c, cx);
4275 send_message(&panel, cx);
4276 let session_id_c = active_session_id(&panel, cx);
4277 save_thread_metadata(
4278 session_id_c.clone(),
4279 "Thread C".into(),
4280 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4281 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
4282 &project,
4283 cx,
4284 );
4285
4286 let connection_b = StubAgentConnection::new();
4287 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4288 acp::ContentChunk::new("Done B".into()),
4289 )]);
4290 open_thread_with_connection(&panel, connection_b, cx);
4291 send_message(&panel, cx);
4292 let session_id_b = active_session_id(&panel, cx);
4293 save_thread_metadata(
4294 session_id_b.clone(),
4295 "Thread B".into(),
4296 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4297 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
4298 &project,
4299 cx,
4300 );
4301
4302 let connection_a = StubAgentConnection::new();
4303 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4304 acp::ContentChunk::new("Done A".into()),
4305 )]);
4306 open_thread_with_connection(&panel, connection_a, cx);
4307 send_message(&panel, cx);
4308 let session_id_a = active_session_id(&panel, cx);
4309 save_thread_metadata(
4310 session_id_a.clone(),
4311 "Thread A".into(),
4312 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
4313 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
4314 &project,
4315 cx,
4316 );
4317
4318 // All three threads are now live. Thread A was opened last, so it's
4319 // the one being viewed. Opening each thread called record_thread_access,
4320 // so all three have last_accessed_at set.
4321 // Access order is: A (most recent), B, C (oldest).
4322
4323 // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
4324 focus_sidebar(&sidebar, cx);
4325 sidebar.update_in(cx, |sidebar, window, cx| {
4326 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4327 });
4328 cx.run_until_parked();
4329
4330 // All three have last_accessed_at, so they sort by access time.
4331 // A was accessed most recently (it's the currently viewed thread),
4332 // then B, then C.
4333 assert_eq!(
4334 switcher_ids(&sidebar, cx),
4335 vec![
4336 session_id_a.clone(),
4337 session_id_b.clone(),
4338 session_id_c.clone()
4339 ],
4340 );
4341 // First ctrl-tab selects the second entry (B).
4342 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
4343
4344 // Dismiss the switcher without confirming.
4345 sidebar.update_in(cx, |sidebar, _window, cx| {
4346 sidebar.dismiss_thread_switcher(cx);
4347 });
4348 cx.run_until_parked();
4349
4350 // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
4351 sidebar.update_in(cx, |sidebar, window, cx| {
4352 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4353 });
4354 cx.run_until_parked();
4355
4356 // Cycle twice to land on Thread C (index 2).
4357 sidebar.read_with(cx, |sidebar, cx| {
4358 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4359 assert_eq!(switcher.read(cx).selected_index(), 1);
4360 });
4361 sidebar.update_in(cx, |sidebar, _window, cx| {
4362 sidebar
4363 .thread_switcher
4364 .as_ref()
4365 .unwrap()
4366 .update(cx, |s, cx| s.cycle_selection(cx));
4367 });
4368 cx.run_until_parked();
4369 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
4370
4371 assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
4372
4373 // Confirm on Thread C.
4374 sidebar.update_in(cx, |sidebar, window, cx| {
4375 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4376 let focus = switcher.focus_handle(cx);
4377 focus.dispatch_action(&menu::Confirm, window, cx);
4378 });
4379 cx.run_until_parked();
4380
4381 // Switcher should be dismissed after confirm.
4382 sidebar.read_with(cx, |sidebar, _cx| {
4383 assert!(
4384 sidebar.thread_switcher.is_none(),
4385 "switcher should be dismissed"
4386 );
4387 });
4388
4389 sidebar.update(cx, |sidebar, _cx| {
4390 let last_accessed = sidebar
4391 .thread_last_accessed
4392 .keys()
4393 .cloned()
4394 .collect::<Vec<_>>();
4395 assert_eq!(last_accessed.len(), 1);
4396 assert!(last_accessed.contains(&session_id_c));
4397 assert!(
4398 sidebar
4399 .active_entry
4400 .as_ref()
4401 .expect("active_entry should be set")
4402 .is_active_thread(&session_id_c)
4403 );
4404 });
4405
4406 sidebar.update_in(cx, |sidebar, window, cx| {
4407 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4408 });
4409 cx.run_until_parked();
4410
4411 assert_eq!(
4412 switcher_ids(&sidebar, cx),
4413 vec![
4414 session_id_c.clone(),
4415 session_id_a.clone(),
4416 session_id_b.clone()
4417 ],
4418 );
4419
4420 // Confirm on Thread A.
4421 sidebar.update_in(cx, |sidebar, window, cx| {
4422 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4423 let focus = switcher.focus_handle(cx);
4424 focus.dispatch_action(&menu::Confirm, window, cx);
4425 });
4426 cx.run_until_parked();
4427
4428 sidebar.update(cx, |sidebar, _cx| {
4429 let last_accessed = sidebar
4430 .thread_last_accessed
4431 .keys()
4432 .cloned()
4433 .collect::<Vec<_>>();
4434 assert_eq!(last_accessed.len(), 2);
4435 assert!(last_accessed.contains(&session_id_c));
4436 assert!(last_accessed.contains(&session_id_a));
4437 assert!(
4438 sidebar
4439 .active_entry
4440 .as_ref()
4441 .expect("active_entry should be set")
4442 .is_active_thread(&session_id_a)
4443 );
4444 });
4445
4446 sidebar.update_in(cx, |sidebar, window, cx| {
4447 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4448 });
4449 cx.run_until_parked();
4450
4451 assert_eq!(
4452 switcher_ids(&sidebar, cx),
4453 vec![
4454 session_id_a.clone(),
4455 session_id_c.clone(),
4456 session_id_b.clone(),
4457 ],
4458 );
4459
4460 sidebar.update_in(cx, |sidebar, _window, cx| {
4461 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4462 switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
4463 });
4464 cx.run_until_parked();
4465
4466 // Confirm on Thread B.
4467 sidebar.update_in(cx, |sidebar, window, cx| {
4468 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4469 let focus = switcher.focus_handle(cx);
4470 focus.dispatch_action(&menu::Confirm, window, cx);
4471 });
4472 cx.run_until_parked();
4473
4474 sidebar.update(cx, |sidebar, _cx| {
4475 let last_accessed = sidebar
4476 .thread_last_accessed
4477 .keys()
4478 .cloned()
4479 .collect::<Vec<_>>();
4480 assert_eq!(last_accessed.len(), 3);
4481 assert!(last_accessed.contains(&session_id_c));
4482 assert!(last_accessed.contains(&session_id_a));
4483 assert!(last_accessed.contains(&session_id_b));
4484 assert!(
4485 sidebar
4486 .active_entry
4487 .as_ref()
4488 .expect("active_entry should be set")
4489 .is_active_thread(&session_id_b)
4490 );
4491 });
4492
4493 // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
4494 // This thread was never opened in a panel — it only exists in metadata.
4495 save_thread_metadata(
4496 acp::SessionId::new(Arc::from("thread-historical")),
4497 "Historical Thread".into(),
4498 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
4499 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
4500 &project,
4501 cx,
4502 );
4503
4504 sidebar.update_in(cx, |sidebar, window, cx| {
4505 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4506 });
4507 cx.run_until_parked();
4508
4509 // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
4510 // so it falls to tier 3 (sorted by created_at). It should appear after all
4511 // accessed threads, even though its created_at (June 2024) is much later
4512 // than the others.
4513 //
4514 // But the live threads (A, B, C) each had send_message called which sets
4515 // last_message_sent_or_queued. So for the accessed threads (tier 1) the
4516 // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
4517 let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
4518
4519 let ids = switcher_ids(&sidebar, cx);
4520 assert_eq!(
4521 ids,
4522 vec![
4523 session_id_b.clone(),
4524 session_id_a.clone(),
4525 session_id_c.clone(),
4526 session_id_hist.clone()
4527 ],
4528 );
4529
4530 sidebar.update_in(cx, |sidebar, _window, cx| {
4531 sidebar.dismiss_thread_switcher(cx);
4532 });
4533 cx.run_until_parked();
4534
4535 // ── 4. Add another historical thread with older created_at ─────────
4536 save_thread_metadata(
4537 acp::SessionId::new(Arc::from("thread-old-historical")),
4538 "Old Historical Thread".into(),
4539 chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
4540 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
4541 &project,
4542 cx,
4543 );
4544
4545 sidebar.update_in(cx, |sidebar, window, cx| {
4546 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4547 });
4548 cx.run_until_parked();
4549
4550 // Both historical threads have no access or message times. They should
4551 // appear after accessed threads, sorted by created_at (newest first).
4552 let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
4553 let ids = switcher_ids(&sidebar, cx);
4554 assert_eq!(
4555 ids,
4556 vec![
4557 session_id_b,
4558 session_id_a,
4559 session_id_c,
4560 session_id_hist,
4561 session_id_old_hist,
4562 ],
4563 );
4564
4565 sidebar.update_in(cx, |sidebar, _window, cx| {
4566 sidebar.dismiss_thread_switcher(cx);
4567 });
4568 cx.run_until_parked();
4569}
4570
4571#[gpui::test]
4572async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
4573 let project = init_test_project("/my-project", cx).await;
4574 let (multi_workspace, cx) =
4575 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4576 let sidebar = setup_sidebar(&multi_workspace, cx);
4577
4578 save_thread_metadata(
4579 acp::SessionId::new(Arc::from("thread-to-archive")),
4580 "Thread To Archive".into(),
4581 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4582 None,
4583 &project,
4584 cx,
4585 );
4586 cx.run_until_parked();
4587
4588 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4589 cx.run_until_parked();
4590
4591 let entries = visible_entries_as_strings(&sidebar, cx);
4592 assert!(
4593 entries.iter().any(|e| e.contains("Thread To Archive")),
4594 "expected thread to be visible before archiving, got: {entries:?}"
4595 );
4596
4597 sidebar.update_in(cx, |sidebar, window, cx| {
4598 sidebar.archive_thread(
4599 &acp::SessionId::new(Arc::from("thread-to-archive")),
4600 window,
4601 cx,
4602 );
4603 });
4604 cx.run_until_parked();
4605
4606 let entries = visible_entries_as_strings(&sidebar, cx);
4607 assert!(
4608 !entries.iter().any(|e| e.contains("Thread To Archive")),
4609 "expected thread to be hidden after archiving, got: {entries:?}"
4610 );
4611
4612 cx.update(|_, cx| {
4613 let store = ThreadMetadataStore::global(cx);
4614 let archived: Vec<_> = store.read(cx).archived_entries().collect();
4615 assert_eq!(archived.len(), 1);
4616 assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive");
4617 assert!(archived[0].archived);
4618 });
4619}
4620
4621#[gpui::test]
4622async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
4623 let project = init_test_project("/my-project", cx).await;
4624 let (multi_workspace, cx) =
4625 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4626 let sidebar = setup_sidebar(&multi_workspace, cx);
4627
4628 save_thread_metadata(
4629 acp::SessionId::new(Arc::from("visible-thread")),
4630 "Visible Thread".into(),
4631 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4632 None,
4633 &project,
4634 cx,
4635 );
4636
4637 let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
4638 save_thread_metadata(
4639 archived_thread_session_id.clone(),
4640 "Archived Thread".into(),
4641 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4642 None,
4643 &project,
4644 cx,
4645 );
4646
4647 cx.update(|_, cx| {
4648 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4649 store.archive(&archived_thread_session_id, cx)
4650 })
4651 });
4652 cx.run_until_parked();
4653
4654 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4655 cx.run_until_parked();
4656
4657 let entries = visible_entries_as_strings(&sidebar, cx);
4658 assert!(
4659 entries.iter().any(|e| e.contains("Visible Thread")),
4660 "expected visible thread in sidebar, got: {entries:?}"
4661 );
4662 assert!(
4663 !entries.iter().any(|e| e.contains("Archived Thread")),
4664 "expected archived thread to be hidden from sidebar, got: {entries:?}"
4665 );
4666
4667 cx.update(|_, cx| {
4668 let store = ThreadMetadataStore::global(cx);
4669 let all: Vec<_> = store.read(cx).entries().collect();
4670 assert_eq!(
4671 all.len(),
4672 2,
4673 "expected 2 total entries in the store, got: {}",
4674 all.len()
4675 );
4676
4677 let archived: Vec<_> = store.read(cx).archived_entries().collect();
4678 assert_eq!(archived.len(), 1);
4679 assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread");
4680 });
4681}
4682
4683#[gpui::test]
4684async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
4685 // When only a linked worktree workspace is open (not the main repo),
4686 // threads saved against the main repo should still appear in the sidebar.
4687 init_test(cx);
4688 let fs = FakeFs::new(cx.executor());
4689
4690 // Create the main repo with a linked worktree.
4691 fs.insert_tree(
4692 "/project",
4693 serde_json::json!({
4694 ".git": {
4695 "worktrees": {
4696 "feature-a": {
4697 "commondir": "../../",
4698 "HEAD": "ref: refs/heads/feature-a",
4699 },
4700 },
4701 },
4702 "src": {},
4703 }),
4704 )
4705 .await;
4706
4707 fs.insert_tree(
4708 "/wt-feature-a",
4709 serde_json::json!({
4710 ".git": "gitdir: /project/.git/worktrees/feature-a",
4711 "src": {},
4712 }),
4713 )
4714 .await;
4715
4716 fs.add_linked_worktree_for_repo(
4717 std::path::Path::new("/project/.git"),
4718 false,
4719 git::repository::Worktree {
4720 path: std::path::PathBuf::from("/wt-feature-a"),
4721 ref_name: Some("refs/heads/feature-a".into()),
4722 sha: "abc".into(),
4723 is_main: false,
4724 },
4725 )
4726 .await;
4727
4728 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4729
4730 // Only open the linked worktree as a workspace — NOT the main repo.
4731 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4732 worktree_project
4733 .update(cx, |p, cx| p.git_scans_complete(cx))
4734 .await;
4735
4736 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4737 main_project
4738 .update(cx, |p, cx| p.git_scans_complete(cx))
4739 .await;
4740
4741 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4742 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
4743 });
4744 let sidebar = setup_sidebar(&multi_workspace, cx);
4745
4746 // Save a thread against the MAIN repo path.
4747 save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
4748
4749 // Save a thread against the linked worktree path.
4750 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
4751
4752 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4753 cx.run_until_parked();
4754
4755 // Both threads should be visible: the worktree thread by direct lookup,
4756 // and the main repo thread because the workspace is a linked worktree
4757 // and we also query the main repo path.
4758 let entries = visible_entries_as_strings(&sidebar, cx);
4759 assert!(
4760 entries.iter().any(|e| e.contains("Main Repo Thread")),
4761 "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
4762 );
4763 assert!(
4764 entries.iter().any(|e| e.contains("Worktree Thread")),
4765 "expected worktree thread to be visible, got: {entries:?}"
4766 );
4767}
4768
4769async fn init_multi_project_test(
4770 paths: &[&str],
4771 cx: &mut TestAppContext,
4772) -> (Arc<FakeFs>, Entity<project::Project>) {
4773 agent_ui::test_support::init_test(cx);
4774 cx.update(|cx| {
4775 cx.update_flags(false, vec!["agent-v2".into()]);
4776 ThreadStore::init_global(cx);
4777 ThreadMetadataStore::init_global(cx);
4778 language_model::LanguageModelRegistry::test(cx);
4779 prompt_store::init(cx);
4780 });
4781 let fs = FakeFs::new(cx.executor());
4782 for path in paths {
4783 fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
4784 .await;
4785 }
4786 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4787 let project =
4788 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
4789 (fs, project)
4790}
4791
4792async fn add_test_project(
4793 path: &str,
4794 fs: &Arc<FakeFs>,
4795 multi_workspace: &Entity<MultiWorkspace>,
4796 cx: &mut gpui::VisualTestContext,
4797) -> Entity<Workspace> {
4798 let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
4799 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4800 mw.test_add_workspace(project, window, cx)
4801 });
4802 cx.run_until_parked();
4803 workspace
4804}
4805
4806#[gpui::test]
4807async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
4808 let (fs, project_a) =
4809 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
4810 let (multi_workspace, cx) =
4811 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4812 let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
4813
4814 // Sidebar starts closed. Initial workspace A is transient.
4815 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4816 assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
4817 assert_eq!(
4818 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4819 1
4820 );
4821 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
4822
4823 // Add B — replaces A as the transient workspace.
4824 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
4825 assert_eq!(
4826 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4827 1
4828 );
4829 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
4830
4831 // Add C — replaces B as the transient workspace.
4832 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
4833 assert_eq!(
4834 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4835 1
4836 );
4837 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
4838}
4839
4840#[gpui::test]
4841async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
4842 let (fs, project_a) = init_multi_project_test(
4843 &["/project-a", "/project-b", "/project-c", "/project-d"],
4844 cx,
4845 )
4846 .await;
4847 let (multi_workspace, cx) =
4848 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4849 let _sidebar = setup_sidebar(&multi_workspace, cx);
4850 assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
4851
4852 // Add B — retained since sidebar is open.
4853 let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
4854 assert_eq!(
4855 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4856 2
4857 );
4858
4859 // Switch to A — B survives. (Switching from one internal workspace, to another)
4860 multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
4861 cx.run_until_parked();
4862 assert_eq!(
4863 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4864 2
4865 );
4866
4867 // Close sidebar — both A and B remain retained.
4868 multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
4869 cx.run_until_parked();
4870 assert_eq!(
4871 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4872 2
4873 );
4874
4875 // Add C — added as new transient workspace. (switching from retained, to transient)
4876 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
4877 assert_eq!(
4878 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4879 3
4880 );
4881 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
4882
4883 // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
4884 let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
4885 assert_eq!(
4886 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4887 3
4888 );
4889 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
4890}
4891
4892#[gpui::test]
4893async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
4894 let (fs, project_a) =
4895 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
4896 let (multi_workspace, cx) =
4897 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4898 setup_sidebar_closed(&multi_workspace, cx);
4899
4900 // Add B — replaces A as the transient workspace (A is discarded).
4901 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
4902 assert_eq!(
4903 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4904 1
4905 );
4906 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
4907
4908 // Open sidebar — promotes the transient B to retained.
4909 multi_workspace.update_in(cx, |mw, window, cx| {
4910 mw.toggle_sidebar(window, cx);
4911 });
4912 cx.run_until_parked();
4913 assert_eq!(
4914 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4915 1
4916 );
4917 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
4918
4919 // Close sidebar — the retained B remains.
4920 multi_workspace.update_in(cx, |mw, window, cx| {
4921 mw.toggle_sidebar(window, cx);
4922 });
4923
4924 // Add C — added as new transient workspace.
4925 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
4926 assert_eq!(
4927 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4928 2
4929 );
4930 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
4931}
4932
4933#[gpui::test]
4934async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
4935 init_test(cx);
4936 let fs = FakeFs::new(cx.executor());
4937
4938 fs.insert_tree(
4939 "/project",
4940 serde_json::json!({
4941 ".git": {
4942 "worktrees": {
4943 "feature-a": {
4944 "commondir": "../../",
4945 "HEAD": "ref: refs/heads/feature-a",
4946 },
4947 },
4948 },
4949 "src": {},
4950 }),
4951 )
4952 .await;
4953
4954 fs.insert_tree(
4955 "/wt-feature-a",
4956 serde_json::json!({
4957 ".git": "gitdir: /project/.git/worktrees/feature-a",
4958 "src": {},
4959 }),
4960 )
4961 .await;
4962
4963 fs.add_linked_worktree_for_repo(
4964 Path::new("/project/.git"),
4965 false,
4966 git::repository::Worktree {
4967 path: PathBuf::from("/wt-feature-a"),
4968 ref_name: Some("refs/heads/feature-a".into()),
4969 sha: "abc".into(),
4970 is_main: false,
4971 },
4972 )
4973 .await;
4974
4975 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4976
4977 // Only a linked worktree workspace is open — no workspace for /project.
4978 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4979 worktree_project
4980 .update(cx, |p, cx| p.git_scans_complete(cx))
4981 .await;
4982
4983 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4984 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
4985 });
4986 let sidebar = setup_sidebar(&multi_workspace, cx);
4987
4988 // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
4989 let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
4990 cx.update(|_, cx| {
4991 let metadata = ThreadMetadata {
4992 session_id: legacy_session.clone(),
4993 agent_id: agent::ZED_AGENT_ID.clone(),
4994 title: "Legacy Main Thread".into(),
4995 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4996 created_at: None,
4997 folder_paths: PathList::new(&[PathBuf::from("/project")]),
4998 main_worktree_paths: PathList::default(),
4999 archived: false,
5000 };
5001 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
5002 });
5003 cx.run_until_parked();
5004
5005 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5006 cx.run_until_parked();
5007
5008 // The legacy thread should appear in the sidebar under the project group.
5009 let entries = visible_entries_as_strings(&sidebar, cx);
5010 assert!(
5011 entries.iter().any(|e| e.contains("Legacy Main Thread")),
5012 "legacy thread should be visible: {entries:?}",
5013 );
5014
5015 // Verify only 1 workspace before clicking.
5016 assert_eq!(
5017 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5018 1,
5019 );
5020
5021 // Focus and select the legacy thread, then confirm.
5022 focus_sidebar(&sidebar, cx);
5023 let thread_index = sidebar.read_with(cx, |sidebar, _| {
5024 sidebar
5025 .contents
5026 .entries
5027 .iter()
5028 .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
5029 .expect("legacy thread should be in entries")
5030 });
5031 sidebar.update_in(cx, |sidebar, _window, _cx| {
5032 sidebar.selection = Some(thread_index);
5033 });
5034 cx.dispatch_action(Confirm);
5035 cx.run_until_parked();
5036
5037 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5038 let new_path_list =
5039 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
5040 assert_eq!(
5041 new_path_list,
5042 PathList::new(&[PathBuf::from("/project")]),
5043 "the new workspace should be for the main repo, not the linked worktree",
5044 );
5045}
5046
5047mod property_test {
5048 use super::*;
5049 use gpui::proptest::prelude::*;
5050
5051 struct UnopenedWorktree {
5052 path: String,
5053 main_workspace_path: String,
5054 }
5055
5056 struct TestState {
5057 fs: Arc<FakeFs>,
5058 thread_counter: u32,
5059 workspace_counter: u32,
5060 worktree_counter: u32,
5061 saved_thread_ids: Vec<acp::SessionId>,
5062 workspace_paths: Vec<String>,
5063 main_repo_indices: Vec<usize>,
5064 unopened_worktrees: Vec<UnopenedWorktree>,
5065 }
5066
5067 impl TestState {
5068 fn new(fs: Arc<FakeFs>, initial_workspace_path: String) -> Self {
5069 Self {
5070 fs,
5071 thread_counter: 0,
5072 workspace_counter: 1,
5073 worktree_counter: 0,
5074 saved_thread_ids: Vec::new(),
5075 workspace_paths: vec![initial_workspace_path],
5076 main_repo_indices: vec![0],
5077 unopened_worktrees: Vec::new(),
5078 }
5079 }
5080
5081 fn next_thread_id(&mut self) -> acp::SessionId {
5082 let id = self.thread_counter;
5083 self.thread_counter += 1;
5084 let session_id = acp::SessionId::new(Arc::from(format!("prop-thread-{id}")));
5085 self.saved_thread_ids.push(session_id.clone());
5086 session_id
5087 }
5088
5089 fn remove_thread(&mut self, index: usize) -> acp::SessionId {
5090 self.saved_thread_ids.remove(index)
5091 }
5092
5093 fn next_workspace_path(&mut self) -> String {
5094 let id = self.workspace_counter;
5095 self.workspace_counter += 1;
5096 format!("/prop-project-{id}")
5097 }
5098
5099 fn next_worktree_name(&mut self) -> String {
5100 let id = self.worktree_counter;
5101 self.worktree_counter += 1;
5102 format!("wt-{id}")
5103 }
5104 }
5105
5106 #[derive(Debug)]
5107 enum Operation {
5108 SaveThread { workspace_index: usize },
5109 SaveWorktreeThread { worktree_index: usize },
5110 DeleteThread { index: usize },
5111 ToggleAgentPanel,
5112 CreateDraftThread,
5113 AddWorkspace,
5114 OpenWorktreeAsWorkspace { worktree_index: usize },
5115 RemoveWorkspace { index: usize },
5116 SwitchWorkspace { index: usize },
5117 AddLinkedWorktree { workspace_index: usize },
5118 }
5119
5120 // Distribution (out of 20 slots):
5121 // SaveThread: 5 slots (~23%)
5122 // SaveWorktreeThread: 2 slots (~9%)
5123 // DeleteThread: 2 slots (~9%)
5124 // ToggleAgentPanel: 2 slots (~9%)
5125 // CreateDraftThread: 2 slots (~9%)
5126 // AddWorkspace: 1 slot (~5%)
5127 // OpenWorktreeAsWorkspace: 1 slot (~5%)
5128 // RemoveWorkspace: 1 slot (~5%)
5129 // SwitchWorkspace: 2 slots (~9%)
5130 // AddLinkedWorktree: 4 slots (~18%)
5131 const DISTRIBUTION_SLOTS: u32 = 22;
5132
5133 impl TestState {
5134 fn generate_operation(&self, raw: u32) -> Operation {
5135 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
5136 let workspace_count = self.workspace_paths.len();
5137
5138 match raw % DISTRIBUTION_SLOTS {
5139 0..=4 => Operation::SaveThread {
5140 workspace_index: extra % workspace_count,
5141 },
5142 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
5143 worktree_index: extra % self.unopened_worktrees.len(),
5144 },
5145 5..=6 => Operation::SaveThread {
5146 workspace_index: extra % workspace_count,
5147 },
5148 7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread {
5149 index: extra % self.saved_thread_ids.len(),
5150 },
5151 7..=8 => Operation::SaveThread {
5152 workspace_index: extra % workspace_count,
5153 },
5154 9..=10 => Operation::ToggleAgentPanel,
5155 11..=12 => Operation::CreateDraftThread,
5156 13 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace {
5157 worktree_index: extra % self.unopened_worktrees.len(),
5158 },
5159 13 => Operation::AddWorkspace,
5160 14 if workspace_count > 1 => Operation::RemoveWorkspace {
5161 index: extra % workspace_count,
5162 },
5163 14 => Operation::AddWorkspace,
5164 15..=16 => Operation::SwitchWorkspace {
5165 index: extra % workspace_count,
5166 },
5167 17..=21 if !self.main_repo_indices.is_empty() => {
5168 let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()];
5169 Operation::AddLinkedWorktree {
5170 workspace_index: main_index,
5171 }
5172 }
5173 17..=21 => Operation::SaveThread {
5174 workspace_index: extra % workspace_count,
5175 },
5176 _ => unreachable!(),
5177 }
5178 }
5179 }
5180
5181 fn save_thread_to_path(
5182 state: &mut TestState,
5183 project: &Entity<project::Project>,
5184 cx: &mut gpui::VisualTestContext,
5185 ) {
5186 let session_id = state.next_thread_id();
5187 let title: SharedString = format!("Thread {}", session_id).into();
5188 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
5189 .unwrap()
5190 + chrono::Duration::seconds(state.thread_counter as i64);
5191 save_thread_metadata(session_id, title, updated_at, None, project, cx);
5192 }
5193
5194 fn save_thread_to_path_with_main(
5195 state: &mut TestState,
5196 path_list: PathList,
5197 main_worktree_paths: PathList,
5198 cx: &mut gpui::VisualTestContext,
5199 ) {
5200 let session_id = state.next_thread_id();
5201 let title: SharedString = format!("Thread {}", session_id).into();
5202 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
5203 .unwrap()
5204 + chrono::Duration::seconds(state.thread_counter as i64);
5205 let metadata = ThreadMetadata {
5206 session_id,
5207 agent_id: agent::ZED_AGENT_ID.clone(),
5208 title,
5209 updated_at,
5210 created_at: None,
5211 folder_paths: path_list,
5212 main_worktree_paths,
5213 archived: false,
5214 };
5215 cx.update(|_, cx| {
5216 ThreadMetadataStore::global(cx)
5217 .update(cx, |store, cx| store.save_manually(metadata, cx))
5218 });
5219 cx.run_until_parked();
5220 }
5221
5222 async fn perform_operation(
5223 operation: Operation,
5224 state: &mut TestState,
5225 multi_workspace: &Entity<MultiWorkspace>,
5226 _sidebar: &Entity<Sidebar>,
5227 cx: &mut gpui::VisualTestContext,
5228 ) {
5229 match operation {
5230 Operation::SaveThread { workspace_index } => {
5231 let project = multi_workspace.read_with(cx, |mw, cx| {
5232 mw.workspaces()
5233 .nth(workspace_index)
5234 .unwrap()
5235 .read(cx)
5236 .project()
5237 .clone()
5238 });
5239 save_thread_to_path(state, &project, cx);
5240 }
5241 Operation::SaveWorktreeThread { worktree_index } => {
5242 let worktree = &state.unopened_worktrees[worktree_index];
5243 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
5244 let main_worktree_paths =
5245 PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
5246 save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
5247 }
5248 Operation::DeleteThread { index } => {
5249 let session_id = state.remove_thread(index);
5250 cx.update(|_, cx| {
5251 ThreadMetadataStore::global(cx)
5252 .update(cx, |store, cx| store.delete(session_id, cx));
5253 });
5254 }
5255 Operation::ToggleAgentPanel => {
5256 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5257 let panel_open =
5258 workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
5259 workspace.update_in(cx, |workspace, window, cx| {
5260 if panel_open {
5261 workspace.close_panel::<AgentPanel>(window, cx);
5262 } else {
5263 workspace.open_panel::<AgentPanel>(window, cx);
5264 }
5265 });
5266 }
5267 Operation::CreateDraftThread => {
5268 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5269 let panel =
5270 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
5271 if let Some(panel) = panel {
5272 let connection = StubAgentConnection::new();
5273 open_thread_with_connection(&panel, connection, cx);
5274 cx.run_until_parked();
5275 }
5276 workspace.update_in(cx, |workspace, window, cx| {
5277 workspace.focus_panel::<AgentPanel>(window, cx);
5278 });
5279 }
5280 Operation::AddWorkspace => {
5281 let path = state.next_workspace_path();
5282 state
5283 .fs
5284 .insert_tree(
5285 &path,
5286 serde_json::json!({
5287 ".git": {},
5288 "src": {},
5289 }),
5290 )
5291 .await;
5292 let project = project::Project::test(
5293 state.fs.clone() as Arc<dyn fs::Fs>,
5294 [path.as_ref()],
5295 cx,
5296 )
5297 .await;
5298 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5299 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5300 mw.test_add_workspace(project.clone(), window, cx)
5301 });
5302 add_agent_panel(&workspace, cx);
5303 let new_index = state.workspace_paths.len();
5304 state.workspace_paths.push(path);
5305 state.main_repo_indices.push(new_index);
5306 }
5307 Operation::OpenWorktreeAsWorkspace { worktree_index } => {
5308 let worktree = state.unopened_worktrees.remove(worktree_index);
5309 let project = project::Project::test(
5310 state.fs.clone() as Arc<dyn fs::Fs>,
5311 [worktree.path.as_ref()],
5312 cx,
5313 )
5314 .await;
5315 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5316 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5317 mw.test_add_workspace(project.clone(), window, cx)
5318 });
5319 add_agent_panel(&workspace, cx);
5320 state.workspace_paths.push(worktree.path);
5321 }
5322 Operation::RemoveWorkspace { index } => {
5323 let removed = multi_workspace.update_in(cx, |mw, window, cx| {
5324 let workspace = mw.workspaces().nth(index).unwrap().clone();
5325 mw.remove(&workspace, window, cx)
5326 });
5327 if removed {
5328 state.workspace_paths.remove(index);
5329 state.main_repo_indices.retain(|i| *i != index);
5330 for i in &mut state.main_repo_indices {
5331 if *i > index {
5332 *i -= 1;
5333 }
5334 }
5335 }
5336 }
5337 Operation::SwitchWorkspace { index } => {
5338 let workspace = multi_workspace
5339 .read_with(cx, |mw, _| mw.workspaces().nth(index).unwrap().clone());
5340 multi_workspace.update_in(cx, |mw, window, cx| {
5341 mw.activate(workspace, window, cx);
5342 });
5343 }
5344 Operation::AddLinkedWorktree { workspace_index } => {
5345 let main_path = state.workspace_paths[workspace_index].clone();
5346 let dot_git = format!("{}/.git", main_path);
5347 let worktree_name = state.next_worktree_name();
5348 let worktree_path = format!("/worktrees/{}", worktree_name);
5349
5350 state.fs
5351 .insert_tree(
5352 &worktree_path,
5353 serde_json::json!({
5354 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
5355 "src": {},
5356 }),
5357 )
5358 .await;
5359
5360 // Also create the worktree metadata dir inside the main repo's .git
5361 state
5362 .fs
5363 .insert_tree(
5364 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
5365 serde_json::json!({
5366 "commondir": "../../",
5367 "HEAD": format!("ref: refs/heads/{}", worktree_name),
5368 }),
5369 )
5370 .await;
5371
5372 let dot_git_path = std::path::Path::new(&dot_git);
5373 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
5374 state
5375 .fs
5376 .add_linked_worktree_for_repo(
5377 dot_git_path,
5378 false,
5379 git::repository::Worktree {
5380 path: worktree_pathbuf,
5381 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
5382 sha: "aaa".into(),
5383 is_main: false,
5384 },
5385 )
5386 .await;
5387
5388 // Re-scan the main workspace's project so it discovers the new worktree.
5389 let main_workspace = multi_workspace.read_with(cx, |mw, _| {
5390 mw.workspaces().nth(workspace_index).unwrap().clone()
5391 });
5392 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
5393 main_project
5394 .update(cx, |p, cx| p.git_scans_complete(cx))
5395 .await;
5396
5397 state.unopened_worktrees.push(UnopenedWorktree {
5398 path: worktree_path,
5399 main_workspace_path: main_path.clone(),
5400 });
5401 }
5402 }
5403 }
5404
5405 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
5406 sidebar.update_in(cx, |sidebar, _window, cx| {
5407 sidebar.collapsed_groups.clear();
5408 let path_lists: Vec<PathList> = sidebar
5409 .contents
5410 .entries
5411 .iter()
5412 .filter_map(|entry| match entry {
5413 ListEntry::ProjectHeader { key, .. } => Some(key.path_list().clone()),
5414 _ => None,
5415 })
5416 .collect();
5417 for path_list in path_lists {
5418 sidebar.expanded_groups.insert(path_list, 10_000);
5419 }
5420 sidebar.update_entries(cx);
5421 });
5422 }
5423
5424 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
5425 verify_every_workspace_in_multiworkspace_is_shown(sidebar, cx)?;
5426 verify_all_threads_are_shown(sidebar, cx)?;
5427 verify_active_state_matches_current_workspace(sidebar, cx)?;
5428 Ok(())
5429 }
5430
5431 fn verify_every_workspace_in_multiworkspace_is_shown(
5432 sidebar: &Sidebar,
5433 cx: &App,
5434 ) -> anyhow::Result<()> {
5435 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5436 anyhow::bail!("sidebar should still have an associated multi-workspace");
5437 };
5438
5439 let mw = multi_workspace.read(cx);
5440
5441 // Every project group key in the multi-workspace that has a
5442 // non-empty path list should appear as a ProjectHeader in the
5443 // sidebar.
5444 let expected_keys: HashSet<&project::ProjectGroupKey> = mw
5445 .project_group_keys()
5446 .filter(|k| !k.path_list().paths().is_empty())
5447 .collect();
5448
5449 let sidebar_keys: HashSet<&project::ProjectGroupKey> = sidebar
5450 .contents
5451 .entries
5452 .iter()
5453 .filter_map(|entry| match entry {
5454 ListEntry::ProjectHeader { key, .. } => Some(key),
5455 _ => None,
5456 })
5457 .collect();
5458
5459 let missing = &expected_keys - &sidebar_keys;
5460 let stray = &sidebar_keys - &expected_keys;
5461
5462 anyhow::ensure!(
5463 missing.is_empty() && stray.is_empty(),
5464 "sidebar project groups don't match multi-workspace.\n\
5465 Only in multi-workspace (missing): {:?}\n\
5466 Only in sidebar (stray): {:?}",
5467 missing,
5468 stray,
5469 );
5470
5471 Ok(())
5472 }
5473
5474 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
5475 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5476 anyhow::bail!("sidebar should still have an associated multi-workspace");
5477 };
5478 let workspaces = multi_workspace
5479 .read(cx)
5480 .workspaces()
5481 .cloned()
5482 .collect::<Vec<_>>();
5483 let thread_store = ThreadMetadataStore::global(cx);
5484
5485 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
5486 .contents
5487 .entries
5488 .iter()
5489 .filter_map(|entry| entry.session_id().cloned())
5490 .collect();
5491
5492 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
5493
5494 // Query using the same approach as the sidebar: iterate project
5495 // group keys, then do main + legacy queries per group.
5496 let mw = multi_workspace.read(cx);
5497 let mut workspaces_by_group: HashMap<project::ProjectGroupKey, Vec<Entity<Workspace>>> =
5498 HashMap::default();
5499 for workspace in &workspaces {
5500 let key = workspace.read(cx).project_group_key(cx);
5501 workspaces_by_group
5502 .entry(key)
5503 .or_default()
5504 .push(workspace.clone());
5505 }
5506
5507 for group_key in mw.project_group_keys() {
5508 let path_list = group_key.path_list().clone();
5509 if path_list.paths().is_empty() {
5510 continue;
5511 }
5512
5513 let group_workspaces = workspaces_by_group
5514 .get(group_key)
5515 .map(|ws| ws.as_slice())
5516 .unwrap_or_default();
5517
5518 // Main code path queries (run for all groups, even without workspaces).
5519 for metadata in thread_store
5520 .read(cx)
5521 .entries_for_main_worktree_path(&path_list)
5522 {
5523 metadata_thread_ids.insert(metadata.session_id.clone());
5524 }
5525 for metadata in thread_store.read(cx).entries_for_path(&path_list) {
5526 metadata_thread_ids.insert(metadata.session_id.clone());
5527 }
5528
5529 // Legacy: per-workspace queries for different root paths.
5530 let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
5531 .iter()
5532 .flat_map(|ws| {
5533 ws.read(cx)
5534 .root_paths(cx)
5535 .into_iter()
5536 .map(|p| p.to_path_buf())
5537 })
5538 .collect();
5539
5540 for workspace in group_workspaces {
5541 let ws_path_list = workspace_path_list(workspace, cx);
5542 if ws_path_list != path_list {
5543 for metadata in thread_store.read(cx).entries_for_path(&ws_path_list) {
5544 metadata_thread_ids.insert(metadata.session_id.clone());
5545 }
5546 }
5547 }
5548
5549 for workspace in group_workspaces {
5550 for snapshot in root_repository_snapshots(workspace, cx) {
5551 let repo_path_list =
5552 PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
5553 if repo_path_list != path_list {
5554 continue;
5555 }
5556 for linked_worktree in snapshot.linked_worktrees() {
5557 if covered_paths.contains(&*linked_worktree.path) {
5558 continue;
5559 }
5560 let worktree_path_list =
5561 PathList::new(std::slice::from_ref(&linked_worktree.path));
5562 for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list)
5563 {
5564 metadata_thread_ids.insert(metadata.session_id.clone());
5565 }
5566 }
5567 }
5568 }
5569 }
5570
5571 anyhow::ensure!(
5572 sidebar_thread_ids == metadata_thread_ids,
5573 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
5574 sidebar_thread_ids,
5575 metadata_thread_ids,
5576 );
5577 Ok(())
5578 }
5579
5580 fn verify_active_state_matches_current_workspace(
5581 sidebar: &Sidebar,
5582 cx: &App,
5583 ) -> anyhow::Result<()> {
5584 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5585 anyhow::bail!("sidebar should still have an associated multi-workspace");
5586 };
5587
5588 let active_workspace = multi_workspace.read(cx).workspace();
5589
5590 // 1. active_entry must always be Some after rebuild_contents.
5591 let entry = sidebar
5592 .active_entry
5593 .as_ref()
5594 .ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?;
5595
5596 // 2. The entry's workspace must agree with the multi-workspace's
5597 // active workspace.
5598 anyhow::ensure!(
5599 entry.workspace().entity_id() == active_workspace.entity_id(),
5600 "active_entry workspace ({:?}) != active workspace ({:?})",
5601 entry.workspace().entity_id(),
5602 active_workspace.entity_id(),
5603 );
5604
5605 // 3. The entry must match the agent panel's current state.
5606 let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
5607 if panel.read(cx).active_thread_is_draft(cx) {
5608 anyhow::ensure!(
5609 matches!(entry, ActiveEntry::Draft(_)),
5610 "panel shows a draft but active_entry is {:?}",
5611 entry,
5612 );
5613 } else if let Some(session_id) = panel
5614 .read(cx)
5615 .active_conversation_view()
5616 .and_then(|cv| cv.read(cx).parent_id(cx))
5617 {
5618 anyhow::ensure!(
5619 matches!(entry, ActiveEntry::Thread { session_id: id, .. } if id == &session_id),
5620 "panel has session {:?} but active_entry is {:?}",
5621 session_id,
5622 entry,
5623 );
5624 }
5625
5626 // 4. Exactly one entry in sidebar contents must be uniquely
5627 // identified by the active_entry.
5628 let matching_count = sidebar
5629 .contents
5630 .entries
5631 .iter()
5632 .filter(|e| entry.matches_entry(e))
5633 .count();
5634 anyhow::ensure!(
5635 matching_count == 1,
5636 "expected exactly 1 sidebar entry matching active_entry {:?}, found {}",
5637 entry,
5638 matching_count,
5639 );
5640
5641 Ok(())
5642 }
5643
5644 #[gpui::property_test(config = ProptestConfig {
5645 cases: 10,
5646 ..Default::default()
5647 })]
5648 async fn test_sidebar_invariants(
5649 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)]
5650 raw_operations: Vec<u32>,
5651 cx: &mut TestAppContext,
5652 ) {
5653 agent_ui::test_support::init_test(cx);
5654 cx.update(|cx| {
5655 cx.update_flags(false, vec!["agent-v2".into()]);
5656 ThreadStore::init_global(cx);
5657 ThreadMetadataStore::init_global(cx);
5658 language_model::LanguageModelRegistry::test(cx);
5659 prompt_store::init(cx);
5660 });
5661
5662 let fs = FakeFs::new(cx.executor());
5663 fs.insert_tree(
5664 "/my-project",
5665 serde_json::json!({
5666 ".git": {},
5667 "src": {},
5668 }),
5669 )
5670 .await;
5671 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5672 let project =
5673 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
5674 .await;
5675 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5676
5677 let (multi_workspace, cx) =
5678 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5679 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5680
5681 let mut state = TestState::new(fs, "/my-project".to_string());
5682 let mut executed: Vec<String> = Vec::new();
5683
5684 for &raw_op in &raw_operations {
5685 let operation = state.generate_operation(raw_op);
5686 executed.push(format!("{:?}", operation));
5687 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
5688 cx.run_until_parked();
5689
5690 update_sidebar(&sidebar, cx);
5691 cx.run_until_parked();
5692
5693 let result =
5694 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
5695 if let Err(err) = result {
5696 let log = executed.join("\n ");
5697 panic!(
5698 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
5699 executed.len(),
5700 );
5701 }
5702 }
5703 }
5704}