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 old
2166 // thread was stored under [project-a], so it no longer appears in
2167 // the sidebar list for this workspace.
2168 let entries = visible_entries_as_strings(&sidebar, cx);
2169 assert!(
2170 !entries.iter().any(|e| e.contains("Hello")),
2171 "Thread stored under the old path_list should not appear: {:?}",
2172 entries
2173 );
2174
2175 // The "New Thread" button must still be clickable (not stuck in
2176 // "active/draft" state). Verify that `active_thread_is_draft` is
2177 // false — the panel still has the old thread with messages.
2178 sidebar.read_with(cx, |sidebar, cx| {
2179 assert!(
2180 !sidebar.active_thread_is_draft(cx),
2181 "After adding a folder the panel still has a thread with messages, \
2182 so active_thread_is_draft should be false"
2183 );
2184 });
2185
2186 // Actually click "New Thread" by calling create_new_thread and
2187 // verify a new draft is created.
2188 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2189 sidebar.update_in(cx, |sidebar, window, cx| {
2190 sidebar.create_new_thread(&workspace, window, cx);
2191 });
2192 cx.run_until_parked();
2193
2194 // After creating a new thread, the panel should now be in draft
2195 // state (no messages on the new thread).
2196 sidebar.read_with(cx, |sidebar, cx| {
2197 assert!(
2198 sidebar.active_thread_is_draft(cx),
2199 "After creating a new thread the panel should be in draft state"
2200 );
2201 });
2202}
2203
2204#[gpui::test]
2205async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
2206 // When the user presses Cmd-N (NewThread action) while viewing a
2207 // non-empty thread, the sidebar should show the "New Thread" entry.
2208 // This exercises the same code path as the workspace action handler
2209 // (which bypasses the sidebar's create_new_thread method).
2210 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2211 let (multi_workspace, cx) =
2212 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2213 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
2214
2215 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2216
2217 // Create a non-empty thread (has messages).
2218 let connection = StubAgentConnection::new();
2219 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2220 acp::ContentChunk::new("Done".into()),
2221 )]);
2222 open_thread_with_connection(&panel, connection, cx);
2223 send_message(&panel, cx);
2224
2225 let session_id = active_session_id(&panel, cx);
2226 save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
2227 cx.run_until_parked();
2228
2229 assert_eq!(
2230 visible_entries_as_strings(&sidebar, cx),
2231 vec!["v [my-project]", " Hello *"]
2232 );
2233
2234 // Simulate cmd-n
2235 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2236 panel.update_in(cx, |panel, window, cx| {
2237 panel.new_thread(&NewThread, window, cx);
2238 });
2239 workspace.update_in(cx, |workspace, window, cx| {
2240 workspace.focus_panel::<AgentPanel>(window, cx);
2241 });
2242 cx.run_until_parked();
2243
2244 assert_eq!(
2245 visible_entries_as_strings(&sidebar, cx),
2246 vec!["v [my-project]", " [+ New Thread]", " Hello *"],
2247 "After Cmd-N the sidebar should show a highlighted New Thread entry"
2248 );
2249
2250 sidebar.read_with(cx, |sidebar, cx| {
2251 assert!(
2252 sidebar.focused_thread.is_none(),
2253 "focused_thread should be cleared after Cmd-N"
2254 );
2255 assert!(
2256 sidebar.active_thread_is_draft(cx),
2257 "the new blank thread should be a draft"
2258 );
2259 });
2260}
2261
2262#[gpui::test]
2263async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
2264 // When the active workspace is an absorbed git worktree, cmd-n
2265 // should still show the "New Thread" entry under the main repo's
2266 // header and highlight it as active.
2267 agent_ui::test_support::init_test(cx);
2268 cx.update(|cx| {
2269 cx.update_flags(false, vec!["agent-v2".into()]);
2270 ThreadStore::init_global(cx);
2271 ThreadMetadataStore::init_global(cx);
2272 language_model::LanguageModelRegistry::test(cx);
2273 prompt_store::init(cx);
2274 });
2275
2276 let fs = FakeFs::new(cx.executor());
2277
2278 // Main repo with a linked worktree.
2279 fs.insert_tree(
2280 "/project",
2281 serde_json::json!({
2282 ".git": {
2283 "worktrees": {
2284 "feature-a": {
2285 "commondir": "../../",
2286 "HEAD": "ref: refs/heads/feature-a",
2287 },
2288 },
2289 },
2290 "src": {},
2291 }),
2292 )
2293 .await;
2294
2295 // Worktree checkout pointing back to the main repo.
2296 fs.insert_tree(
2297 "/wt-feature-a",
2298 serde_json::json!({
2299 ".git": "gitdir: /project/.git/worktrees/feature-a",
2300 "src": {},
2301 }),
2302 )
2303 .await;
2304
2305 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
2306 state.worktrees.push(git::repository::Worktree {
2307 path: std::path::PathBuf::from("/wt-feature-a"),
2308 ref_name: Some("refs/heads/feature-a".into()),
2309 sha: "aaa".into(),
2310 });
2311 })
2312 .unwrap();
2313
2314 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2315
2316 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2317 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2318
2319 main_project
2320 .update(cx, |p, cx| p.git_scans_complete(cx))
2321 .await;
2322 worktree_project
2323 .update(cx, |p, cx| p.git_scans_complete(cx))
2324 .await;
2325
2326 let (multi_workspace, cx) =
2327 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
2328
2329 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
2330 mw.test_add_workspace(worktree_project.clone(), window, cx)
2331 });
2332
2333 let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
2334
2335 // Switch to the worktree workspace.
2336 multi_workspace.update_in(cx, |mw, window, cx| {
2337 let workspace = mw.workspaces()[1].clone();
2338 mw.activate(workspace, window, cx);
2339 });
2340
2341 let sidebar = setup_sidebar(&multi_workspace, cx);
2342
2343 // Create a non-empty thread in the worktree workspace.
2344 let connection = StubAgentConnection::new();
2345 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2346 acp::ContentChunk::new("Done".into()),
2347 )]);
2348 open_thread_with_connection(&worktree_panel, connection, cx);
2349 send_message(&worktree_panel, cx);
2350
2351 let session_id = active_session_id(&worktree_panel, cx);
2352 let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
2353 save_test_thread_metadata(&session_id, wt_path_list, cx).await;
2354 cx.run_until_parked();
2355
2356 assert_eq!(
2357 visible_entries_as_strings(&sidebar, cx),
2358 vec![
2359 "v [project]",
2360 " [+ New Thread]",
2361 " Hello {wt-feature-a} *"
2362 ]
2363 );
2364
2365 // Simulate Cmd-N in the worktree workspace.
2366 worktree_panel.update_in(cx, |panel, window, cx| {
2367 panel.new_thread(&NewThread, window, cx);
2368 });
2369 worktree_workspace.update_in(cx, |workspace, window, cx| {
2370 workspace.focus_panel::<AgentPanel>(window, cx);
2371 });
2372 cx.run_until_parked();
2373
2374 assert_eq!(
2375 visible_entries_as_strings(&sidebar, cx),
2376 vec![
2377 "v [project]",
2378 " [+ New Thread]",
2379 " [+ New Thread {wt-feature-a}]",
2380 " Hello {wt-feature-a} *"
2381 ],
2382 "After Cmd-N in an absorbed worktree, the sidebar should show \
2383 a highlighted New Thread entry under the main repo header"
2384 );
2385
2386 sidebar.read_with(cx, |sidebar, cx| {
2387 assert!(
2388 sidebar.focused_thread.is_none(),
2389 "focused_thread should be cleared after Cmd-N"
2390 );
2391 assert!(
2392 sidebar.active_thread_is_draft(cx),
2393 "the new blank thread should be a draft"
2394 );
2395 });
2396}
2397
2398async fn init_test_project_with_git(
2399 worktree_path: &str,
2400 cx: &mut TestAppContext,
2401) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
2402 init_test(cx);
2403 let fs = FakeFs::new(cx.executor());
2404 fs.insert_tree(
2405 worktree_path,
2406 serde_json::json!({
2407 ".git": {},
2408 "src": {},
2409 }),
2410 )
2411 .await;
2412 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2413 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
2414 (project, fs)
2415}
2416
2417#[gpui::test]
2418async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
2419 let (project, fs) = init_test_project_with_git("/project", cx).await;
2420
2421 fs.as_fake()
2422 .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
2423 state.worktrees.push(git::repository::Worktree {
2424 path: std::path::PathBuf::from("/wt/rosewood"),
2425 ref_name: Some("refs/heads/rosewood".into()),
2426 sha: "abc".into(),
2427 });
2428 })
2429 .unwrap();
2430
2431 project
2432 .update(cx, |project, cx| project.git_scans_complete(cx))
2433 .await;
2434
2435 let (multi_workspace, cx) =
2436 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2437 let sidebar = setup_sidebar(&multi_workspace, cx);
2438
2439 let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
2440 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
2441 save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await;
2442 save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await;
2443
2444 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2445 cx.run_until_parked();
2446
2447 // Search for "rosewood" — should match the worktree name, not the title.
2448 type_in_search(&sidebar, "rosewood", cx);
2449
2450 assert_eq!(
2451 visible_entries_as_strings(&sidebar, cx),
2452 vec!["v [project]", " Fix Bug {rosewood} <== selected"],
2453 );
2454}
2455
2456#[gpui::test]
2457async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
2458 let (project, fs) = init_test_project_with_git("/project", cx).await;
2459
2460 project
2461 .update(cx, |project, cx| project.git_scans_complete(cx))
2462 .await;
2463
2464 let (multi_workspace, cx) =
2465 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2466 let sidebar = setup_sidebar(&multi_workspace, cx);
2467
2468 // Save a thread against a worktree path that doesn't exist yet.
2469 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
2470 save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
2471
2472 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2473 cx.run_until_parked();
2474
2475 // Thread is not visible yet — no worktree knows about this path.
2476 assert_eq!(
2477 visible_entries_as_strings(&sidebar, cx),
2478 vec!["v [project]", " [+ New Thread]"]
2479 );
2480
2481 // Now add the worktree to the git state and trigger a rescan.
2482 fs.as_fake()
2483 .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
2484 state.worktrees.push(git::repository::Worktree {
2485 path: std::path::PathBuf::from("/wt/rosewood"),
2486 ref_name: Some("refs/heads/rosewood".into()),
2487 sha: "abc".into(),
2488 });
2489 })
2490 .unwrap();
2491
2492 cx.run_until_parked();
2493
2494 assert_eq!(
2495 visible_entries_as_strings(&sidebar, cx),
2496 vec![
2497 "v [project]",
2498 " [+ New Thread]",
2499 " Worktree Thread {rosewood}",
2500 ]
2501 );
2502}
2503
2504#[gpui::test]
2505async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
2506 init_test(cx);
2507 let fs = FakeFs::new(cx.executor());
2508
2509 // Create the main repo directory (not opened as a workspace yet).
2510 fs.insert_tree(
2511 "/project",
2512 serde_json::json!({
2513 ".git": {
2514 "worktrees": {
2515 "feature-a": {
2516 "commondir": "../../",
2517 "HEAD": "ref: refs/heads/feature-a",
2518 },
2519 "feature-b": {
2520 "commondir": "../../",
2521 "HEAD": "ref: refs/heads/feature-b",
2522 },
2523 },
2524 },
2525 "src": {},
2526 }),
2527 )
2528 .await;
2529
2530 // Two worktree checkouts whose .git files point back to the main repo.
2531 fs.insert_tree(
2532 "/wt-feature-a",
2533 serde_json::json!({
2534 ".git": "gitdir: /project/.git/worktrees/feature-a",
2535 "src": {},
2536 }),
2537 )
2538 .await;
2539 fs.insert_tree(
2540 "/wt-feature-b",
2541 serde_json::json!({
2542 ".git": "gitdir: /project/.git/worktrees/feature-b",
2543 "src": {},
2544 }),
2545 )
2546 .await;
2547
2548 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2549
2550 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2551 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
2552
2553 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2554 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2555
2556 // Open both worktrees as workspaces — no main repo yet.
2557 let (multi_workspace, cx) =
2558 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2559 multi_workspace.update_in(cx, |mw, window, cx| {
2560 mw.test_add_workspace(project_b.clone(), window, cx);
2561 });
2562 let sidebar = setup_sidebar(&multi_workspace, cx);
2563
2564 let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
2565 let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
2566 save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
2567 save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await;
2568
2569 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2570 cx.run_until_parked();
2571
2572 // Without the main repo, each worktree has its own header.
2573 assert_eq!(
2574 visible_entries_as_strings(&sidebar, cx),
2575 vec![
2576 "v [project]",
2577 " Thread A {wt-feature-a}",
2578 " Thread B {wt-feature-b}",
2579 ]
2580 );
2581
2582 // Configure the main repo to list both worktrees before opening
2583 // it so the initial git scan picks them up.
2584 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
2585 state.worktrees.push(git::repository::Worktree {
2586 path: std::path::PathBuf::from("/wt-feature-a"),
2587 ref_name: Some("refs/heads/feature-a".into()),
2588 sha: "aaa".into(),
2589 });
2590 state.worktrees.push(git::repository::Worktree {
2591 path: std::path::PathBuf::from("/wt-feature-b"),
2592 ref_name: Some("refs/heads/feature-b".into()),
2593 sha: "bbb".into(),
2594 });
2595 })
2596 .unwrap();
2597
2598 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2599 main_project
2600 .update(cx, |p, cx| p.git_scans_complete(cx))
2601 .await;
2602
2603 multi_workspace.update_in(cx, |mw, window, cx| {
2604 mw.test_add_workspace(main_project.clone(), window, cx);
2605 });
2606 cx.run_until_parked();
2607
2608 // Both worktree workspaces should now be absorbed under the main
2609 // repo header, with worktree chips.
2610 assert_eq!(
2611 visible_entries_as_strings(&sidebar, cx),
2612 vec![
2613 "v [project]",
2614 " [+ New Thread]",
2615 " Thread A {wt-feature-a}",
2616 " Thread B {wt-feature-b}",
2617 ]
2618 );
2619}
2620
2621#[gpui::test]
2622async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
2623 // When a group has two workspaces — one with threads and one
2624 // without — the threadless workspace should appear as a
2625 // "New Thread" button with its worktree chip.
2626 init_test(cx);
2627 let fs = FakeFs::new(cx.executor());
2628
2629 // Main repo with two linked worktrees.
2630 fs.insert_tree(
2631 "/project",
2632 serde_json::json!({
2633 ".git": {
2634 "worktrees": {
2635 "feature-a": {
2636 "commondir": "../../",
2637 "HEAD": "ref: refs/heads/feature-a",
2638 },
2639 "feature-b": {
2640 "commondir": "../../",
2641 "HEAD": "ref: refs/heads/feature-b",
2642 },
2643 },
2644 },
2645 "src": {},
2646 }),
2647 )
2648 .await;
2649 fs.insert_tree(
2650 "/wt-feature-a",
2651 serde_json::json!({
2652 ".git": "gitdir: /project/.git/worktrees/feature-a",
2653 "src": {},
2654 }),
2655 )
2656 .await;
2657 fs.insert_tree(
2658 "/wt-feature-b",
2659 serde_json::json!({
2660 ".git": "gitdir: /project/.git/worktrees/feature-b",
2661 "src": {},
2662 }),
2663 )
2664 .await;
2665
2666 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
2667 state.worktrees.push(git::repository::Worktree {
2668 path: std::path::PathBuf::from("/wt-feature-a"),
2669 ref_name: Some("refs/heads/feature-a".into()),
2670 sha: "aaa".into(),
2671 });
2672 state.worktrees.push(git::repository::Worktree {
2673 path: std::path::PathBuf::from("/wt-feature-b"),
2674 ref_name: Some("refs/heads/feature-b".into()),
2675 sha: "bbb".into(),
2676 });
2677 })
2678 .unwrap();
2679
2680 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2681
2682 // Workspace A: worktree feature-a (has threads).
2683 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2684 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2685
2686 // Workspace B: worktree feature-b (no threads).
2687 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
2688 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2689
2690 let (multi_workspace, cx) =
2691 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2692 multi_workspace.update_in(cx, |mw, window, cx| {
2693 mw.test_add_workspace(project_b.clone(), window, cx);
2694 });
2695 let sidebar = setup_sidebar(&multi_workspace, cx);
2696
2697 // Only save a thread for workspace A.
2698 let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
2699 save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
2700
2701 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2702 cx.run_until_parked();
2703
2704 // Workspace A's thread appears normally. Workspace B (threadless)
2705 // appears as a "New Thread" button with its worktree chip.
2706 assert_eq!(
2707 visible_entries_as_strings(&sidebar, cx),
2708 vec![
2709 "v [project]",
2710 " [+ New Thread {wt-feature-b}]",
2711 " Thread A {wt-feature-a}",
2712 ]
2713 );
2714}
2715
2716#[gpui::test]
2717async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
2718 // A thread created in a workspace with roots from different git
2719 // worktrees should show a chip for each distinct worktree name.
2720 init_test(cx);
2721 let fs = FakeFs::new(cx.executor());
2722
2723 // Two main repos.
2724 fs.insert_tree(
2725 "/project_a",
2726 serde_json::json!({
2727 ".git": {
2728 "worktrees": {
2729 "olivetti": {
2730 "commondir": "../../",
2731 "HEAD": "ref: refs/heads/olivetti",
2732 },
2733 "selectric": {
2734 "commondir": "../../",
2735 "HEAD": "ref: refs/heads/selectric",
2736 },
2737 },
2738 },
2739 "src": {},
2740 }),
2741 )
2742 .await;
2743 fs.insert_tree(
2744 "/project_b",
2745 serde_json::json!({
2746 ".git": {
2747 "worktrees": {
2748 "olivetti": {
2749 "commondir": "../../",
2750 "HEAD": "ref: refs/heads/olivetti",
2751 },
2752 "selectric": {
2753 "commondir": "../../",
2754 "HEAD": "ref: refs/heads/selectric",
2755 },
2756 },
2757 },
2758 "src": {},
2759 }),
2760 )
2761 .await;
2762
2763 // Worktree checkouts.
2764 for (repo, branch) in &[
2765 ("project_a", "olivetti"),
2766 ("project_a", "selectric"),
2767 ("project_b", "olivetti"),
2768 ("project_b", "selectric"),
2769 ] {
2770 let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}");
2771 let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}");
2772 fs.insert_tree(
2773 &worktree_path,
2774 serde_json::json!({
2775 ".git": gitdir,
2776 "src": {},
2777 }),
2778 )
2779 .await;
2780 }
2781
2782 // Register linked worktrees.
2783 for repo in &["project_a", "project_b"] {
2784 let git_path = format!("/{repo}/.git");
2785 fs.with_git_state(std::path::Path::new(&git_path), false, |state| {
2786 for branch in &["olivetti", "selectric"] {
2787 state.worktrees.push(git::repository::Worktree {
2788 path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
2789 ref_name: Some(format!("refs/heads/{branch}").into()),
2790 sha: "aaa".into(),
2791 });
2792 }
2793 })
2794 .unwrap();
2795 }
2796
2797 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2798
2799 // Open a workspace with the worktree checkout paths as roots
2800 // (this is the workspace the thread was created in).
2801 let project = project::Project::test(
2802 fs.clone(),
2803 [
2804 "/worktrees/project_a/olivetti/project_a".as_ref(),
2805 "/worktrees/project_b/selectric/project_b".as_ref(),
2806 ],
2807 cx,
2808 )
2809 .await;
2810 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2811
2812 let (multi_workspace, cx) =
2813 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2814 let sidebar = setup_sidebar(&multi_workspace, cx);
2815
2816 // Save a thread under the same paths as the workspace roots.
2817 let thread_paths = PathList::new(&[
2818 std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
2819 std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"),
2820 ]);
2821 save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await;
2822
2823 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2824 cx.run_until_parked();
2825
2826 // Should show two distinct worktree chips.
2827 assert_eq!(
2828 visible_entries_as_strings(&sidebar, cx),
2829 vec![
2830 "v [project_a, project_b]",
2831 " Cross Worktree Thread {olivetti}, {selectric}",
2832 ]
2833 );
2834}
2835
2836#[gpui::test]
2837async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
2838 // When a thread's roots span multiple repos but share the same
2839 // worktree name (e.g. both in "olivetti"), only one chip should
2840 // appear.
2841 init_test(cx);
2842 let fs = FakeFs::new(cx.executor());
2843
2844 fs.insert_tree(
2845 "/project_a",
2846 serde_json::json!({
2847 ".git": {
2848 "worktrees": {
2849 "olivetti": {
2850 "commondir": "../../",
2851 "HEAD": "ref: refs/heads/olivetti",
2852 },
2853 },
2854 },
2855 "src": {},
2856 }),
2857 )
2858 .await;
2859 fs.insert_tree(
2860 "/project_b",
2861 serde_json::json!({
2862 ".git": {
2863 "worktrees": {
2864 "olivetti": {
2865 "commondir": "../../",
2866 "HEAD": "ref: refs/heads/olivetti",
2867 },
2868 },
2869 },
2870 "src": {},
2871 }),
2872 )
2873 .await;
2874
2875 for repo in &["project_a", "project_b"] {
2876 let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}");
2877 let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti");
2878 fs.insert_tree(
2879 &worktree_path,
2880 serde_json::json!({
2881 ".git": gitdir,
2882 "src": {},
2883 }),
2884 )
2885 .await;
2886
2887 let git_path = format!("/{repo}/.git");
2888 fs.with_git_state(std::path::Path::new(&git_path), false, |state| {
2889 state.worktrees.push(git::repository::Worktree {
2890 path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
2891 ref_name: Some("refs/heads/olivetti".into()),
2892 sha: "aaa".into(),
2893 });
2894 })
2895 .unwrap();
2896 }
2897
2898 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2899
2900 let project = project::Project::test(
2901 fs.clone(),
2902 [
2903 "/worktrees/project_a/olivetti/project_a".as_ref(),
2904 "/worktrees/project_b/olivetti/project_b".as_ref(),
2905 ],
2906 cx,
2907 )
2908 .await;
2909 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2910
2911 let (multi_workspace, cx) =
2912 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2913 let sidebar = setup_sidebar(&multi_workspace, cx);
2914
2915 // Thread with roots in both repos' "olivetti" worktrees.
2916 let thread_paths = PathList::new(&[
2917 std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
2918 std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"),
2919 ]);
2920 save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await;
2921
2922 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2923 cx.run_until_parked();
2924
2925 // Both worktree paths have the name "olivetti", so only one chip.
2926 assert_eq!(
2927 visible_entries_as_strings(&sidebar, cx),
2928 vec![
2929 "v [project_a, project_b]",
2930 " Same Branch Thread {olivetti}",
2931 ]
2932 );
2933}
2934
2935#[gpui::test]
2936async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
2937 // When a worktree workspace is absorbed under the main repo, a
2938 // running thread in the worktree's agent panel should still show
2939 // live status (spinner + "(running)") in the sidebar.
2940 agent_ui::test_support::init_test(cx);
2941 cx.update(|cx| {
2942 cx.update_flags(false, vec!["agent-v2".into()]);
2943 ThreadStore::init_global(cx);
2944 ThreadMetadataStore::init_global(cx);
2945 language_model::LanguageModelRegistry::test(cx);
2946 prompt_store::init(cx);
2947 });
2948
2949 let fs = FakeFs::new(cx.executor());
2950
2951 // Main repo with a linked worktree.
2952 fs.insert_tree(
2953 "/project",
2954 serde_json::json!({
2955 ".git": {
2956 "worktrees": {
2957 "feature-a": {
2958 "commondir": "../../",
2959 "HEAD": "ref: refs/heads/feature-a",
2960 },
2961 },
2962 },
2963 "src": {},
2964 }),
2965 )
2966 .await;
2967
2968 // Worktree checkout pointing back to the main repo.
2969 fs.insert_tree(
2970 "/wt-feature-a",
2971 serde_json::json!({
2972 ".git": "gitdir: /project/.git/worktrees/feature-a",
2973 "src": {},
2974 }),
2975 )
2976 .await;
2977
2978 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
2979 state.worktrees.push(git::repository::Worktree {
2980 path: std::path::PathBuf::from("/wt-feature-a"),
2981 ref_name: Some("refs/heads/feature-a".into()),
2982 sha: "aaa".into(),
2983 });
2984 })
2985 .unwrap();
2986
2987 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2988
2989 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2990 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2991
2992 main_project
2993 .update(cx, |p, cx| p.git_scans_complete(cx))
2994 .await;
2995 worktree_project
2996 .update(cx, |p, cx| p.git_scans_complete(cx))
2997 .await;
2998
2999 // Create the MultiWorkspace with both projects.
3000 let (multi_workspace, cx) =
3001 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3002
3003 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3004 mw.test_add_workspace(worktree_project.clone(), window, cx)
3005 });
3006
3007 // Add an agent panel to the worktree workspace so we can run a
3008 // thread inside it.
3009 let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
3010
3011 // Switch back to the main workspace before setting up the sidebar.
3012 multi_workspace.update_in(cx, |mw, window, cx| {
3013 let workspace = mw.workspaces()[0].clone();
3014 mw.activate(workspace, window, cx);
3015 });
3016
3017 let sidebar = setup_sidebar(&multi_workspace, cx);
3018
3019 // Start a thread in the worktree workspace's panel and keep it
3020 // generating (don't resolve it).
3021 let connection = StubAgentConnection::new();
3022 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3023 send_message(&worktree_panel, cx);
3024
3025 let session_id = active_session_id(&worktree_panel, cx);
3026
3027 // Save metadata so the sidebar knows about this thread.
3028 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
3029 save_test_thread_metadata(&session_id, wt_paths, cx).await;
3030
3031 // Keep the thread generating by sending a chunk without ending
3032 // the turn.
3033 cx.update(|_, cx| {
3034 connection.send_update(
3035 session_id.clone(),
3036 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3037 cx,
3038 );
3039 });
3040 cx.run_until_parked();
3041
3042 // The worktree thread should be absorbed under the main project
3043 // and show live running status.
3044 let entries = visible_entries_as_strings(&sidebar, cx);
3045 assert_eq!(
3046 entries,
3047 vec![
3048 "v [project]",
3049 " [+ New Thread]",
3050 " Hello {wt-feature-a} * (running)",
3051 ]
3052 );
3053}
3054
3055#[gpui::test]
3056async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
3057 agent_ui::test_support::init_test(cx);
3058 cx.update(|cx| {
3059 cx.update_flags(false, vec!["agent-v2".into()]);
3060 ThreadStore::init_global(cx);
3061 ThreadMetadataStore::init_global(cx);
3062 language_model::LanguageModelRegistry::test(cx);
3063 prompt_store::init(cx);
3064 });
3065
3066 let fs = FakeFs::new(cx.executor());
3067
3068 fs.insert_tree(
3069 "/project",
3070 serde_json::json!({
3071 ".git": {
3072 "worktrees": {
3073 "feature-a": {
3074 "commondir": "../../",
3075 "HEAD": "ref: refs/heads/feature-a",
3076 },
3077 },
3078 },
3079 "src": {},
3080 }),
3081 )
3082 .await;
3083
3084 fs.insert_tree(
3085 "/wt-feature-a",
3086 serde_json::json!({
3087 ".git": "gitdir: /project/.git/worktrees/feature-a",
3088 "src": {},
3089 }),
3090 )
3091 .await;
3092
3093 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
3094 state.worktrees.push(git::repository::Worktree {
3095 path: std::path::PathBuf::from("/wt-feature-a"),
3096 ref_name: Some("refs/heads/feature-a".into()),
3097 sha: "aaa".into(),
3098 });
3099 })
3100 .unwrap();
3101
3102 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3103
3104 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3105 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3106
3107 main_project
3108 .update(cx, |p, cx| p.git_scans_complete(cx))
3109 .await;
3110 worktree_project
3111 .update(cx, |p, cx| p.git_scans_complete(cx))
3112 .await;
3113
3114 let (multi_workspace, cx) =
3115 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3116
3117 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3118 mw.test_add_workspace(worktree_project.clone(), window, cx)
3119 });
3120
3121 let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
3122
3123 multi_workspace.update_in(cx, |mw, window, cx| {
3124 let workspace = mw.workspaces()[0].clone();
3125 mw.activate(workspace, window, cx);
3126 });
3127
3128 let sidebar = setup_sidebar(&multi_workspace, cx);
3129
3130 let connection = StubAgentConnection::new();
3131 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3132 send_message(&worktree_panel, cx);
3133
3134 let session_id = active_session_id(&worktree_panel, cx);
3135 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
3136 save_test_thread_metadata(&session_id, wt_paths, cx).await;
3137
3138 cx.update(|_, cx| {
3139 connection.send_update(
3140 session_id.clone(),
3141 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3142 cx,
3143 );
3144 });
3145 cx.run_until_parked();
3146
3147 assert_eq!(
3148 visible_entries_as_strings(&sidebar, cx),
3149 vec![
3150 "v [project]",
3151 " [+ New Thread]",
3152 " Hello {wt-feature-a} * (running)",
3153 ]
3154 );
3155
3156 connection.end_turn(session_id, acp::StopReason::EndTurn);
3157 cx.run_until_parked();
3158
3159 assert_eq!(
3160 visible_entries_as_strings(&sidebar, cx),
3161 vec![
3162 "v [project]",
3163 " [+ New Thread]",
3164 " Hello {wt-feature-a} * (!)",
3165 ]
3166 );
3167}
3168
3169#[gpui::test]
3170async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
3171 init_test(cx);
3172 let fs = FakeFs::new(cx.executor());
3173
3174 fs.insert_tree(
3175 "/project",
3176 serde_json::json!({
3177 ".git": {
3178 "worktrees": {
3179 "feature-a": {
3180 "commondir": "../../",
3181 "HEAD": "ref: refs/heads/feature-a",
3182 },
3183 },
3184 },
3185 "src": {},
3186 }),
3187 )
3188 .await;
3189
3190 fs.insert_tree(
3191 "/wt-feature-a",
3192 serde_json::json!({
3193 ".git": "gitdir: /project/.git/worktrees/feature-a",
3194 "src": {},
3195 }),
3196 )
3197 .await;
3198
3199 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
3200 state.worktrees.push(git::repository::Worktree {
3201 path: std::path::PathBuf::from("/wt-feature-a"),
3202 ref_name: Some("refs/heads/feature-a".into()),
3203 sha: "aaa".into(),
3204 });
3205 })
3206 .unwrap();
3207
3208 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3209
3210 // Only open the main repo — no workspace for the worktree.
3211 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3212 main_project
3213 .update(cx, |p, cx| p.git_scans_complete(cx))
3214 .await;
3215
3216 let (multi_workspace, cx) =
3217 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3218 let sidebar = setup_sidebar(&multi_workspace, cx);
3219
3220 // Save a thread for the worktree path (no workspace for it).
3221 let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
3222 save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
3223
3224 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3225 cx.run_until_parked();
3226
3227 // Thread should appear under the main repo with a worktree chip.
3228 assert_eq!(
3229 visible_entries_as_strings(&sidebar, cx),
3230 vec![
3231 "v [project]",
3232 " [+ New Thread]",
3233 " WT Thread {wt-feature-a}"
3234 ],
3235 );
3236
3237 // Only 1 workspace should exist.
3238 assert_eq!(
3239 multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
3240 1,
3241 );
3242
3243 // Focus the sidebar and select the worktree thread.
3244 open_and_focus_sidebar(&sidebar, cx);
3245 sidebar.update_in(cx, |sidebar, _window, _cx| {
3246 sidebar.selection = Some(2); // index 0 is header, 1 is new thread, 2 is the thread
3247 });
3248
3249 // Confirm to open the worktree thread.
3250 cx.dispatch_action(Confirm);
3251 cx.run_until_parked();
3252
3253 // A new workspace should have been created for the worktree path.
3254 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
3255 assert_eq!(
3256 mw.workspaces().len(),
3257 2,
3258 "confirming a worktree thread without a workspace should open one",
3259 );
3260 mw.workspaces()[1].clone()
3261 });
3262
3263 let new_path_list =
3264 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
3265 assert_eq!(
3266 new_path_list,
3267 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
3268 "the new workspace should have been opened for the worktree path",
3269 );
3270}
3271
3272#[gpui::test]
3273async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
3274 cx: &mut TestAppContext,
3275) {
3276 init_test(cx);
3277 let fs = FakeFs::new(cx.executor());
3278
3279 fs.insert_tree(
3280 "/project",
3281 serde_json::json!({
3282 ".git": {
3283 "worktrees": {
3284 "feature-a": {
3285 "commondir": "../../",
3286 "HEAD": "ref: refs/heads/feature-a",
3287 },
3288 },
3289 },
3290 "src": {},
3291 }),
3292 )
3293 .await;
3294
3295 fs.insert_tree(
3296 "/wt-feature-a",
3297 serde_json::json!({
3298 ".git": "gitdir: /project/.git/worktrees/feature-a",
3299 "src": {},
3300 }),
3301 )
3302 .await;
3303
3304 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
3305 state.worktrees.push(git::repository::Worktree {
3306 path: std::path::PathBuf::from("/wt-feature-a"),
3307 ref_name: Some("refs/heads/feature-a".into()),
3308 sha: "aaa".into(),
3309 });
3310 })
3311 .unwrap();
3312
3313 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3314
3315 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3316 main_project
3317 .update(cx, |p, cx| p.git_scans_complete(cx))
3318 .await;
3319
3320 let (multi_workspace, cx) =
3321 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3322 let sidebar = setup_sidebar(&multi_workspace, cx);
3323
3324 let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
3325 save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
3326
3327 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3328 cx.run_until_parked();
3329
3330 assert_eq!(
3331 visible_entries_as_strings(&sidebar, cx),
3332 vec![
3333 "v [project]",
3334 " [+ New Thread]",
3335 " WT Thread {wt-feature-a}"
3336 ],
3337 );
3338
3339 open_and_focus_sidebar(&sidebar, cx);
3340 sidebar.update_in(cx, |sidebar, _window, _cx| {
3341 sidebar.selection = Some(2);
3342 });
3343
3344 let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
3345 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
3346 if let ListEntry::ProjectHeader { label, .. } = entry {
3347 Some(label.as_ref())
3348 } else {
3349 None
3350 }
3351 });
3352
3353 let Some(project_header) = project_headers.next() else {
3354 panic!("expected exactly one sidebar project header named `project`, found none");
3355 };
3356 assert_eq!(
3357 project_header, "project",
3358 "expected the only sidebar project header to be `project`"
3359 );
3360 if let Some(unexpected_header) = project_headers.next() {
3361 panic!(
3362 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
3363 );
3364 }
3365
3366 let mut saw_expected_thread = false;
3367 for entry in &sidebar.contents.entries {
3368 match entry {
3369 ListEntry::ProjectHeader { label, .. } => {
3370 assert_eq!(
3371 label.as_ref(),
3372 "project",
3373 "expected the only sidebar project header to be `project`"
3374 );
3375 }
3376 ListEntry::Thread(thread)
3377 if thread.metadata.title.as_ref() == "WT Thread"
3378 && thread.worktrees.first().map(|wt| wt.name.as_ref())
3379 == Some("wt-feature-a") =>
3380 {
3381 saw_expected_thread = true;
3382 }
3383 ListEntry::Thread(thread) => {
3384 let title = thread.metadata.title.as_ref();
3385 let worktree_name = thread
3386 .worktrees
3387 .first()
3388 .map(|wt| wt.name.as_ref())
3389 .unwrap_or("<none>");
3390 panic!(
3391 "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`"
3392 );
3393 }
3394 ListEntry::ViewMore { .. } => {
3395 panic!("unexpected `View More` entry while opening linked worktree thread");
3396 }
3397 ListEntry::NewThread { .. } => {}
3398 }
3399 }
3400
3401 assert!(
3402 saw_expected_thread,
3403 "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
3404 );
3405 };
3406
3407 sidebar
3408 .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
3409 .detach();
3410
3411 let window = cx.windows()[0];
3412 cx.update_window(window, |_, window, cx| {
3413 window.dispatch_action(Confirm.boxed_clone(), cx);
3414 })
3415 .unwrap();
3416
3417 cx.run_until_parked();
3418
3419 sidebar.update(cx, assert_sidebar_state);
3420}
3421
3422#[gpui::test]
3423async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
3424 cx: &mut TestAppContext,
3425) {
3426 init_test(cx);
3427 let fs = FakeFs::new(cx.executor());
3428
3429 fs.insert_tree(
3430 "/project",
3431 serde_json::json!({
3432 ".git": {
3433 "worktrees": {
3434 "feature-a": {
3435 "commondir": "../../",
3436 "HEAD": "ref: refs/heads/feature-a",
3437 },
3438 },
3439 },
3440 "src": {},
3441 }),
3442 )
3443 .await;
3444
3445 fs.insert_tree(
3446 "/wt-feature-a",
3447 serde_json::json!({
3448 ".git": "gitdir: /project/.git/worktrees/feature-a",
3449 "src": {},
3450 }),
3451 )
3452 .await;
3453
3454 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
3455 state.worktrees.push(git::repository::Worktree {
3456 path: std::path::PathBuf::from("/wt-feature-a"),
3457 ref_name: Some("refs/heads/feature-a".into()),
3458 sha: "aaa".into(),
3459 });
3460 })
3461 .unwrap();
3462
3463 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3464
3465 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3466 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3467
3468 main_project
3469 .update(cx, |p, cx| p.git_scans_complete(cx))
3470 .await;
3471 worktree_project
3472 .update(cx, |p, cx| p.git_scans_complete(cx))
3473 .await;
3474
3475 let (multi_workspace, cx) =
3476 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3477
3478 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3479 mw.test_add_workspace(worktree_project.clone(), window, cx)
3480 });
3481
3482 // Activate the main workspace before setting up the sidebar.
3483 multi_workspace.update_in(cx, |mw, window, cx| {
3484 let workspace = mw.workspaces()[0].clone();
3485 mw.activate(workspace, window, cx);
3486 });
3487
3488 let sidebar = setup_sidebar(&multi_workspace, cx);
3489
3490 let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
3491 let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
3492 save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await;
3493 save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
3494
3495 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3496 cx.run_until_parked();
3497
3498 // The worktree workspace should be absorbed under the main repo.
3499 let entries = visible_entries_as_strings(&sidebar, cx);
3500 assert_eq!(entries.len(), 3);
3501 assert_eq!(entries[0], "v [project]");
3502 assert!(entries.contains(&" Main Thread".to_string()));
3503 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
3504
3505 let wt_thread_index = entries
3506 .iter()
3507 .position(|e| e.contains("WT Thread"))
3508 .expect("should find the worktree thread entry");
3509
3510 assert_eq!(
3511 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3512 0,
3513 "main workspace should be active initially"
3514 );
3515
3516 // Focus the sidebar and select the absorbed worktree thread.
3517 open_and_focus_sidebar(&sidebar, cx);
3518 sidebar.update_in(cx, |sidebar, _window, _cx| {
3519 sidebar.selection = Some(wt_thread_index);
3520 });
3521
3522 // Confirm to activate the worktree thread.
3523 cx.dispatch_action(Confirm);
3524 cx.run_until_parked();
3525
3526 // The worktree workspace should now be active, not the main one.
3527 let active_workspace = multi_workspace.read_with(cx, |mw, _| {
3528 mw.workspaces()[mw.active_workspace_index()].clone()
3529 });
3530 assert_eq!(
3531 active_workspace, worktree_workspace,
3532 "clicking an absorbed worktree thread should activate the worktree workspace"
3533 );
3534}
3535
3536#[gpui::test]
3537async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
3538 cx: &mut TestAppContext,
3539) {
3540 // Thread has saved metadata in ThreadStore. A matching workspace is
3541 // already open. Expected: activates the matching workspace.
3542 init_test(cx);
3543 let fs = FakeFs::new(cx.executor());
3544 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3545 .await;
3546 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3547 .await;
3548 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3549
3550 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3551 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3552
3553 let (multi_workspace, cx) =
3554 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3555
3556 multi_workspace.update_in(cx, |mw, window, cx| {
3557 mw.test_add_workspace(project_b, window, cx);
3558 });
3559
3560 let sidebar = setup_sidebar(&multi_workspace, cx);
3561
3562 // Save a thread with path_list pointing to project-b.
3563 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
3564 let session_id = acp::SessionId::new(Arc::from("archived-1"));
3565 save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await;
3566
3567 // Ensure workspace A is active.
3568 multi_workspace.update_in(cx, |mw, window, cx| {
3569 let workspace = mw.workspaces()[0].clone();
3570 mw.activate(workspace, window, cx);
3571 });
3572 cx.run_until_parked();
3573 assert_eq!(
3574 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3575 0
3576 );
3577
3578 // Call activate_archived_thread – should resolve saved paths and
3579 // switch to the workspace for project-b.
3580 sidebar.update_in(cx, |sidebar, window, cx| {
3581 sidebar.activate_archived_thread(
3582 ThreadMetadata {
3583 session_id: session_id.clone(),
3584 agent_id: agent::ZED_AGENT_ID.clone(),
3585 title: "Archived Thread".into(),
3586 updated_at: Utc::now(),
3587 created_at: None,
3588 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3589 archived: false,
3590 },
3591 window,
3592 cx,
3593 );
3594 });
3595 cx.run_until_parked();
3596
3597 assert_eq!(
3598 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3599 1,
3600 "should have activated the workspace matching the saved path_list"
3601 );
3602}
3603
3604#[gpui::test]
3605async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
3606 cx: &mut TestAppContext,
3607) {
3608 // Thread has no saved metadata but session_info has cwd. A matching
3609 // workspace is open. Expected: uses cwd to find and activate it.
3610 init_test(cx);
3611 let fs = FakeFs::new(cx.executor());
3612 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3613 .await;
3614 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3615 .await;
3616 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3617
3618 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3619 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3620
3621 let (multi_workspace, cx) =
3622 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3623
3624 multi_workspace.update_in(cx, |mw, window, cx| {
3625 mw.test_add_workspace(project_b, window, cx);
3626 });
3627
3628 let sidebar = setup_sidebar(&multi_workspace, cx);
3629
3630 // Start with workspace A active.
3631 multi_workspace.update_in(cx, |mw, window, cx| {
3632 let workspace = mw.workspaces()[0].clone();
3633 mw.activate(workspace, window, cx);
3634 });
3635 cx.run_until_parked();
3636 assert_eq!(
3637 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3638 0
3639 );
3640
3641 // No thread saved to the store – cwd is the only path hint.
3642 sidebar.update_in(cx, |sidebar, window, cx| {
3643 sidebar.activate_archived_thread(
3644 ThreadMetadata {
3645 session_id: acp::SessionId::new(Arc::from("unknown-session")),
3646 agent_id: agent::ZED_AGENT_ID.clone(),
3647 title: "CWD Thread".into(),
3648 updated_at: Utc::now(),
3649 created_at: None,
3650 folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]),
3651 archived: false,
3652 },
3653 window,
3654 cx,
3655 );
3656 });
3657 cx.run_until_parked();
3658
3659 assert_eq!(
3660 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3661 1,
3662 "should have activated the workspace matching the cwd"
3663 );
3664}
3665
3666#[gpui::test]
3667async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
3668 cx: &mut TestAppContext,
3669) {
3670 // Thread has no saved metadata and no cwd. Expected: falls back to
3671 // the currently active workspace.
3672 init_test(cx);
3673 let fs = FakeFs::new(cx.executor());
3674 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3675 .await;
3676 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3677 .await;
3678 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3679
3680 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3681 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3682
3683 let (multi_workspace, cx) =
3684 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3685
3686 multi_workspace.update_in(cx, |mw, window, cx| {
3687 mw.test_add_workspace(project_b, window, cx);
3688 });
3689
3690 let sidebar = setup_sidebar(&multi_workspace, cx);
3691
3692 // Activate workspace B (index 1) to make it the active one.
3693 multi_workspace.update_in(cx, |mw, window, cx| {
3694 let workspace = mw.workspaces()[1].clone();
3695 mw.activate(workspace, window, cx);
3696 });
3697 cx.run_until_parked();
3698 assert_eq!(
3699 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3700 1
3701 );
3702
3703 // No saved thread, no cwd – should fall back to the active workspace.
3704 sidebar.update_in(cx, |sidebar, window, cx| {
3705 sidebar.activate_archived_thread(
3706 ThreadMetadata {
3707 session_id: acp::SessionId::new(Arc::from("no-context-session")),
3708 agent_id: agent::ZED_AGENT_ID.clone(),
3709 title: "Contextless Thread".into(),
3710 updated_at: Utc::now(),
3711 created_at: None,
3712 folder_paths: PathList::default(),
3713 archived: false,
3714 },
3715 window,
3716 cx,
3717 );
3718 });
3719 cx.run_until_parked();
3720
3721 assert_eq!(
3722 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3723 1,
3724 "should have stayed on the active workspace when no path info is available"
3725 );
3726}
3727
3728#[gpui::test]
3729async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
3730 // Thread has saved metadata pointing to a path with no open workspace.
3731 // Expected: opens a new workspace for that path.
3732 init_test(cx);
3733 let fs = FakeFs::new(cx.executor());
3734 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3735 .await;
3736 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3737 .await;
3738 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3739
3740 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3741
3742 let (multi_workspace, cx) =
3743 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3744
3745 let sidebar = setup_sidebar(&multi_workspace, cx);
3746
3747 // Save a thread with path_list pointing to project-b – which has no
3748 // open workspace.
3749 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
3750 let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
3751
3752 assert_eq!(
3753 multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
3754 1,
3755 "should start with one workspace"
3756 );
3757
3758 sidebar.update_in(cx, |sidebar, window, cx| {
3759 sidebar.activate_archived_thread(
3760 ThreadMetadata {
3761 session_id: session_id.clone(),
3762 agent_id: agent::ZED_AGENT_ID.clone(),
3763 title: "New WS Thread".into(),
3764 updated_at: Utc::now(),
3765 created_at: None,
3766 folder_paths: path_list_b,
3767 archived: false,
3768 },
3769 window,
3770 cx,
3771 );
3772 });
3773 cx.run_until_parked();
3774
3775 assert_eq!(
3776 multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
3777 2,
3778 "should have opened a second workspace for the archived thread's saved paths"
3779 );
3780}
3781
3782#[gpui::test]
3783async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
3784 init_test(cx);
3785 let fs = FakeFs::new(cx.executor());
3786 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3787 .await;
3788 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3789 .await;
3790 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3791
3792 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3793 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3794
3795 let multi_workspace_a =
3796 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3797 let multi_workspace_b =
3798 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
3799
3800 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3801
3802 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
3803 let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
3804
3805 let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
3806
3807 sidebar.update_in(cx_a, |sidebar, window, cx| {
3808 sidebar.activate_archived_thread(
3809 ThreadMetadata {
3810 session_id: session_id.clone(),
3811 agent_id: agent::ZED_AGENT_ID.clone(),
3812 title: "Cross Window Thread".into(),
3813 updated_at: Utc::now(),
3814 created_at: None,
3815 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3816 archived: false,
3817 },
3818 window,
3819 cx,
3820 );
3821 });
3822 cx_a.run_until_parked();
3823
3824 assert_eq!(
3825 multi_workspace_a
3826 .read_with(cx_a, |mw, _| mw.workspaces().len())
3827 .unwrap(),
3828 1,
3829 "should not add the other window's workspace into the current window"
3830 );
3831 assert_eq!(
3832 multi_workspace_b
3833 .read_with(cx_a, |mw, _| mw.workspaces().len())
3834 .unwrap(),
3835 1,
3836 "should reuse the existing workspace in the other window"
3837 );
3838 assert!(
3839 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
3840 "should activate the window that already owns the matching workspace"
3841 );
3842 sidebar.read_with(cx_a, |sidebar, _| {
3843 assert_eq!(
3844 sidebar.focused_thread, None,
3845 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
3846 );
3847 });
3848}
3849
3850#[gpui::test]
3851async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
3852 cx: &mut TestAppContext,
3853) {
3854 init_test(cx);
3855 let fs = FakeFs::new(cx.executor());
3856 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3857 .await;
3858 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3859 .await;
3860 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3861
3862 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3863 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3864
3865 let multi_workspace_a =
3866 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3867 let multi_workspace_b =
3868 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
3869
3870 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3871 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
3872
3873 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
3874 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
3875
3876 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
3877 let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
3878 let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
3879 let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b);
3880
3881 let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
3882
3883 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
3884 sidebar.activate_archived_thread(
3885 ThreadMetadata {
3886 session_id: session_id.clone(),
3887 agent_id: agent::ZED_AGENT_ID.clone(),
3888 title: "Cross Window Thread".into(),
3889 updated_at: Utc::now(),
3890 created_at: None,
3891 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3892 archived: false,
3893 },
3894 window,
3895 cx,
3896 );
3897 });
3898 cx_a.run_until_parked();
3899
3900 assert_eq!(
3901 multi_workspace_a
3902 .read_with(cx_a, |mw, _| mw.workspaces().len())
3903 .unwrap(),
3904 1,
3905 "should not add the other window's workspace into the current window"
3906 );
3907 assert_eq!(
3908 multi_workspace_b
3909 .read_with(cx_a, |mw, _| mw.workspaces().len())
3910 .unwrap(),
3911 1,
3912 "should reuse the existing workspace in the other window"
3913 );
3914 assert!(
3915 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
3916 "should activate the window that already owns the matching workspace"
3917 );
3918 sidebar_a.read_with(cx_a, |sidebar, _| {
3919 assert_eq!(
3920 sidebar.focused_thread, None,
3921 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
3922 );
3923 });
3924 sidebar_b.read_with(cx_b, |sidebar, _| {
3925 assert_eq!(
3926 sidebar.focused_thread.as_ref(),
3927 Some(&session_id),
3928 "target window's sidebar should eagerly focus the activated archived thread"
3929 );
3930 });
3931}
3932
3933#[gpui::test]
3934async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
3935 cx: &mut TestAppContext,
3936) {
3937 init_test(cx);
3938 let fs = FakeFs::new(cx.executor());
3939 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3940 .await;
3941 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3942
3943 let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3944 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3945
3946 let multi_workspace_b =
3947 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
3948 let multi_workspace_a =
3949 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3950
3951 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3952
3953 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
3954 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
3955
3956 let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
3957
3958 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
3959 sidebar.activate_archived_thread(
3960 ThreadMetadata {
3961 session_id: session_id.clone(),
3962 agent_id: agent::ZED_AGENT_ID.clone(),
3963 title: "Current Window Thread".into(),
3964 updated_at: Utc::now(),
3965 created_at: None,
3966 folder_paths: PathList::new(&[PathBuf::from("/project-a")]),
3967 archived: false,
3968 },
3969 window,
3970 cx,
3971 );
3972 });
3973 cx_a.run_until_parked();
3974
3975 assert!(
3976 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
3977 "should keep activation in the current window when it already has a matching workspace"
3978 );
3979 sidebar_a.read_with(cx_a, |sidebar, _| {
3980 assert_eq!(
3981 sidebar.focused_thread.as_ref(),
3982 Some(&session_id),
3983 "current window's sidebar should eagerly focus the activated archived thread"
3984 );
3985 });
3986 assert_eq!(
3987 multi_workspace_a
3988 .read_with(cx_a, |mw, _| mw.workspaces().len())
3989 .unwrap(),
3990 1,
3991 "current window should continue reusing its existing workspace"
3992 );
3993 assert_eq!(
3994 multi_workspace_b
3995 .read_with(cx_a, |mw, _| mw.workspaces().len())
3996 .unwrap(),
3997 1,
3998 "other windows should not be activated just because they also match the saved paths"
3999 );
4000}
4001
4002#[gpui::test]
4003async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
4004 // Regression test: archive_thread previously always loaded the next thread
4005 // through group_workspace (the main workspace's ProjectHeader), even when
4006 // the next thread belonged to an absorbed linked-worktree workspace. That
4007 // caused the worktree thread to be loaded in the main panel, which bound it
4008 // to the main project and corrupted its stored folder_paths.
4009 //
4010 // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
4011 // falling back to group_workspace only for Closed workspaces.
4012 agent_ui::test_support::init_test(cx);
4013 cx.update(|cx| {
4014 cx.update_flags(false, vec!["agent-v2".into()]);
4015 ThreadStore::init_global(cx);
4016 ThreadMetadataStore::init_global(cx);
4017 language_model::LanguageModelRegistry::test(cx);
4018 prompt_store::init(cx);
4019 });
4020
4021 let fs = FakeFs::new(cx.executor());
4022
4023 fs.insert_tree(
4024 "/project",
4025 serde_json::json!({
4026 ".git": {
4027 "worktrees": {
4028 "feature-a": {
4029 "commondir": "../../",
4030 "HEAD": "ref: refs/heads/feature-a",
4031 },
4032 },
4033 },
4034 "src": {},
4035 }),
4036 )
4037 .await;
4038
4039 fs.insert_tree(
4040 "/wt-feature-a",
4041 serde_json::json!({
4042 ".git": "gitdir: /project/.git/worktrees/feature-a",
4043 "src": {},
4044 }),
4045 )
4046 .await;
4047
4048 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4049 state.worktrees.push(git::repository::Worktree {
4050 path: std::path::PathBuf::from("/wt-feature-a"),
4051 ref_name: Some("refs/heads/feature-a".into()),
4052 sha: "aaa".into(),
4053 });
4054 })
4055 .unwrap();
4056
4057 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4058
4059 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4060 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4061
4062 main_project
4063 .update(cx, |p, cx| p.git_scans_complete(cx))
4064 .await;
4065 worktree_project
4066 .update(cx, |p, cx| p.git_scans_complete(cx))
4067 .await;
4068
4069 let (multi_workspace, cx) =
4070 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4071
4072 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4073 mw.test_add_workspace(worktree_project.clone(), window, cx)
4074 });
4075
4076 // Activate main workspace so the sidebar tracks the main panel.
4077 multi_workspace.update_in(cx, |mw, window, cx| {
4078 let workspace = mw.workspaces()[0].clone();
4079 mw.activate(workspace, window, cx);
4080 });
4081
4082 let sidebar = setup_sidebar(&multi_workspace, cx);
4083
4084 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone());
4085 let main_panel = add_agent_panel(&main_workspace, &main_project, cx);
4086 let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
4087
4088 // Open Thread 2 in the main panel and keep it running.
4089 let connection = StubAgentConnection::new();
4090 open_thread_with_connection(&main_panel, connection.clone(), cx);
4091 send_message(&main_panel, cx);
4092
4093 let thread2_session_id = active_session_id(&main_panel, cx);
4094
4095 cx.update(|_, cx| {
4096 connection.send_update(
4097 thread2_session_id.clone(),
4098 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4099 cx,
4100 );
4101 });
4102
4103 // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
4104 save_thread_metadata(
4105 thread2_session_id.clone(),
4106 "Thread 2".into(),
4107 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4108 PathList::new(&[std::path::PathBuf::from("/project")]),
4109 cx,
4110 )
4111 .await;
4112
4113 // Save thread 1's metadata with the worktree path and an older timestamp so
4114 // it sorts below thread 2. archive_thread will find it as the "next" candidate.
4115 let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
4116 save_thread_metadata(
4117 thread1_session_id.clone(),
4118 "Thread 1".into(),
4119 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4120 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
4121 cx,
4122 )
4123 .await;
4124
4125 cx.run_until_parked();
4126
4127 // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
4128 let entries_before = visible_entries_as_strings(&sidebar, cx);
4129 assert!(
4130 entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
4131 "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
4132 entries_before
4133 );
4134
4135 // The sidebar should track T2 as the focused thread (derived from the
4136 // main panel's active view).
4137 let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone());
4138 assert_eq!(
4139 focused,
4140 Some(thread2_session_id.clone()),
4141 "focused thread should be Thread 2 before archiving: {:?}",
4142 focused
4143 );
4144
4145 // Archive thread 2.
4146 sidebar.update_in(cx, |sidebar, window, cx| {
4147 sidebar.archive_thread(&thread2_session_id, window, cx);
4148 });
4149
4150 cx.run_until_parked();
4151
4152 // The main panel's active thread must still be thread 2.
4153 let main_active = main_panel.read_with(cx, |panel, cx| {
4154 panel
4155 .active_agent_thread(cx)
4156 .map(|t| t.read(cx).session_id().clone())
4157 });
4158 assert_eq!(
4159 main_active,
4160 Some(thread2_session_id.clone()),
4161 "main panel should not have been taken over by loading the linked-worktree thread T1; \
4162 before the fix, archive_thread used group_workspace instead of next.workspace, \
4163 causing T1 to be loaded in the wrong panel"
4164 );
4165
4166 // Thread 1 should still appear in the sidebar with its worktree chip
4167 // (Thread 2 was archived so it is gone from the list).
4168 let entries_after = visible_entries_as_strings(&sidebar, cx);
4169 assert!(
4170 entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
4171 "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
4172 entries_after
4173 );
4174}
4175
4176#[gpui::test]
4177async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
4178 // When a multi-root workspace (e.g. [/other, /project]) shares a
4179 // repo with a single-root workspace (e.g. [/project]), linked
4180 // worktree threads from the shared repo should only appear under
4181 // the dedicated group [project], not under [other, project].
4182 init_test(cx);
4183 let fs = FakeFs::new(cx.executor());
4184
4185 // Two independent repos, each with their own git history.
4186 fs.insert_tree(
4187 "/project",
4188 serde_json::json!({
4189 ".git": {
4190 "worktrees": {
4191 "feature-a": {
4192 "commondir": "../../",
4193 "HEAD": "ref: refs/heads/feature-a",
4194 },
4195 },
4196 },
4197 "src": {},
4198 }),
4199 )
4200 .await;
4201 fs.insert_tree(
4202 "/wt-feature-a",
4203 serde_json::json!({
4204 ".git": "gitdir: /project/.git/worktrees/feature-a",
4205 "src": {},
4206 }),
4207 )
4208 .await;
4209 fs.insert_tree(
4210 "/other",
4211 serde_json::json!({
4212 ".git": {},
4213 "src": {},
4214 }),
4215 )
4216 .await;
4217
4218 // Register the linked worktree in the main repo.
4219 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4220 state.worktrees.push(git::repository::Worktree {
4221 path: std::path::PathBuf::from("/wt-feature-a"),
4222 ref_name: Some("refs/heads/feature-a".into()),
4223 sha: "aaa".into(),
4224 });
4225 })
4226 .unwrap();
4227
4228 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4229
4230 // Workspace 1: just /project.
4231 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4232 project_only
4233 .update(cx, |p, cx| p.git_scans_complete(cx))
4234 .await;
4235
4236 // Workspace 2: /other and /project together (multi-root).
4237 let multi_root =
4238 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
4239 multi_root
4240 .update(cx, |p, cx| p.git_scans_complete(cx))
4241 .await;
4242
4243 let (multi_workspace, cx) =
4244 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
4245 multi_workspace.update_in(cx, |mw, window, cx| {
4246 mw.test_add_workspace(multi_root.clone(), window, cx);
4247 });
4248 let sidebar = setup_sidebar(&multi_workspace, cx);
4249
4250 // Save a thread under the linked worktree path.
4251 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4252 save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
4253
4254 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4255 cx.run_until_parked();
4256
4257 // The thread should appear only under [project] (the dedicated
4258 // group for the /project repo), not under [other, project].
4259 assert_eq!(
4260 visible_entries_as_strings(&sidebar, cx),
4261 vec![
4262 "v [project]",
4263 " [+ New Thread]",
4264 " Worktree Thread {wt-feature-a}",
4265 "v [other, project]",
4266 " [+ New Thread]",
4267 ]
4268 );
4269}
4270
4271#[gpui::test]
4272async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
4273 let project = init_test_project_with_agent_panel("/my-project", cx).await;
4274 let (multi_workspace, cx) =
4275 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4276 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
4277
4278 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4279
4280 let switcher_ids =
4281 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
4282 sidebar.read_with(cx, |sidebar, cx| {
4283 let switcher = sidebar
4284 .thread_switcher
4285 .as_ref()
4286 .expect("switcher should be open");
4287 switcher
4288 .read(cx)
4289 .entries()
4290 .iter()
4291 .map(|e| e.session_id.clone())
4292 .collect()
4293 })
4294 };
4295
4296 let switcher_selected_id =
4297 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
4298 sidebar.read_with(cx, |sidebar, cx| {
4299 let switcher = sidebar
4300 .thread_switcher
4301 .as_ref()
4302 .expect("switcher should be open");
4303 let s = switcher.read(cx);
4304 s.selected_entry()
4305 .expect("should have selection")
4306 .session_id
4307 .clone()
4308 })
4309 };
4310
4311 // ── Setup: create three threads with distinct created_at times ──────
4312 // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
4313 // We send messages in each so they also get last_message_sent_or_queued timestamps.
4314 let connection_c = StubAgentConnection::new();
4315 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4316 acp::ContentChunk::new("Done C".into()),
4317 )]);
4318 open_thread_with_connection(&panel, connection_c, cx);
4319 send_message(&panel, cx);
4320 let session_id_c = active_session_id(&panel, cx);
4321 cx.update(|_, cx| {
4322 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4323 store.save(
4324 ThreadMetadata {
4325 session_id: session_id_c.clone(),
4326 agent_id: agent::ZED_AGENT_ID.clone(),
4327 title: "Thread C".into(),
4328 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0)
4329 .unwrap(),
4330 created_at: Some(
4331 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4332 ),
4333 folder_paths: path_list.clone(),
4334 archived: false,
4335 },
4336 cx,
4337 )
4338 })
4339 });
4340 cx.run_until_parked();
4341
4342 let connection_b = StubAgentConnection::new();
4343 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4344 acp::ContentChunk::new("Done B".into()),
4345 )]);
4346 open_thread_with_connection(&panel, connection_b, cx);
4347 send_message(&panel, cx);
4348 let session_id_b = active_session_id(&panel, cx);
4349 cx.update(|_, cx| {
4350 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4351 store.save(
4352 ThreadMetadata {
4353 session_id: session_id_b.clone(),
4354 agent_id: agent::ZED_AGENT_ID.clone(),
4355 title: "Thread B".into(),
4356 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0)
4357 .unwrap(),
4358 created_at: Some(
4359 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4360 ),
4361 folder_paths: path_list.clone(),
4362 archived: false,
4363 },
4364 cx,
4365 )
4366 })
4367 });
4368 cx.run_until_parked();
4369
4370 let connection_a = StubAgentConnection::new();
4371 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4372 acp::ContentChunk::new("Done A".into()),
4373 )]);
4374 open_thread_with_connection(&panel, connection_a, cx);
4375 send_message(&panel, cx);
4376 let session_id_a = active_session_id(&panel, cx);
4377 cx.update(|_, cx| {
4378 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4379 store.save(
4380 ThreadMetadata {
4381 session_id: session_id_a.clone(),
4382 agent_id: agent::ZED_AGENT_ID.clone(),
4383 title: "Thread A".into(),
4384 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0)
4385 .unwrap(),
4386 created_at: Some(
4387 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
4388 ),
4389 folder_paths: path_list.clone(),
4390 archived: false,
4391 },
4392 cx,
4393 )
4394 })
4395 });
4396 cx.run_until_parked();
4397
4398 // All three threads are now live. Thread A was opened last, so it's
4399 // the one being viewed. Opening each thread called record_thread_access,
4400 // so all three have last_accessed_at set.
4401 // Access order is: A (most recent), B, C (oldest).
4402
4403 // ── 1. Open switcher: threads sorted by last_accessed_at ───────────
4404 open_and_focus_sidebar(&sidebar, cx);
4405 sidebar.update_in(cx, |sidebar, window, cx| {
4406 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4407 });
4408 cx.run_until_parked();
4409
4410 // All three have last_accessed_at, so they sort by access time.
4411 // A was accessed most recently (it's the currently viewed thread),
4412 // then B, then C.
4413 assert_eq!(
4414 switcher_ids(&sidebar, cx),
4415 vec![
4416 session_id_a.clone(),
4417 session_id_b.clone(),
4418 session_id_c.clone()
4419 ],
4420 );
4421 // First ctrl-tab selects the second entry (B).
4422 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
4423
4424 // Dismiss the switcher without confirming.
4425 sidebar.update_in(cx, |sidebar, _window, cx| {
4426 sidebar.dismiss_thread_switcher(cx);
4427 });
4428 cx.run_until_parked();
4429
4430 // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
4431 sidebar.update_in(cx, |sidebar, window, cx| {
4432 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4433 });
4434 cx.run_until_parked();
4435
4436 // Cycle twice to land on Thread C (index 2).
4437 sidebar.read_with(cx, |sidebar, cx| {
4438 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4439 assert_eq!(switcher.read(cx).selected_index(), 1);
4440 });
4441 sidebar.update_in(cx, |sidebar, _window, cx| {
4442 sidebar
4443 .thread_switcher
4444 .as_ref()
4445 .unwrap()
4446 .update(cx, |s, cx| s.cycle_selection(cx));
4447 });
4448 cx.run_until_parked();
4449 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
4450
4451 // Confirm on Thread C.
4452 sidebar.update_in(cx, |sidebar, window, cx| {
4453 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4454 let focus = switcher.focus_handle(cx);
4455 focus.dispatch_action(&menu::Confirm, window, cx);
4456 });
4457 cx.run_until_parked();
4458
4459 // Switcher should be dismissed after confirm.
4460 sidebar.read_with(cx, |sidebar, _cx| {
4461 assert!(
4462 sidebar.thread_switcher.is_none(),
4463 "switcher should be dismissed"
4464 );
4465 });
4466
4467 // Re-open switcher: Thread C is now most-recently-accessed.
4468 sidebar.update_in(cx, |sidebar, window, cx| {
4469 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4470 });
4471 cx.run_until_parked();
4472
4473 assert_eq!(
4474 switcher_ids(&sidebar, cx),
4475 vec![
4476 session_id_c.clone(),
4477 session_id_a.clone(),
4478 session_id_b.clone()
4479 ],
4480 );
4481
4482 sidebar.update_in(cx, |sidebar, _window, cx| {
4483 sidebar.dismiss_thread_switcher(cx);
4484 });
4485 cx.run_until_parked();
4486
4487 // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
4488 // This thread was never opened in a panel — it only exists in metadata.
4489 cx.update(|_, cx| {
4490 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4491 store.save(
4492 ThreadMetadata {
4493 session_id: acp::SessionId::new(Arc::from("thread-historical")),
4494 agent_id: agent::ZED_AGENT_ID.clone(),
4495 title: "Historical Thread".into(),
4496 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0)
4497 .unwrap(),
4498 created_at: Some(
4499 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
4500 ),
4501 folder_paths: path_list.clone(),
4502 archived: false,
4503 },
4504 cx,
4505 )
4506 })
4507 });
4508 cx.run_until_parked();
4509
4510 sidebar.update_in(cx, |sidebar, window, cx| {
4511 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4512 });
4513 cx.run_until_parked();
4514
4515 // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
4516 // so it falls to tier 3 (sorted by created_at). It should appear after all
4517 // accessed threads, even though its created_at (June 2024) is much later
4518 // than the others.
4519 //
4520 // But the live threads (A, B, C) each had send_message called which sets
4521 // last_message_sent_or_queued. So for the accessed threads (tier 1) the
4522 // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
4523 let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
4524 let ids = switcher_ids(&sidebar, cx);
4525 assert_eq!(
4526 ids,
4527 vec![
4528 session_id_c.clone(),
4529 session_id_a.clone(),
4530 session_id_b.clone(),
4531 session_id_hist.clone()
4532 ],
4533 );
4534
4535 sidebar.update_in(cx, |sidebar, _window, cx| {
4536 sidebar.dismiss_thread_switcher(cx);
4537 });
4538 cx.run_until_parked();
4539
4540 // ── 4. Add another historical thread with older created_at ─────────
4541 cx.update(|_, cx| {
4542 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4543 store.save(
4544 ThreadMetadata {
4545 session_id: acp::SessionId::new(Arc::from("thread-old-historical")),
4546 agent_id: agent::ZED_AGENT_ID.clone(),
4547 title: "Old Historical Thread".into(),
4548 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0)
4549 .unwrap(),
4550 created_at: Some(
4551 chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
4552 ),
4553 folder_paths: path_list.clone(),
4554 archived: false,
4555 },
4556 cx,
4557 )
4558 })
4559 });
4560 cx.run_until_parked();
4561
4562 sidebar.update_in(cx, |sidebar, window, cx| {
4563 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4564 });
4565 cx.run_until_parked();
4566
4567 // Both historical threads have no access or message times. They should
4568 // appear after accessed threads, sorted by created_at (newest first).
4569 let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
4570 let ids = switcher_ids(&sidebar, cx);
4571 assert_eq!(
4572 ids,
4573 vec![
4574 session_id_c.clone(),
4575 session_id_a.clone(),
4576 session_id_b.clone(),
4577 session_id_hist,
4578 session_id_old_hist,
4579 ],
4580 );
4581
4582 sidebar.update_in(cx, |sidebar, _window, cx| {
4583 sidebar.dismiss_thread_switcher(cx);
4584 });
4585 cx.run_until_parked();
4586}
4587
4588#[gpui::test]
4589async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
4590 let project = init_test_project("/my-project", cx).await;
4591 let (multi_workspace, cx) =
4592 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4593 let sidebar = setup_sidebar(&multi_workspace, cx);
4594
4595 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4596
4597 save_thread_metadata(
4598 acp::SessionId::new(Arc::from("thread-to-archive")),
4599 "Thread To Archive".into(),
4600 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4601 path_list.clone(),
4602 cx,
4603 )
4604 .await;
4605 cx.run_until_parked();
4606
4607 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4608 cx.run_until_parked();
4609
4610 let entries = visible_entries_as_strings(&sidebar, cx);
4611 assert!(
4612 entries.iter().any(|e| e.contains("Thread To Archive")),
4613 "expected thread to be visible before archiving, got: {entries:?}"
4614 );
4615
4616 sidebar.update_in(cx, |sidebar, window, cx| {
4617 sidebar.archive_thread(
4618 &acp::SessionId::new(Arc::from("thread-to-archive")),
4619 window,
4620 cx,
4621 );
4622 });
4623 cx.run_until_parked();
4624
4625 let entries = visible_entries_as_strings(&sidebar, cx);
4626 assert!(
4627 !entries.iter().any(|e| e.contains("Thread To Archive")),
4628 "expected thread to be hidden after archiving, got: {entries:?}"
4629 );
4630
4631 cx.update(|_, cx| {
4632 let store = ThreadMetadataStore::global(cx);
4633 let archived: Vec<_> = store.read(cx).archived_entries().collect();
4634 assert_eq!(archived.len(), 1);
4635 assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive");
4636 assert!(archived[0].archived);
4637 });
4638}
4639
4640#[gpui::test]
4641async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
4642 let project = init_test_project("/my-project", cx).await;
4643 let (multi_workspace, cx) =
4644 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4645 let sidebar = setup_sidebar(&multi_workspace, cx);
4646
4647 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4648
4649 save_thread_metadata(
4650 acp::SessionId::new(Arc::from("visible-thread")),
4651 "Visible Thread".into(),
4652 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4653 path_list.clone(),
4654 cx,
4655 )
4656 .await;
4657
4658 cx.update(|_, cx| {
4659 let metadata = ThreadMetadata {
4660 session_id: acp::SessionId::new(Arc::from("archived-thread")),
4661 agent_id: agent::ZED_AGENT_ID.clone(),
4662 title: "Archived Thread".into(),
4663 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4664 created_at: None,
4665 folder_paths: path_list.clone(),
4666 archived: true,
4667 };
4668 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
4669 });
4670 cx.run_until_parked();
4671
4672 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4673 cx.run_until_parked();
4674
4675 let entries = visible_entries_as_strings(&sidebar, cx);
4676 assert!(
4677 entries.iter().any(|e| e.contains("Visible Thread")),
4678 "expected visible thread in sidebar, got: {entries:?}"
4679 );
4680 assert!(
4681 !entries.iter().any(|e| e.contains("Archived Thread")),
4682 "expected archived thread to be hidden from sidebar, got: {entries:?}"
4683 );
4684
4685 cx.update(|_, cx| {
4686 let store = ThreadMetadataStore::global(cx);
4687 let all: Vec<_> = store.read(cx).entries().collect();
4688 assert_eq!(
4689 all.len(),
4690 2,
4691 "expected 2 total entries in the store, got: {}",
4692 all.len()
4693 );
4694
4695 let archived: Vec<_> = store.read(cx).archived_entries().collect();
4696 assert_eq!(archived.len(), 1);
4697 assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread");
4698 });
4699}
4700
4701mod property_test {
4702 use super::*;
4703 use gpui::EntityId;
4704
4705 struct UnopenedWorktree {
4706 path: String,
4707 }
4708
4709 struct TestState {
4710 fs: Arc<FakeFs>,
4711 thread_counter: u32,
4712 workspace_counter: u32,
4713 worktree_counter: u32,
4714 saved_thread_ids: Vec<acp::SessionId>,
4715 workspace_paths: Vec<String>,
4716 main_repo_indices: Vec<usize>,
4717 unopened_worktrees: Vec<UnopenedWorktree>,
4718 }
4719
4720 impl TestState {
4721 fn new(fs: Arc<FakeFs>, initial_workspace_path: String) -> Self {
4722 Self {
4723 fs,
4724 thread_counter: 0,
4725 workspace_counter: 1,
4726 worktree_counter: 0,
4727 saved_thread_ids: Vec::new(),
4728 workspace_paths: vec![initial_workspace_path],
4729 main_repo_indices: vec![0],
4730 unopened_worktrees: Vec::new(),
4731 }
4732 }
4733
4734 fn next_thread_id(&mut self) -> acp::SessionId {
4735 let id = self.thread_counter;
4736 self.thread_counter += 1;
4737 let session_id = acp::SessionId::new(Arc::from(format!("prop-thread-{id}")));
4738 self.saved_thread_ids.push(session_id.clone());
4739 session_id
4740 }
4741
4742 fn remove_thread(&mut self, index: usize) -> acp::SessionId {
4743 self.saved_thread_ids.remove(index)
4744 }
4745
4746 fn next_workspace_path(&mut self) -> String {
4747 let id = self.workspace_counter;
4748 self.workspace_counter += 1;
4749 format!("/prop-project-{id}")
4750 }
4751
4752 fn next_worktree_name(&mut self) -> String {
4753 let id = self.worktree_counter;
4754 self.worktree_counter += 1;
4755 format!("wt-{id}")
4756 }
4757 }
4758
4759 #[derive(Debug)]
4760 enum Operation {
4761 SaveThread { workspace_index: usize },
4762 SaveWorktreeThread { worktree_index: usize },
4763 DeleteThread { index: usize },
4764 ToggleAgentPanel,
4765 AddWorkspace,
4766 OpenWorktreeAsWorkspace { worktree_index: usize },
4767 RemoveWorkspace { index: usize },
4768 SwitchWorkspace { index: usize },
4769 AddLinkedWorktree { workspace_index: usize },
4770 }
4771
4772 // Distribution (out of 20 slots):
4773 // SaveThread: 5 slots (25%)
4774 // SaveWorktreeThread: 2 slots (10%)
4775 // DeleteThread: 2 slots (10%)
4776 // ToggleAgentPanel: 2 slots (10%)
4777 // AddWorkspace: 1 slot (5%)
4778 // OpenWorktreeAsWorkspace: 1 slot (5%)
4779 // RemoveWorkspace: 1 slot (5%)
4780 // SwitchWorkspace: 2 slots (10%)
4781 // AddLinkedWorktree: 4 slots (20%)
4782 const DISTRIBUTION_SLOTS: u32 = 20;
4783
4784 impl TestState {
4785 fn generate_operation(&self, raw: u32) -> Operation {
4786 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
4787 let workspace_count = self.workspace_paths.len();
4788
4789 match raw % DISTRIBUTION_SLOTS {
4790 0..=4 => Operation::SaveThread {
4791 workspace_index: extra % workspace_count,
4792 },
4793 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
4794 worktree_index: extra % self.unopened_worktrees.len(),
4795 },
4796 5..=6 => Operation::SaveThread {
4797 workspace_index: extra % workspace_count,
4798 },
4799 7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread {
4800 index: extra % self.saved_thread_ids.len(),
4801 },
4802 7..=8 => Operation::SaveThread {
4803 workspace_index: extra % workspace_count,
4804 },
4805 9..=10 => Operation::ToggleAgentPanel,
4806 11 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace {
4807 worktree_index: extra % self.unopened_worktrees.len(),
4808 },
4809 11 => Operation::AddWorkspace,
4810 12 if workspace_count > 1 => Operation::RemoveWorkspace {
4811 index: extra % workspace_count,
4812 },
4813 12 => Operation::AddWorkspace,
4814 13..=14 => Operation::SwitchWorkspace {
4815 index: extra % workspace_count,
4816 },
4817 15..=19 if !self.main_repo_indices.is_empty() => {
4818 let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()];
4819 Operation::AddLinkedWorktree {
4820 workspace_index: main_index,
4821 }
4822 }
4823 15..=19 => Operation::SaveThread {
4824 workspace_index: extra % workspace_count,
4825 },
4826 _ => unreachable!(),
4827 }
4828 }
4829 }
4830
4831 fn save_thread_to_path(
4832 state: &mut TestState,
4833 path_list: PathList,
4834 cx: &mut gpui::VisualTestContext,
4835 ) {
4836 let session_id = state.next_thread_id();
4837 let title: SharedString = format!("Thread {}", session_id).into();
4838 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
4839 .unwrap()
4840 + chrono::Duration::seconds(state.thread_counter as i64);
4841 let metadata = ThreadMetadata {
4842 session_id,
4843 agent_id: agent::ZED_AGENT_ID.clone(),
4844 title,
4845 updated_at,
4846 created_at: None,
4847 folder_paths: path_list,
4848 archived: false,
4849 };
4850 cx.update(|_, cx| {
4851 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
4852 });
4853 }
4854
4855 async fn perform_operation(
4856 operation: Operation,
4857 state: &mut TestState,
4858 multi_workspace: &Entity<MultiWorkspace>,
4859 sidebar: &Entity<Sidebar>,
4860 cx: &mut gpui::VisualTestContext,
4861 ) {
4862 match operation {
4863 Operation::SaveThread { workspace_index } => {
4864 let workspace =
4865 multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone());
4866 let path_list = workspace
4867 .read_with(cx, |workspace, cx| PathList::new(&workspace.root_paths(cx)));
4868 save_thread_to_path(state, path_list, cx);
4869 }
4870 Operation::SaveWorktreeThread { worktree_index } => {
4871 let worktree = &state.unopened_worktrees[worktree_index];
4872 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
4873 save_thread_to_path(state, path_list, cx);
4874 }
4875 Operation::DeleteThread { index } => {
4876 let session_id = state.remove_thread(index);
4877 cx.update(|_, cx| {
4878 ThreadMetadataStore::global(cx)
4879 .update(cx, |store, cx| store.delete(session_id, cx));
4880 });
4881 }
4882 Operation::ToggleAgentPanel => {
4883 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4884 let panel_open =
4885 sidebar.read_with(cx, |sidebar, cx| sidebar.agent_panel_visible(cx));
4886 workspace.update_in(cx, |workspace, window, cx| {
4887 if panel_open {
4888 workspace.close_panel::<AgentPanel>(window, cx);
4889 } else {
4890 workspace.open_panel::<AgentPanel>(window, cx);
4891 }
4892 });
4893 }
4894 Operation::AddWorkspace => {
4895 let path = state.next_workspace_path();
4896 state
4897 .fs
4898 .insert_tree(
4899 &path,
4900 serde_json::json!({
4901 ".git": {},
4902 "src": {},
4903 }),
4904 )
4905 .await;
4906 let project = project::Project::test(
4907 state.fs.clone() as Arc<dyn fs::Fs>,
4908 [path.as_ref()],
4909 cx,
4910 )
4911 .await;
4912 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4913 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4914 mw.test_add_workspace(project.clone(), window, cx)
4915 });
4916 add_agent_panel(&workspace, &project, cx);
4917 let new_index = state.workspace_paths.len();
4918 state.workspace_paths.push(path);
4919 state.main_repo_indices.push(new_index);
4920 }
4921 Operation::OpenWorktreeAsWorkspace { worktree_index } => {
4922 let worktree = state.unopened_worktrees.remove(worktree_index);
4923 let project = project::Project::test(
4924 state.fs.clone() as Arc<dyn fs::Fs>,
4925 [worktree.path.as_ref()],
4926 cx,
4927 )
4928 .await;
4929 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4930 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4931 mw.test_add_workspace(project.clone(), window, cx)
4932 });
4933 add_agent_panel(&workspace, &project, cx);
4934 state.workspace_paths.push(worktree.path);
4935 }
4936 Operation::RemoveWorkspace { index } => {
4937 let removed = multi_workspace.update_in(cx, |mw, window, cx| {
4938 let workspace = mw.workspaces()[index].clone();
4939 mw.remove(&workspace, window, cx)
4940 });
4941 if removed {
4942 state.workspace_paths.remove(index);
4943 state.main_repo_indices.retain(|i| *i != index);
4944 for i in &mut state.main_repo_indices {
4945 if *i > index {
4946 *i -= 1;
4947 }
4948 }
4949 }
4950 }
4951 Operation::SwitchWorkspace { index } => {
4952 let workspace =
4953 multi_workspace.read_with(cx, |mw, _| mw.workspaces()[index].clone());
4954 multi_workspace.update_in(cx, |mw, window, cx| {
4955 mw.activate(workspace, window, cx);
4956 });
4957 }
4958 Operation::AddLinkedWorktree { workspace_index } => {
4959 let main_path = state.workspace_paths[workspace_index].clone();
4960 let dot_git = format!("{}/.git", main_path);
4961 let worktree_name = state.next_worktree_name();
4962 let worktree_path = format!("/worktrees/{}", worktree_name);
4963
4964 state.fs
4965 .insert_tree(
4966 &worktree_path,
4967 serde_json::json!({
4968 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
4969 "src": {},
4970 }),
4971 )
4972 .await;
4973
4974 // Also create the worktree metadata dir inside the main repo's .git
4975 state
4976 .fs
4977 .insert_tree(
4978 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
4979 serde_json::json!({
4980 "commondir": "../../",
4981 "HEAD": format!("ref: refs/heads/{}", worktree_name),
4982 }),
4983 )
4984 .await;
4985
4986 let dot_git_path = std::path::Path::new(&dot_git);
4987 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
4988 state
4989 .fs
4990 .with_git_state(dot_git_path, false, |git_state| {
4991 git_state.worktrees.push(git::repository::Worktree {
4992 path: worktree_pathbuf,
4993 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
4994 sha: "aaa".into(),
4995 });
4996 })
4997 .unwrap();
4998
4999 // Re-scan the main workspace's project so it discovers the new worktree.
5000 let main_workspace =
5001 multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone());
5002 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
5003 main_project
5004 .update(cx, |p, cx| p.git_scans_complete(cx))
5005 .await;
5006
5007 state.unopened_worktrees.push(UnopenedWorktree {
5008 path: worktree_path,
5009 });
5010 }
5011 }
5012 }
5013
5014 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
5015 sidebar.update_in(cx, |sidebar, _window, cx| {
5016 sidebar.collapsed_groups.clear();
5017 let path_lists: Vec<PathList> = sidebar
5018 .contents
5019 .entries
5020 .iter()
5021 .filter_map(|entry| match entry {
5022 ListEntry::ProjectHeader { path_list, .. } => Some(path_list.clone()),
5023 _ => None,
5024 })
5025 .collect();
5026 for path_list in path_lists {
5027 sidebar.expanded_groups.insert(path_list, 10_000);
5028 }
5029 sidebar.update_entries(cx);
5030 });
5031 }
5032
5033 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
5034 verify_every_workspace_in_multiworkspace_is_shown(sidebar, cx)?;
5035 verify_all_threads_are_shown(sidebar, cx)?;
5036 verify_active_state_matches_current_workspace(sidebar, cx)?;
5037 Ok(())
5038 }
5039
5040 fn verify_every_workspace_in_multiworkspace_is_shown(
5041 sidebar: &Sidebar,
5042 cx: &App,
5043 ) -> anyhow::Result<()> {
5044 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5045 anyhow::bail!("sidebar should still have an associated multi-workspace");
5046 };
5047
5048 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
5049
5050 // Workspaces with no root paths are not shown because the
5051 // sidebar skips empty path lists. All other workspaces should
5052 // appear — either via a Thread entry or a NewThread entry for
5053 // threadless workspaces.
5054 let expected_workspaces: HashSet<EntityId> = workspaces
5055 .iter()
5056 .filter(|ws| !workspace_path_list(ws, cx).paths().is_empty())
5057 .map(|ws| ws.entity_id())
5058 .collect();
5059
5060 let sidebar_workspaces: HashSet<EntityId> = sidebar
5061 .contents
5062 .entries
5063 .iter()
5064 .filter_map(|entry| entry.workspace().map(|ws| ws.entity_id()))
5065 .collect();
5066
5067 let missing = &expected_workspaces - &sidebar_workspaces;
5068 let stray = &sidebar_workspaces - &expected_workspaces;
5069
5070 anyhow::ensure!(
5071 missing.is_empty() && stray.is_empty(),
5072 "sidebar workspaces don't match multi-workspace.\n\
5073 Only in multi-workspace (missing): {:?}\n\
5074 Only in sidebar (stray): {:?}",
5075 missing,
5076 stray,
5077 );
5078
5079 Ok(())
5080 }
5081
5082 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
5083 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5084 anyhow::bail!("sidebar should still have an associated multi-workspace");
5085 };
5086 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
5087 let thread_store = ThreadMetadataStore::global(cx);
5088
5089 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
5090 .contents
5091 .entries
5092 .iter()
5093 .filter_map(|entry| entry.session_id().cloned())
5094 .collect();
5095
5096 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
5097 for workspace in &workspaces {
5098 let path_list = workspace_path_list(workspace, cx);
5099 if path_list.paths().is_empty() {
5100 continue;
5101 }
5102 for metadata in thread_store.read(cx).entries_for_path(&path_list) {
5103 metadata_thread_ids.insert(metadata.session_id.clone());
5104 }
5105 for snapshot in root_repository_snapshots(workspace, cx) {
5106 for linked_worktree in snapshot.linked_worktrees() {
5107 let worktree_path_list =
5108 PathList::new(std::slice::from_ref(&linked_worktree.path));
5109 for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list) {
5110 metadata_thread_ids.insert(metadata.session_id.clone());
5111 }
5112 }
5113 }
5114 }
5115
5116 anyhow::ensure!(
5117 sidebar_thread_ids == metadata_thread_ids,
5118 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
5119 sidebar_thread_ids,
5120 metadata_thread_ids,
5121 );
5122 Ok(())
5123 }
5124
5125 fn verify_active_state_matches_current_workspace(
5126 sidebar: &Sidebar,
5127 cx: &App,
5128 ) -> anyhow::Result<()> {
5129 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5130 anyhow::bail!("sidebar should still have an associated multi-workspace");
5131 };
5132
5133 let workspace = multi_workspace.read(cx).workspace();
5134
5135 // TODO: The focused_thread should _always_ be Some(item-in-the-list) after
5136 // update_entries. If the activated workspace's agent panel has an active thread,
5137 // this item should match the one in the list. There may be a slight delay where
5138 // a thread is loading so the agent panel returns None initially, and the
5139 // focused_thread is often optimistically set to the thread the agent panel is
5140 // going to be.
5141 if sidebar.agent_panel_visible(cx) && !sidebar.active_thread_is_draft(cx) {
5142 let panel_active_session_id =
5143 workspace
5144 .read(cx)
5145 .panel::<AgentPanel>(cx)
5146 .and_then(|panel| {
5147 panel
5148 .read(cx)
5149 .active_conversation_view()
5150 .and_then(|cv| cv.read(cx).parent_id(cx))
5151 });
5152 if let Some(panel_session_id) = panel_active_session_id {
5153 anyhow::ensure!(
5154 sidebar.focused_thread.as_ref() == Some(&panel_session_id),
5155 "agent panel is visible with active session {:?} but sidebar focused_thread is {:?}",
5156 panel_session_id,
5157 sidebar.focused_thread,
5158 );
5159 }
5160 }
5161 Ok(())
5162 }
5163
5164 #[gpui::property_test]
5165 async fn test_sidebar_invariants(
5166 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)]
5167 raw_operations: Vec<u32>,
5168 cx: &mut TestAppContext,
5169 ) {
5170 agent_ui::test_support::init_test(cx);
5171 cx.update(|cx| {
5172 cx.update_flags(false, vec!["agent-v2".into()]);
5173 ThreadStore::init_global(cx);
5174 ThreadMetadataStore::init_global(cx);
5175 language_model::LanguageModelRegistry::test(cx);
5176 prompt_store::init(cx);
5177 });
5178
5179 let fs = FakeFs::new(cx.executor());
5180 fs.insert_tree(
5181 "/my-project",
5182 serde_json::json!({
5183 ".git": {},
5184 "src": {},
5185 }),
5186 )
5187 .await;
5188 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5189 let project =
5190 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
5191 .await;
5192 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5193
5194 let (multi_workspace, cx) =
5195 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5196 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5197
5198 let mut state = TestState::new(fs, "/my-project".to_string());
5199 let mut executed: Vec<String> = Vec::new();
5200
5201 for &raw_op in &raw_operations {
5202 let operation = state.generate_operation(raw_op);
5203 executed.push(format!("{:?}", operation));
5204 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
5205 cx.run_until_parked();
5206
5207 update_sidebar(&sidebar, cx);
5208 cx.run_until_parked();
5209
5210 let result =
5211 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
5212 if let Err(err) = result {
5213 let log = executed.join("\n ");
5214 panic!(
5215 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
5216 executed.len(),
5217 );
5218 }
5219 }
5220 }
5221}