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