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