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