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