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