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