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