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