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