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