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