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