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