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