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