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