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