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