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