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