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