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