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