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