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