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