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