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