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;
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 cx.run_until_parked();
4310
4311 // The linked worktree workspace should have been removed.
4312 assert_eq!(
4313 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4314 1,
4315 "linked worktree workspace should be removed after archiving its last thread"
4316 );
4317
4318 // The main thread should still be visible.
4319 let entries = visible_entries_as_strings(&sidebar, cx);
4320 assert!(
4321 entries.iter().any(|e| e.contains("Main Thread")),
4322 "main thread should still be visible: {entries:?}"
4323 );
4324 assert!(
4325 !entries.iter().any(|e| e.contains("Worktree Thread")),
4326 "archived worktree thread should not be visible: {entries:?}"
4327 );
4328}
4329
4330#[gpui::test]
4331async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
4332 // When a multi-root workspace (e.g. [/other, /project]) shares a
4333 // repo with a single-root workspace (e.g. [/project]), linked
4334 // worktree threads from the shared repo should only appear under
4335 // the dedicated group [project], not under [other, project].
4336 init_test(cx);
4337 let fs = FakeFs::new(cx.executor());
4338
4339 // Two independent repos, each with their own git history.
4340 fs.insert_tree(
4341 "/project",
4342 serde_json::json!({
4343 ".git": {},
4344 "src": {},
4345 }),
4346 )
4347 .await;
4348 fs.insert_tree(
4349 "/other",
4350 serde_json::json!({
4351 ".git": {},
4352 "src": {},
4353 }),
4354 )
4355 .await;
4356
4357 // Register the linked worktree in the main repo.
4358 fs.add_linked_worktree_for_repo(
4359 Path::new("/project/.git"),
4360 false,
4361 git::repository::Worktree {
4362 path: std::path::PathBuf::from("/wt-feature-a"),
4363 ref_name: Some("refs/heads/feature-a".into()),
4364 sha: "aaa".into(),
4365 is_main: false,
4366 },
4367 )
4368 .await;
4369
4370 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4371
4372 // Workspace 1: just /project.
4373 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4374 project_only
4375 .update(cx, |p, cx| p.git_scans_complete(cx))
4376 .await;
4377
4378 // Workspace 2: /other and /project together (multi-root).
4379 let multi_root =
4380 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
4381 multi_root
4382 .update(cx, |p, cx| p.git_scans_complete(cx))
4383 .await;
4384
4385 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4386 worktree_project
4387 .update(cx, |p, cx| p.git_scans_complete(cx))
4388 .await;
4389
4390 let (multi_workspace, cx) =
4391 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
4392 let sidebar = setup_sidebar(&multi_workspace, cx);
4393 multi_workspace.update_in(cx, |mw, window, cx| {
4394 mw.test_add_workspace(multi_root.clone(), window, cx);
4395 });
4396
4397 // Save a thread under the linked worktree path.
4398 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
4399
4400 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4401 cx.run_until_parked();
4402
4403 // The thread should appear only under [project] (the dedicated
4404 // group for the /project repo), not under [other, project].
4405 assert_eq!(
4406 visible_entries_as_strings(&sidebar, cx),
4407 vec![
4408 "v [other, project]",
4409 " [+ New Thread]",
4410 "v [project]",
4411 " Worktree Thread {wt-feature-a}",
4412 ]
4413 );
4414}
4415
4416#[gpui::test]
4417async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
4418 let project = init_test_project_with_agent_panel("/my-project", cx).await;
4419 let (multi_workspace, cx) =
4420 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4421 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4422
4423 let switcher_ids =
4424 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
4425 sidebar.read_with(cx, |sidebar, cx| {
4426 let switcher = sidebar
4427 .thread_switcher
4428 .as_ref()
4429 .expect("switcher should be open");
4430 switcher
4431 .read(cx)
4432 .entries()
4433 .iter()
4434 .map(|e| e.session_id.clone())
4435 .collect()
4436 })
4437 };
4438
4439 let switcher_selected_id =
4440 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
4441 sidebar.read_with(cx, |sidebar, cx| {
4442 let switcher = sidebar
4443 .thread_switcher
4444 .as_ref()
4445 .expect("switcher should be open");
4446 let s = switcher.read(cx);
4447 s.selected_entry()
4448 .expect("should have selection")
4449 .session_id
4450 .clone()
4451 })
4452 };
4453
4454 // ── Setup: create three threads with distinct created_at times ──────
4455 // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
4456 // We send messages in each so they also get last_message_sent_or_queued timestamps.
4457 let connection_c = StubAgentConnection::new();
4458 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4459 acp::ContentChunk::new("Done C".into()),
4460 )]);
4461 open_thread_with_connection(&panel, connection_c, cx);
4462 send_message(&panel, cx);
4463 let session_id_c = active_session_id(&panel, cx);
4464 save_thread_metadata(
4465 session_id_c.clone(),
4466 "Thread C".into(),
4467 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4468 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
4469 &project,
4470 cx,
4471 );
4472
4473 let connection_b = StubAgentConnection::new();
4474 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4475 acp::ContentChunk::new("Done B".into()),
4476 )]);
4477 open_thread_with_connection(&panel, connection_b, cx);
4478 send_message(&panel, cx);
4479 let session_id_b = active_session_id(&panel, cx);
4480 save_thread_metadata(
4481 session_id_b.clone(),
4482 "Thread B".into(),
4483 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4484 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
4485 &project,
4486 cx,
4487 );
4488
4489 let connection_a = StubAgentConnection::new();
4490 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4491 acp::ContentChunk::new("Done A".into()),
4492 )]);
4493 open_thread_with_connection(&panel, connection_a, cx);
4494 send_message(&panel, cx);
4495 let session_id_a = active_session_id(&panel, cx);
4496 save_thread_metadata(
4497 session_id_a.clone(),
4498 "Thread A".into(),
4499 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
4500 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
4501 &project,
4502 cx,
4503 );
4504
4505 // All three threads are now live. Thread A was opened last, so it's
4506 // the one being viewed. Opening each thread called record_thread_access,
4507 // so all three have last_accessed_at set.
4508 // Access order is: A (most recent), B, C (oldest).
4509
4510 // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
4511 focus_sidebar(&sidebar, cx);
4512 sidebar.update_in(cx, |sidebar, window, cx| {
4513 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4514 });
4515 cx.run_until_parked();
4516
4517 // All three have last_accessed_at, so they sort by access time.
4518 // A was accessed most recently (it's the currently viewed thread),
4519 // then B, then C.
4520 assert_eq!(
4521 switcher_ids(&sidebar, cx),
4522 vec![
4523 session_id_a.clone(),
4524 session_id_b.clone(),
4525 session_id_c.clone()
4526 ],
4527 );
4528 // First ctrl-tab selects the second entry (B).
4529 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
4530
4531 // Dismiss the switcher without confirming.
4532 sidebar.update_in(cx, |sidebar, _window, cx| {
4533 sidebar.dismiss_thread_switcher(cx);
4534 });
4535 cx.run_until_parked();
4536
4537 // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
4538 sidebar.update_in(cx, |sidebar, window, cx| {
4539 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4540 });
4541 cx.run_until_parked();
4542
4543 // Cycle twice to land on Thread C (index 2).
4544 sidebar.read_with(cx, |sidebar, cx| {
4545 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4546 assert_eq!(switcher.read(cx).selected_index(), 1);
4547 });
4548 sidebar.update_in(cx, |sidebar, _window, cx| {
4549 sidebar
4550 .thread_switcher
4551 .as_ref()
4552 .unwrap()
4553 .update(cx, |s, cx| s.cycle_selection(cx));
4554 });
4555 cx.run_until_parked();
4556 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
4557
4558 assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
4559
4560 // Confirm on Thread C.
4561 sidebar.update_in(cx, |sidebar, window, cx| {
4562 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4563 let focus = switcher.focus_handle(cx);
4564 focus.dispatch_action(&menu::Confirm, window, cx);
4565 });
4566 cx.run_until_parked();
4567
4568 // Switcher should be dismissed after confirm.
4569 sidebar.read_with(cx, |sidebar, _cx| {
4570 assert!(
4571 sidebar.thread_switcher.is_none(),
4572 "switcher should be dismissed"
4573 );
4574 });
4575
4576 sidebar.update(cx, |sidebar, _cx| {
4577 let last_accessed = sidebar
4578 .thread_last_accessed
4579 .keys()
4580 .cloned()
4581 .collect::<Vec<_>>();
4582 assert_eq!(last_accessed.len(), 1);
4583 assert!(last_accessed.contains(&session_id_c));
4584 assert!(
4585 sidebar
4586 .active_entry
4587 .as_ref()
4588 .expect("active_entry should be set")
4589 .is_active_thread(&session_id_c)
4590 );
4591 });
4592
4593 sidebar.update_in(cx, |sidebar, window, cx| {
4594 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4595 });
4596 cx.run_until_parked();
4597
4598 assert_eq!(
4599 switcher_ids(&sidebar, cx),
4600 vec![
4601 session_id_c.clone(),
4602 session_id_a.clone(),
4603 session_id_b.clone()
4604 ],
4605 );
4606
4607 // Confirm on Thread A.
4608 sidebar.update_in(cx, |sidebar, window, cx| {
4609 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4610 let focus = switcher.focus_handle(cx);
4611 focus.dispatch_action(&menu::Confirm, window, cx);
4612 });
4613 cx.run_until_parked();
4614
4615 sidebar.update(cx, |sidebar, _cx| {
4616 let last_accessed = sidebar
4617 .thread_last_accessed
4618 .keys()
4619 .cloned()
4620 .collect::<Vec<_>>();
4621 assert_eq!(last_accessed.len(), 2);
4622 assert!(last_accessed.contains(&session_id_c));
4623 assert!(last_accessed.contains(&session_id_a));
4624 assert!(
4625 sidebar
4626 .active_entry
4627 .as_ref()
4628 .expect("active_entry should be set")
4629 .is_active_thread(&session_id_a)
4630 );
4631 });
4632
4633 sidebar.update_in(cx, |sidebar, window, cx| {
4634 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4635 });
4636 cx.run_until_parked();
4637
4638 assert_eq!(
4639 switcher_ids(&sidebar, cx),
4640 vec![
4641 session_id_a.clone(),
4642 session_id_c.clone(),
4643 session_id_b.clone(),
4644 ],
4645 );
4646
4647 sidebar.update_in(cx, |sidebar, _window, cx| {
4648 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4649 switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
4650 });
4651 cx.run_until_parked();
4652
4653 // Confirm on Thread B.
4654 sidebar.update_in(cx, |sidebar, window, cx| {
4655 let switcher = sidebar.thread_switcher.as_ref().unwrap();
4656 let focus = switcher.focus_handle(cx);
4657 focus.dispatch_action(&menu::Confirm, window, cx);
4658 });
4659 cx.run_until_parked();
4660
4661 sidebar.update(cx, |sidebar, _cx| {
4662 let last_accessed = sidebar
4663 .thread_last_accessed
4664 .keys()
4665 .cloned()
4666 .collect::<Vec<_>>();
4667 assert_eq!(last_accessed.len(), 3);
4668 assert!(last_accessed.contains(&session_id_c));
4669 assert!(last_accessed.contains(&session_id_a));
4670 assert!(last_accessed.contains(&session_id_b));
4671 assert!(
4672 sidebar
4673 .active_entry
4674 .as_ref()
4675 .expect("active_entry should be set")
4676 .is_active_thread(&session_id_b)
4677 );
4678 });
4679
4680 // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
4681 // This thread was never opened in a panel — it only exists in metadata.
4682 save_thread_metadata(
4683 acp::SessionId::new(Arc::from("thread-historical")),
4684 "Historical Thread".into(),
4685 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
4686 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
4687 &project,
4688 cx,
4689 );
4690
4691 sidebar.update_in(cx, |sidebar, window, cx| {
4692 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4693 });
4694 cx.run_until_parked();
4695
4696 // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
4697 // so it falls to tier 3 (sorted by created_at). It should appear after all
4698 // accessed threads, even though its created_at (June 2024) is much later
4699 // than the others.
4700 //
4701 // But the live threads (A, B, C) each had send_message called which sets
4702 // last_message_sent_or_queued. So for the accessed threads (tier 1) the
4703 // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
4704 let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
4705
4706 let ids = switcher_ids(&sidebar, cx);
4707 assert_eq!(
4708 ids,
4709 vec![
4710 session_id_b.clone(),
4711 session_id_a.clone(),
4712 session_id_c.clone(),
4713 session_id_hist.clone()
4714 ],
4715 );
4716
4717 sidebar.update_in(cx, |sidebar, _window, cx| {
4718 sidebar.dismiss_thread_switcher(cx);
4719 });
4720 cx.run_until_parked();
4721
4722 // ── 4. Add another historical thread with older created_at ─────────
4723 save_thread_metadata(
4724 acp::SessionId::new(Arc::from("thread-old-historical")),
4725 "Old Historical Thread".into(),
4726 chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
4727 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
4728 &project,
4729 cx,
4730 );
4731
4732 sidebar.update_in(cx, |sidebar, window, cx| {
4733 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4734 });
4735 cx.run_until_parked();
4736
4737 // Both historical threads have no access or message times. They should
4738 // appear after accessed threads, sorted by created_at (newest first).
4739 let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
4740 let ids = switcher_ids(&sidebar, cx);
4741 assert_eq!(
4742 ids,
4743 vec![
4744 session_id_b,
4745 session_id_a,
4746 session_id_c,
4747 session_id_hist,
4748 session_id_old_hist,
4749 ],
4750 );
4751
4752 sidebar.update_in(cx, |sidebar, _window, cx| {
4753 sidebar.dismiss_thread_switcher(cx);
4754 });
4755 cx.run_until_parked();
4756}
4757
4758#[gpui::test]
4759async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
4760 let project = init_test_project("/my-project", cx).await;
4761 let (multi_workspace, cx) =
4762 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4763 let sidebar = setup_sidebar(&multi_workspace, cx);
4764
4765 save_thread_metadata(
4766 acp::SessionId::new(Arc::from("thread-to-archive")),
4767 "Thread To Archive".into(),
4768 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4769 None,
4770 &project,
4771 cx,
4772 );
4773 cx.run_until_parked();
4774
4775 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4776 cx.run_until_parked();
4777
4778 let entries = visible_entries_as_strings(&sidebar, cx);
4779 assert!(
4780 entries.iter().any(|e| e.contains("Thread To Archive")),
4781 "expected thread to be visible before archiving, got: {entries:?}"
4782 );
4783
4784 sidebar.update_in(cx, |sidebar, window, cx| {
4785 sidebar.archive_thread(
4786 &acp::SessionId::new(Arc::from("thread-to-archive")),
4787 window,
4788 cx,
4789 );
4790 });
4791 cx.run_until_parked();
4792
4793 let entries = visible_entries_as_strings(&sidebar, cx);
4794 assert!(
4795 !entries.iter().any(|e| e.contains("Thread To Archive")),
4796 "expected thread to be hidden after archiving, got: {entries:?}"
4797 );
4798
4799 cx.update(|_, cx| {
4800 let store = ThreadMetadataStore::global(cx);
4801 let archived: Vec<_> = store.read(cx).archived_entries().collect();
4802 assert_eq!(archived.len(), 1);
4803 assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive");
4804 assert!(archived[0].archived);
4805 });
4806}
4807
4808#[gpui::test]
4809async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
4810 // Tests two archive scenarios:
4811 // 1. Archiving a thread in a non-active workspace leaves active_entry
4812 // as the current draft.
4813 // 2. Archiving the thread the user is looking at falls back to a draft
4814 // on the same workspace.
4815 agent_ui::test_support::init_test(cx);
4816 cx.update(|cx| {
4817 ThreadStore::init_global(cx);
4818 ThreadMetadataStore::init_global(cx);
4819 language_model::LanguageModelRegistry::test(cx);
4820 prompt_store::init(cx);
4821 });
4822
4823 let fs = FakeFs::new(cx.executor());
4824 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4825 .await;
4826 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4827 .await;
4828 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4829
4830 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4831 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4832
4833 let (multi_workspace, cx) =
4834 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4835 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4836
4837 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4838 mw.test_add_workspace(project_b.clone(), window, cx)
4839 });
4840 let panel_b = add_agent_panel(&workspace_b, cx);
4841 cx.run_until_parked();
4842
4843 // --- Scenario 1: archive a thread in the non-active workspace ---
4844
4845 // Create a thread in project-a (non-active — project-b is active).
4846 let connection = acp_thread::StubAgentConnection::new();
4847 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4848 acp::ContentChunk::new("Done".into()),
4849 )]);
4850 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
4851 agent_ui::test_support::send_message(&panel_a, cx);
4852 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
4853 cx.run_until_parked();
4854
4855 sidebar.update_in(cx, |sidebar, window, cx| {
4856 sidebar.archive_thread(&thread_a, window, cx);
4857 });
4858 cx.run_until_parked();
4859
4860 // active_entry should still be a draft on workspace_b (the active one).
4861 sidebar.read_with(cx, |sidebar, _| {
4862 assert!(
4863 matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
4864 "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
4865 sidebar.active_entry,
4866 );
4867 });
4868
4869 // --- Scenario 2: archive the thread the user is looking at ---
4870
4871 // Create a thread in project-b (the active workspace) and verify it
4872 // becomes the active entry.
4873 let connection = acp_thread::StubAgentConnection::new();
4874 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4875 acp::ContentChunk::new("Done".into()),
4876 )]);
4877 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
4878 agent_ui::test_support::send_message(&panel_b, cx);
4879 let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
4880 cx.run_until_parked();
4881
4882 sidebar.read_with(cx, |sidebar, _| {
4883 assert!(
4884 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id, .. }) if *session_id == thread_b),
4885 "expected active_entry to be Thread({thread_b}), got: {:?}",
4886 sidebar.active_entry,
4887 );
4888 });
4889
4890 sidebar.update_in(cx, |sidebar, window, cx| {
4891 sidebar.archive_thread(&thread_b, window, cx);
4892 });
4893 cx.run_until_parked();
4894
4895 // Should fall back to a draft on the same workspace.
4896 sidebar.read_with(cx, |sidebar, _| {
4897 assert!(
4898 matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
4899 "expected Draft(workspace_b) after archiving active thread, got: {:?}",
4900 sidebar.active_entry,
4901 );
4902 });
4903}
4904
4905#[gpui::test]
4906async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) {
4907 // When a thread is archived while the user is in a different workspace,
4908 // the archiving code clears the thread from its panel (via
4909 // `clear_active_thread`). Switching back to that workspace should show
4910 // a draft, not the archived thread.
4911 agent_ui::test_support::init_test(cx);
4912 cx.update(|cx| {
4913 ThreadStore::init_global(cx);
4914 ThreadMetadataStore::init_global(cx);
4915 language_model::LanguageModelRegistry::test(cx);
4916 prompt_store::init(cx);
4917 });
4918
4919 let fs = FakeFs::new(cx.executor());
4920 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4921 .await;
4922 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4923 .await;
4924 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4925
4926 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4927 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4928
4929 let (multi_workspace, cx) =
4930 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4931 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4932
4933 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4934 mw.test_add_workspace(project_b.clone(), window, cx)
4935 });
4936 let _panel_b = add_agent_panel(&workspace_b, cx);
4937 cx.run_until_parked();
4938
4939 // Create a thread in project-a's panel (currently non-active).
4940 let connection = acp_thread::StubAgentConnection::new();
4941 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4942 acp::ContentChunk::new("Done".into()),
4943 )]);
4944 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
4945 agent_ui::test_support::send_message(&panel_a, cx);
4946 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
4947 cx.run_until_parked();
4948
4949 // Archive it while project-b is active.
4950 sidebar.update_in(cx, |sidebar, window, cx| {
4951 sidebar.archive_thread(&thread_a, window, cx);
4952 });
4953 cx.run_until_parked();
4954
4955 // Switch back to project-a. Its panel was cleared during archiving,
4956 // so active_entry should be Draft.
4957 let workspace_a =
4958 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4959 multi_workspace.update_in(cx, |mw, window, cx| {
4960 mw.activate(workspace_a.clone(), window, cx);
4961 });
4962 cx.run_until_parked();
4963
4964 sidebar.update_in(cx, |sidebar, _window, cx| {
4965 sidebar.update_entries(cx);
4966 });
4967 cx.run_until_parked();
4968
4969 sidebar.read_with(cx, |sidebar, _| {
4970 assert!(
4971 matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_a),
4972 "expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}",
4973 sidebar.active_entry,
4974 );
4975 });
4976}
4977
4978#[gpui::test]
4979async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
4980 let project = init_test_project("/my-project", cx).await;
4981 let (multi_workspace, cx) =
4982 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4983 let sidebar = setup_sidebar(&multi_workspace, cx);
4984
4985 save_thread_metadata(
4986 acp::SessionId::new(Arc::from("visible-thread")),
4987 "Visible Thread".into(),
4988 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4989 None,
4990 &project,
4991 cx,
4992 );
4993
4994 let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
4995 save_thread_metadata(
4996 archived_thread_session_id.clone(),
4997 "Archived Thread".into(),
4998 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4999 None,
5000 &project,
5001 cx,
5002 );
5003
5004 cx.update(|_, cx| {
5005 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5006 store.archive(&archived_thread_session_id, cx)
5007 })
5008 });
5009 cx.run_until_parked();
5010
5011 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5012 cx.run_until_parked();
5013
5014 let entries = visible_entries_as_strings(&sidebar, cx);
5015 assert!(
5016 entries.iter().any(|e| e.contains("Visible Thread")),
5017 "expected visible thread in sidebar, got: {entries:?}"
5018 );
5019 assert!(
5020 !entries.iter().any(|e| e.contains("Archived Thread")),
5021 "expected archived thread to be hidden from sidebar, got: {entries:?}"
5022 );
5023
5024 cx.update(|_, cx| {
5025 let store = ThreadMetadataStore::global(cx);
5026 let all: Vec<_> = store.read(cx).entries().collect();
5027 assert_eq!(
5028 all.len(),
5029 2,
5030 "expected 2 total entries in the store, got: {}",
5031 all.len()
5032 );
5033
5034 let archived: Vec<_> = store.read(cx).archived_entries().collect();
5035 assert_eq!(archived.len(), 1);
5036 assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread");
5037 });
5038}
5039
5040#[gpui::test]
5041async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
5042 // When a linked worktree is opened as its own workspace and the user
5043 // switches away, the workspace must still be reachable from a NewThread
5044 // sidebar entry. Pressing RemoveSelectedThread (shift-backspace) on that
5045 // entry should remove the workspace.
5046 init_test(cx);
5047 let fs = FakeFs::new(cx.executor());
5048
5049 fs.insert_tree(
5050 "/project",
5051 serde_json::json!({
5052 ".git": {
5053 "worktrees": {
5054 "feature-a": {
5055 "commondir": "../../",
5056 "HEAD": "ref: refs/heads/feature-a",
5057 },
5058 },
5059 },
5060 "src": {},
5061 }),
5062 )
5063 .await;
5064
5065 fs.insert_tree(
5066 "/wt-feature-a",
5067 serde_json::json!({
5068 ".git": "gitdir: /project/.git/worktrees/feature-a",
5069 "src": {},
5070 }),
5071 )
5072 .await;
5073
5074 fs.add_linked_worktree_for_repo(
5075 Path::new("/project/.git"),
5076 false,
5077 git::repository::Worktree {
5078 path: PathBuf::from("/wt-feature-a"),
5079 ref_name: Some("refs/heads/feature-a".into()),
5080 sha: "aaa".into(),
5081 is_main: false,
5082 },
5083 )
5084 .await;
5085
5086 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5087
5088 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5089 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5090
5091 main_project
5092 .update(cx, |p, cx| p.git_scans_complete(cx))
5093 .await;
5094 worktree_project
5095 .update(cx, |p, cx| p.git_scans_complete(cx))
5096 .await;
5097
5098 let (multi_workspace, cx) =
5099 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5100 let sidebar = setup_sidebar(&multi_workspace, cx);
5101
5102 // Open the linked worktree as a separate workspace (simulates cmd-o).
5103 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5104 mw.test_add_workspace(worktree_project.clone(), window, cx)
5105 });
5106 add_agent_panel(&worktree_workspace, cx);
5107 cx.run_until_parked();
5108
5109 // Switch back to the main workspace.
5110 multi_workspace.update_in(cx, |mw, window, cx| {
5111 let main_ws = mw.workspaces().next().unwrap().clone();
5112 mw.activate(main_ws, window, cx);
5113 });
5114 cx.run_until_parked();
5115
5116 sidebar.update_in(cx, |sidebar, _window, cx| {
5117 sidebar.update_entries(cx);
5118 });
5119 cx.run_until_parked();
5120
5121 // The linked worktree workspace must be reachable from some sidebar entry.
5122 let worktree_ws_id = worktree_workspace.entity_id();
5123 let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
5124 let mw = multi_workspace.read(cx);
5125 sidebar
5126 .contents
5127 .entries
5128 .iter()
5129 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
5130 .map(|ws| ws.entity_id())
5131 .collect()
5132 });
5133 assert!(
5134 reachable.contains(&worktree_ws_id),
5135 "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
5136 );
5137
5138 // Find the NewThread entry for the linked worktree and dismiss it.
5139 let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
5140 sidebar
5141 .contents
5142 .entries
5143 .iter()
5144 .position(|entry| {
5145 matches!(
5146 entry,
5147 ListEntry::NewThread {
5148 workspace: Some(_),
5149 ..
5150 }
5151 )
5152 })
5153 .expect("expected a NewThread entry for the linked worktree")
5154 });
5155
5156 assert_eq!(
5157 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5158 2
5159 );
5160
5161 sidebar.update_in(cx, |sidebar, window, cx| {
5162 sidebar.selection = Some(new_thread_ix);
5163 sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
5164 });
5165 cx.run_until_parked();
5166
5167 assert_eq!(
5168 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5169 1,
5170 "linked worktree workspace should be removed after dismissing NewThread entry"
5171 );
5172}
5173
5174#[gpui::test]
5175async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
5176 // When only a linked worktree workspace is open (not the main repo),
5177 // threads saved against the main repo should still appear in the sidebar.
5178 init_test(cx);
5179 let fs = FakeFs::new(cx.executor());
5180
5181 // Create the main repo with a linked worktree.
5182 fs.insert_tree(
5183 "/project",
5184 serde_json::json!({
5185 ".git": {
5186 "worktrees": {
5187 "feature-a": {
5188 "commondir": "../../",
5189 "HEAD": "ref: refs/heads/feature-a",
5190 },
5191 },
5192 },
5193 "src": {},
5194 }),
5195 )
5196 .await;
5197
5198 fs.insert_tree(
5199 "/wt-feature-a",
5200 serde_json::json!({
5201 ".git": "gitdir: /project/.git/worktrees/feature-a",
5202 "src": {},
5203 }),
5204 )
5205 .await;
5206
5207 fs.add_linked_worktree_for_repo(
5208 std::path::Path::new("/project/.git"),
5209 false,
5210 git::repository::Worktree {
5211 path: std::path::PathBuf::from("/wt-feature-a"),
5212 ref_name: Some("refs/heads/feature-a".into()),
5213 sha: "abc".into(),
5214 is_main: false,
5215 },
5216 )
5217 .await;
5218
5219 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5220
5221 // Only open the linked worktree as a workspace — NOT the main repo.
5222 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5223 worktree_project
5224 .update(cx, |p, cx| p.git_scans_complete(cx))
5225 .await;
5226
5227 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5228 main_project
5229 .update(cx, |p, cx| p.git_scans_complete(cx))
5230 .await;
5231
5232 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5233 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
5234 });
5235 let sidebar = setup_sidebar(&multi_workspace, cx);
5236
5237 // Save a thread against the MAIN repo path.
5238 save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
5239
5240 // Save a thread against the linked worktree path.
5241 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
5242
5243 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5244 cx.run_until_parked();
5245
5246 // Both threads should be visible: the worktree thread by direct lookup,
5247 // and the main repo thread because the workspace is a linked worktree
5248 // and we also query the main repo path.
5249 let entries = visible_entries_as_strings(&sidebar, cx);
5250 assert!(
5251 entries.iter().any(|e| e.contains("Main Repo Thread")),
5252 "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
5253 );
5254 assert!(
5255 entries.iter().any(|e| e.contains("Worktree Thread")),
5256 "expected worktree thread to be visible, got: {entries:?}"
5257 );
5258}
5259
5260async fn init_multi_project_test(
5261 paths: &[&str],
5262 cx: &mut TestAppContext,
5263) -> (Arc<FakeFs>, Entity<project::Project>) {
5264 agent_ui::test_support::init_test(cx);
5265 cx.update(|cx| {
5266 ThreadStore::init_global(cx);
5267 ThreadMetadataStore::init_global(cx);
5268 language_model::LanguageModelRegistry::test(cx);
5269 prompt_store::init(cx);
5270 });
5271 let fs = FakeFs::new(cx.executor());
5272 for path in paths {
5273 fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
5274 .await;
5275 }
5276 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5277 let project =
5278 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
5279 (fs, project)
5280}
5281
5282async fn add_test_project(
5283 path: &str,
5284 fs: &Arc<FakeFs>,
5285 multi_workspace: &Entity<MultiWorkspace>,
5286 cx: &mut gpui::VisualTestContext,
5287) -> Entity<Workspace> {
5288 let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
5289 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5290 mw.test_add_workspace(project, window, cx)
5291 });
5292 cx.run_until_parked();
5293 workspace
5294}
5295
5296#[gpui::test]
5297async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
5298 let (fs, project_a) =
5299 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
5300 let (multi_workspace, cx) =
5301 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
5302 let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
5303
5304 // Sidebar starts closed. Initial workspace A is transient.
5305 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5306 assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
5307 assert_eq!(
5308 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5309 1
5310 );
5311 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
5312
5313 // Add B — replaces A as the transient workspace.
5314 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
5315 assert_eq!(
5316 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5317 1
5318 );
5319 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
5320
5321 // Add C — replaces B as the transient workspace.
5322 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
5323 assert_eq!(
5324 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5325 1
5326 );
5327 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
5328}
5329
5330#[gpui::test]
5331async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
5332 let (fs, project_a) = init_multi_project_test(
5333 &["/project-a", "/project-b", "/project-c", "/project-d"],
5334 cx,
5335 )
5336 .await;
5337 let (multi_workspace, cx) =
5338 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
5339 let _sidebar = setup_sidebar(&multi_workspace, cx);
5340 assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
5341
5342 // Add B — retained since sidebar is open.
5343 let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
5344 assert_eq!(
5345 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5346 2
5347 );
5348
5349 // Switch to A — B survives. (Switching from one internal workspace, to another)
5350 multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
5351 cx.run_until_parked();
5352 assert_eq!(
5353 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5354 2
5355 );
5356
5357 // Close sidebar — both A and B remain retained.
5358 multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
5359 cx.run_until_parked();
5360 assert_eq!(
5361 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5362 2
5363 );
5364
5365 // Add C — added as new transient workspace. (switching from retained, to transient)
5366 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
5367 assert_eq!(
5368 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5369 3
5370 );
5371 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
5372
5373 // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
5374 let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
5375 assert_eq!(
5376 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5377 3
5378 );
5379 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
5380}
5381
5382#[gpui::test]
5383async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
5384 let (fs, project_a) =
5385 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
5386 let (multi_workspace, cx) =
5387 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
5388 setup_sidebar_closed(&multi_workspace, cx);
5389
5390 // Add B — replaces A as the transient workspace (A is discarded).
5391 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
5392 assert_eq!(
5393 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5394 1
5395 );
5396 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
5397
5398 // Open sidebar — promotes the transient B to retained.
5399 multi_workspace.update_in(cx, |mw, window, cx| {
5400 mw.toggle_sidebar(window, cx);
5401 });
5402 cx.run_until_parked();
5403 assert_eq!(
5404 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5405 1
5406 );
5407 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
5408
5409 // Close sidebar — the retained B remains.
5410 multi_workspace.update_in(cx, |mw, window, cx| {
5411 mw.toggle_sidebar(window, cx);
5412 });
5413
5414 // Add C — added as new transient workspace.
5415 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
5416 assert_eq!(
5417 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5418 2
5419 );
5420 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
5421}
5422
5423#[gpui::test]
5424async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
5425 init_test(cx);
5426 let fs = FakeFs::new(cx.executor());
5427
5428 fs.insert_tree(
5429 "/project",
5430 serde_json::json!({
5431 ".git": {
5432 "worktrees": {
5433 "feature-a": {
5434 "commondir": "../../",
5435 "HEAD": "ref: refs/heads/feature-a",
5436 },
5437 },
5438 },
5439 "src": {},
5440 }),
5441 )
5442 .await;
5443
5444 fs.insert_tree(
5445 "/wt-feature-a",
5446 serde_json::json!({
5447 ".git": "gitdir: /project/.git/worktrees/feature-a",
5448 "src": {},
5449 }),
5450 )
5451 .await;
5452
5453 fs.add_linked_worktree_for_repo(
5454 Path::new("/project/.git"),
5455 false,
5456 git::repository::Worktree {
5457 path: PathBuf::from("/wt-feature-a"),
5458 ref_name: Some("refs/heads/feature-a".into()),
5459 sha: "abc".into(),
5460 is_main: false,
5461 },
5462 )
5463 .await;
5464
5465 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5466
5467 // Only a linked worktree workspace is open — no workspace for /project.
5468 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5469 worktree_project
5470 .update(cx, |p, cx| p.git_scans_complete(cx))
5471 .await;
5472
5473 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5474 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
5475 });
5476 let sidebar = setup_sidebar(&multi_workspace, cx);
5477
5478 // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
5479 let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
5480 cx.update(|_, cx| {
5481 let metadata = ThreadMetadata {
5482 session_id: legacy_session.clone(),
5483 agent_id: agent::ZED_AGENT_ID.clone(),
5484 title: "Legacy Main Thread".into(),
5485 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5486 created_at: None,
5487 folder_paths: PathList::new(&[PathBuf::from("/project")]),
5488 main_worktree_paths: PathList::default(),
5489 archived: false,
5490 };
5491 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
5492 });
5493 cx.run_until_parked();
5494
5495 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5496 cx.run_until_parked();
5497
5498 // The legacy thread should appear in the sidebar under the project group.
5499 let entries = visible_entries_as_strings(&sidebar, cx);
5500 assert!(
5501 entries.iter().any(|e| e.contains("Legacy Main Thread")),
5502 "legacy thread should be visible: {entries:?}",
5503 );
5504
5505 // Verify only 1 workspace before clicking.
5506 assert_eq!(
5507 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5508 1,
5509 );
5510
5511 // Focus and select the legacy thread, then confirm.
5512 focus_sidebar(&sidebar, cx);
5513 let thread_index = sidebar.read_with(cx, |sidebar, _| {
5514 sidebar
5515 .contents
5516 .entries
5517 .iter()
5518 .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
5519 .expect("legacy thread should be in entries")
5520 });
5521 sidebar.update_in(cx, |sidebar, _window, _cx| {
5522 sidebar.selection = Some(thread_index);
5523 });
5524 cx.dispatch_action(Confirm);
5525 cx.run_until_parked();
5526
5527 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5528 let new_path_list =
5529 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
5530 assert_eq!(
5531 new_path_list,
5532 PathList::new(&[PathBuf::from("/project")]),
5533 "the new workspace should be for the main repo, not the linked worktree",
5534 );
5535}
5536
5537mod property_test {
5538 use super::*;
5539 use gpui::proptest::prelude::*;
5540
5541 struct UnopenedWorktree {
5542 path: String,
5543 main_workspace_path: String,
5544 }
5545
5546 struct TestState {
5547 fs: Arc<FakeFs>,
5548 thread_counter: u32,
5549 workspace_counter: u32,
5550 worktree_counter: u32,
5551 saved_thread_ids: Vec<acp::SessionId>,
5552 unopened_worktrees: Vec<UnopenedWorktree>,
5553 }
5554
5555 impl TestState {
5556 fn new(fs: Arc<FakeFs>) -> Self {
5557 Self {
5558 fs,
5559 thread_counter: 0,
5560 workspace_counter: 1,
5561 worktree_counter: 0,
5562 saved_thread_ids: Vec::new(),
5563 unopened_worktrees: Vec::new(),
5564 }
5565 }
5566
5567 fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
5568 let id = self.thread_counter;
5569 self.thread_counter += 1;
5570 acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
5571 }
5572
5573 fn next_workspace_path(&mut self) -> String {
5574 let id = self.workspace_counter;
5575 self.workspace_counter += 1;
5576 format!("/prop-project-{id}")
5577 }
5578
5579 fn next_worktree_name(&mut self) -> String {
5580 let id = self.worktree_counter;
5581 self.worktree_counter += 1;
5582 format!("wt-{id}")
5583 }
5584 }
5585
5586 #[derive(Debug)]
5587 enum Operation {
5588 SaveThread { project_group_index: usize },
5589 SaveWorktreeThread { worktree_index: usize },
5590 ToggleAgentPanel,
5591 CreateDraftThread,
5592 AddProject { use_worktree: bool },
5593 ArchiveThread { index: usize },
5594 SwitchToThread { index: usize },
5595 SwitchToProjectGroup { index: usize },
5596 AddLinkedWorktree { project_group_index: usize },
5597 }
5598
5599 // Distribution (out of 20 slots):
5600 // SaveThread: 5 slots (~25%)
5601 // SaveWorktreeThread: 2 slots (~10%)
5602 // ToggleAgentPanel: 1 slot (~5%)
5603 // CreateDraftThread: 1 slot (~5%)
5604 // AddProject: 1 slot (~5%)
5605 // ArchiveThread: 2 slots (~10%)
5606 // SwitchToThread: 2 slots (~10%)
5607 // SwitchToProjectGroup: 2 slots (~10%)
5608 // AddLinkedWorktree: 4 slots (~20%)
5609 const DISTRIBUTION_SLOTS: u32 = 20;
5610
5611 impl TestState {
5612 fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
5613 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
5614
5615 match raw % DISTRIBUTION_SLOTS {
5616 0..=4 => Operation::SaveThread {
5617 project_group_index: extra % project_group_count,
5618 },
5619 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
5620 worktree_index: extra % self.unopened_worktrees.len(),
5621 },
5622 5..=6 => Operation::SaveThread {
5623 project_group_index: extra % project_group_count,
5624 },
5625 7 => Operation::ToggleAgentPanel,
5626 8 => Operation::CreateDraftThread,
5627 9 => Operation::AddProject {
5628 use_worktree: !self.unopened_worktrees.is_empty(),
5629 },
5630 10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
5631 index: extra % self.saved_thread_ids.len(),
5632 },
5633 10..=11 => Operation::AddProject {
5634 use_worktree: !self.unopened_worktrees.is_empty(),
5635 },
5636 12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
5637 index: extra % self.saved_thread_ids.len(),
5638 },
5639 12..=13 => Operation::SwitchToProjectGroup {
5640 index: extra % project_group_count,
5641 },
5642 14..=15 => Operation::SwitchToProjectGroup {
5643 index: extra % project_group_count,
5644 },
5645 16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
5646 project_group_index: extra % project_group_count,
5647 },
5648 16..=19 => Operation::SaveThread {
5649 project_group_index: extra % project_group_count,
5650 },
5651 _ => unreachable!(),
5652 }
5653 }
5654 }
5655
5656 fn save_thread_to_path_with_main(
5657 state: &mut TestState,
5658 path_list: PathList,
5659 main_worktree_paths: PathList,
5660 cx: &mut gpui::VisualTestContext,
5661 ) {
5662 let session_id = state.next_metadata_only_thread_id();
5663 let title: SharedString = format!("Thread {}", session_id).into();
5664 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
5665 .unwrap()
5666 + chrono::Duration::seconds(state.thread_counter as i64);
5667 let metadata = ThreadMetadata {
5668 session_id,
5669 agent_id: agent::ZED_AGENT_ID.clone(),
5670 title,
5671 updated_at,
5672 created_at: None,
5673 folder_paths: path_list,
5674 main_worktree_paths,
5675 archived: false,
5676 };
5677 cx.update(|_, cx| {
5678 ThreadMetadataStore::global(cx)
5679 .update(cx, |store, cx| store.save_manually(metadata, cx))
5680 });
5681 cx.run_until_parked();
5682 }
5683
5684 async fn perform_operation(
5685 operation: Operation,
5686 state: &mut TestState,
5687 multi_workspace: &Entity<MultiWorkspace>,
5688 sidebar: &Entity<Sidebar>,
5689 cx: &mut gpui::VisualTestContext,
5690 ) {
5691 match operation {
5692 Operation::SaveThread {
5693 project_group_index,
5694 } => {
5695 // Find a workspace for this project group and create a real
5696 // thread via its agent panel.
5697 let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
5698 let key = mw.project_group_keys().nth(project_group_index).unwrap();
5699 let ws = mw
5700 .workspaces_for_project_group(key, cx)
5701 .next()
5702 .unwrap_or(mw.workspace())
5703 .clone();
5704 let project = ws.read(cx).project().clone();
5705 (ws, project)
5706 });
5707
5708 let panel =
5709 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
5710 if let Some(panel) = panel {
5711 let connection = StubAgentConnection::new();
5712 connection.set_next_prompt_updates(vec![
5713 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
5714 "Done".into(),
5715 )),
5716 ]);
5717 open_thread_with_connection(&panel, connection, cx);
5718 send_message(&panel, cx);
5719 let session_id = active_session_id(&panel, cx);
5720 state.saved_thread_ids.push(session_id.clone());
5721
5722 let title: SharedString = format!("Thread {}", state.thread_counter).into();
5723 state.thread_counter += 1;
5724 let updated_at =
5725 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
5726 .unwrap()
5727 + chrono::Duration::seconds(state.thread_counter as i64);
5728 save_thread_metadata(session_id, title, updated_at, None, &project, cx);
5729 }
5730 }
5731 Operation::SaveWorktreeThread { worktree_index } => {
5732 let worktree = &state.unopened_worktrees[worktree_index];
5733 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
5734 let main_worktree_paths =
5735 PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
5736 save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
5737 }
5738
5739 Operation::ToggleAgentPanel => {
5740 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5741 let panel_open =
5742 workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
5743 workspace.update_in(cx, |workspace, window, cx| {
5744 if panel_open {
5745 workspace.close_panel::<AgentPanel>(window, cx);
5746 } else {
5747 workspace.open_panel::<AgentPanel>(window, cx);
5748 }
5749 });
5750 }
5751 Operation::CreateDraftThread => {
5752 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5753 let panel =
5754 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
5755 if let Some(panel) = panel {
5756 let connection = StubAgentConnection::new();
5757 open_thread_with_connection(&panel, connection, cx);
5758 cx.run_until_parked();
5759 }
5760 workspace.update_in(cx, |workspace, window, cx| {
5761 workspace.focus_panel::<AgentPanel>(window, cx);
5762 });
5763 }
5764 Operation::AddProject { use_worktree } => {
5765 let path = if use_worktree {
5766 // Open an existing linked worktree as a project (simulates Cmd+O
5767 // on a worktree directory).
5768 state.unopened_worktrees.remove(0).path
5769 } else {
5770 // Create a brand new project.
5771 let path = state.next_workspace_path();
5772 state
5773 .fs
5774 .insert_tree(
5775 &path,
5776 serde_json::json!({
5777 ".git": {},
5778 "src": {},
5779 }),
5780 )
5781 .await;
5782 path
5783 };
5784 let project = project::Project::test(
5785 state.fs.clone() as Arc<dyn fs::Fs>,
5786 [path.as_ref()],
5787 cx,
5788 )
5789 .await;
5790 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5791 multi_workspace.update_in(cx, |mw, window, cx| {
5792 mw.test_add_workspace(project.clone(), window, cx)
5793 });
5794 }
5795 Operation::ArchiveThread { index } => {
5796 let session_id = state.saved_thread_ids[index].clone();
5797 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5798 sidebar.archive_thread(&session_id, window, cx);
5799 });
5800 cx.run_until_parked();
5801 state.saved_thread_ids.remove(index);
5802 }
5803 Operation::SwitchToThread { index } => {
5804 let session_id = state.saved_thread_ids[index].clone();
5805 // Find the thread's position in the sidebar entries and select it.
5806 let thread_index = sidebar.read_with(cx, |sidebar, _| {
5807 sidebar.contents.entries.iter().position(|entry| {
5808 matches!(
5809 entry,
5810 ListEntry::Thread(t) if t.metadata.session_id == session_id
5811 )
5812 })
5813 });
5814 if let Some(ix) = thread_index {
5815 sidebar.update_in(cx, |sidebar, window, cx| {
5816 sidebar.selection = Some(ix);
5817 sidebar.confirm(&Confirm, window, cx);
5818 });
5819 cx.run_until_parked();
5820 }
5821 }
5822 Operation::SwitchToProjectGroup { index } => {
5823 let workspace = multi_workspace.read_with(cx, |mw, cx| {
5824 let key = mw.project_group_keys().nth(index).unwrap();
5825 mw.workspaces_for_project_group(key, cx)
5826 .next()
5827 .unwrap_or(mw.workspace())
5828 .clone()
5829 });
5830 multi_workspace.update_in(cx, |mw, window, cx| {
5831 mw.activate(workspace, window, cx);
5832 });
5833 }
5834 Operation::AddLinkedWorktree {
5835 project_group_index,
5836 } => {
5837 // Get the main worktree path from the project group key.
5838 let main_path = multi_workspace.read_with(cx, |mw, _| {
5839 let key = mw.project_group_keys().nth(project_group_index).unwrap();
5840 key.path_list()
5841 .paths()
5842 .first()
5843 .unwrap()
5844 .to_string_lossy()
5845 .to_string()
5846 });
5847 let dot_git = format!("{}/.git", main_path);
5848 let worktree_name = state.next_worktree_name();
5849 let worktree_path = format!("/worktrees/{}", worktree_name);
5850
5851 state.fs
5852 .insert_tree(
5853 &worktree_path,
5854 serde_json::json!({
5855 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
5856 "src": {},
5857 }),
5858 )
5859 .await;
5860
5861 // Also create the worktree metadata dir inside the main repo's .git
5862 state
5863 .fs
5864 .insert_tree(
5865 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
5866 serde_json::json!({
5867 "commondir": "../../",
5868 "HEAD": format!("ref: refs/heads/{}", worktree_name),
5869 }),
5870 )
5871 .await;
5872
5873 let dot_git_path = std::path::Path::new(&dot_git);
5874 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
5875 state
5876 .fs
5877 .add_linked_worktree_for_repo(
5878 dot_git_path,
5879 false,
5880 git::repository::Worktree {
5881 path: worktree_pathbuf,
5882 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
5883 sha: "aaa".into(),
5884 is_main: false,
5885 },
5886 )
5887 .await;
5888
5889 // Re-scan the main workspace's project so it discovers the new worktree.
5890 let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
5891 let key = mw.project_group_keys().nth(project_group_index).unwrap();
5892 mw.workspaces_for_project_group(key, cx)
5893 .next()
5894 .unwrap()
5895 .clone()
5896 });
5897 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
5898 main_project
5899 .update(cx, |p, cx| p.git_scans_complete(cx))
5900 .await;
5901
5902 state.unopened_worktrees.push(UnopenedWorktree {
5903 path: worktree_path,
5904 main_workspace_path: main_path.clone(),
5905 });
5906 }
5907 }
5908 }
5909
5910 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
5911 sidebar.update_in(cx, |sidebar, _window, cx| {
5912 sidebar.collapsed_groups.clear();
5913 let path_lists: Vec<PathList> = sidebar
5914 .contents
5915 .entries
5916 .iter()
5917 .filter_map(|entry| match entry {
5918 ListEntry::ProjectHeader { key, .. } => Some(key.path_list().clone()),
5919 _ => None,
5920 })
5921 .collect();
5922 for path_list in path_lists {
5923 sidebar.expanded_groups.insert(path_list, 10_000);
5924 }
5925 sidebar.update_entries(cx);
5926 });
5927 }
5928
5929 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
5930 verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
5931 verify_all_threads_are_shown(sidebar, cx)?;
5932 verify_active_state_matches_current_workspace(sidebar, cx)?;
5933 verify_all_workspaces_are_reachable(sidebar, cx)?;
5934 Ok(())
5935 }
5936
5937 fn verify_every_group_in_multiworkspace_is_shown(
5938 sidebar: &Sidebar,
5939 cx: &App,
5940 ) -> anyhow::Result<()> {
5941 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5942 anyhow::bail!("sidebar should still have an associated multi-workspace");
5943 };
5944
5945 let mw = multi_workspace.read(cx);
5946
5947 // Every project group key in the multi-workspace that has a
5948 // non-empty path list should appear as a ProjectHeader in the
5949 // sidebar.
5950 let expected_keys: HashSet<&project::ProjectGroupKey> = mw
5951 .project_group_keys()
5952 .filter(|k| !k.path_list().paths().is_empty())
5953 .collect();
5954
5955 let sidebar_keys: HashSet<&project::ProjectGroupKey> = sidebar
5956 .contents
5957 .entries
5958 .iter()
5959 .filter_map(|entry| match entry {
5960 ListEntry::ProjectHeader { key, .. } => Some(key),
5961 _ => None,
5962 })
5963 .collect();
5964
5965 let missing = &expected_keys - &sidebar_keys;
5966 let stray = &sidebar_keys - &expected_keys;
5967
5968 anyhow::ensure!(
5969 missing.is_empty() && stray.is_empty(),
5970 "sidebar project groups don't match multi-workspace.\n\
5971 Only in multi-workspace (missing): {:?}\n\
5972 Only in sidebar (stray): {:?}",
5973 missing,
5974 stray,
5975 );
5976
5977 Ok(())
5978 }
5979
5980 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
5981 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5982 anyhow::bail!("sidebar should still have an associated multi-workspace");
5983 };
5984 let workspaces = multi_workspace
5985 .read(cx)
5986 .workspaces()
5987 .cloned()
5988 .collect::<Vec<_>>();
5989 let thread_store = ThreadMetadataStore::global(cx);
5990
5991 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
5992 .contents
5993 .entries
5994 .iter()
5995 .filter_map(|entry| entry.session_id().cloned())
5996 .collect();
5997
5998 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
5999
6000 // Query using the same approach as the sidebar: iterate project
6001 // group keys, then do main + legacy queries per group.
6002 let mw = multi_workspace.read(cx);
6003 let mut workspaces_by_group: HashMap<project::ProjectGroupKey, Vec<Entity<Workspace>>> =
6004 HashMap::default();
6005 for workspace in &workspaces {
6006 let key = workspace.read(cx).project_group_key(cx);
6007 workspaces_by_group
6008 .entry(key)
6009 .or_default()
6010 .push(workspace.clone());
6011 }
6012
6013 for group_key in mw.project_group_keys() {
6014 let path_list = group_key.path_list().clone();
6015 if path_list.paths().is_empty() {
6016 continue;
6017 }
6018
6019 let group_workspaces = workspaces_by_group
6020 .get(group_key)
6021 .map(|ws| ws.as_slice())
6022 .unwrap_or_default();
6023
6024 // Main code path queries (run for all groups, even without workspaces).
6025 for metadata in thread_store
6026 .read(cx)
6027 .entries_for_main_worktree_path(&path_list)
6028 {
6029 metadata_thread_ids.insert(metadata.session_id.clone());
6030 }
6031 for metadata in thread_store.read(cx).entries_for_path(&path_list) {
6032 metadata_thread_ids.insert(metadata.session_id.clone());
6033 }
6034
6035 // Legacy: per-workspace queries for different root paths.
6036 let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
6037 .iter()
6038 .flat_map(|ws| {
6039 ws.read(cx)
6040 .root_paths(cx)
6041 .into_iter()
6042 .map(|p| p.to_path_buf())
6043 })
6044 .collect();
6045
6046 for workspace in group_workspaces {
6047 let ws_path_list = workspace_path_list(workspace, cx);
6048 if ws_path_list != path_list {
6049 for metadata in thread_store.read(cx).entries_for_path(&ws_path_list) {
6050 metadata_thread_ids.insert(metadata.session_id.clone());
6051 }
6052 }
6053 }
6054
6055 for workspace in group_workspaces {
6056 for snapshot in root_repository_snapshots(workspace, cx) {
6057 let repo_path_list =
6058 PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
6059 if repo_path_list != path_list {
6060 continue;
6061 }
6062 for linked_worktree in snapshot.linked_worktrees() {
6063 if covered_paths.contains(&*linked_worktree.path) {
6064 continue;
6065 }
6066 let worktree_path_list =
6067 PathList::new(std::slice::from_ref(&linked_worktree.path));
6068 for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list)
6069 {
6070 metadata_thread_ids.insert(metadata.session_id.clone());
6071 }
6072 }
6073 }
6074 }
6075 }
6076
6077 anyhow::ensure!(
6078 sidebar_thread_ids == metadata_thread_ids,
6079 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
6080 sidebar_thread_ids,
6081 metadata_thread_ids,
6082 );
6083 Ok(())
6084 }
6085
6086 fn verify_active_state_matches_current_workspace(
6087 sidebar: &Sidebar,
6088 cx: &App,
6089 ) -> anyhow::Result<()> {
6090 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
6091 anyhow::bail!("sidebar should still have an associated multi-workspace");
6092 };
6093
6094 let active_workspace = multi_workspace.read(cx).workspace();
6095
6096 // 1. active_entry must always be Some after rebuild_contents.
6097 let entry = sidebar
6098 .active_entry
6099 .as_ref()
6100 .ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?;
6101
6102 // 2. The entry's workspace must agree with the multi-workspace's
6103 // active workspace.
6104 anyhow::ensure!(
6105 entry.workspace().entity_id() == active_workspace.entity_id(),
6106 "active_entry workspace ({:?}) != active workspace ({:?})",
6107 entry.workspace().entity_id(),
6108 active_workspace.entity_id(),
6109 );
6110
6111 // 3. The entry must match the agent panel's current state.
6112 let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
6113 if panel.read(cx).active_thread_is_draft(cx) {
6114 anyhow::ensure!(
6115 matches!(entry, ActiveEntry::Draft(_)),
6116 "panel shows a draft but active_entry is {:?}",
6117 entry,
6118 );
6119 } else if let Some(session_id) = panel
6120 .read(cx)
6121 .active_conversation_view()
6122 .and_then(|cv| cv.read(cx).parent_id(cx))
6123 {
6124 anyhow::ensure!(
6125 matches!(entry, ActiveEntry::Thread { session_id: id, .. } if id == &session_id),
6126 "panel has session {:?} but active_entry is {:?}",
6127 session_id,
6128 entry,
6129 );
6130 }
6131
6132 // 4. Exactly one entry in sidebar contents must be uniquely
6133 // identified by the active_entry.
6134 let matching_count = sidebar
6135 .contents
6136 .entries
6137 .iter()
6138 .filter(|e| entry.matches_entry(e))
6139 .count();
6140 anyhow::ensure!(
6141 matching_count == 1,
6142 "expected exactly 1 sidebar entry matching active_entry {:?}, found {}",
6143 entry,
6144 matching_count,
6145 );
6146
6147 Ok(())
6148 }
6149
6150 /// Every workspace in the multi-workspace should be "reachable" from
6151 /// the sidebar — meaning there is at least one entry (thread, draft,
6152 /// new-thread, or project header) that, when clicked, would activate
6153 /// that workspace.
6154 fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
6155 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
6156 anyhow::bail!("sidebar should still have an associated multi-workspace");
6157 };
6158
6159 let mw = multi_workspace.read(cx);
6160
6161 let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
6162 .contents
6163 .entries
6164 .iter()
6165 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
6166 .map(|ws| ws.entity_id())
6167 .collect();
6168
6169 let all_workspace_ids: HashSet<gpui::EntityId> =
6170 mw.workspaces().map(|ws| ws.entity_id()).collect();
6171
6172 let unreachable = &all_workspace_ids - &reachable_workspaces;
6173
6174 anyhow::ensure!(
6175 unreachable.is_empty(),
6176 "The following workspaces are not reachable from any sidebar entry: {:?}",
6177 unreachable,
6178 );
6179
6180 Ok(())
6181 }
6182
6183 #[gpui::property_test(config = ProptestConfig {
6184 cases: 50,
6185 ..Default::default()
6186 })]
6187 #[ignore = "temporarily disabled to unblock PRs from landing"]
6188 async fn test_sidebar_invariants(
6189 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)]
6190 raw_operations: Vec<u32>,
6191 cx: &mut TestAppContext,
6192 ) {
6193 agent_ui::test_support::init_test(cx);
6194 cx.update(|cx| {
6195 ThreadStore::init_global(cx);
6196 ThreadMetadataStore::init_global(cx);
6197 language_model::LanguageModelRegistry::test(cx);
6198 prompt_store::init(cx);
6199
6200 // Auto-add an AgentPanel to every workspace so that implicitly
6201 // created workspaces (e.g. from thread activation) also have one.
6202 cx.observe_new(
6203 |workspace: &mut Workspace,
6204 window: Option<&mut Window>,
6205 cx: &mut gpui::Context<Workspace>| {
6206 if let Some(window) = window {
6207 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
6208 workspace.add_panel(panel, window, cx);
6209 }
6210 },
6211 )
6212 .detach();
6213 });
6214
6215 let fs = FakeFs::new(cx.executor());
6216 fs.insert_tree(
6217 "/my-project",
6218 serde_json::json!({
6219 ".git": {},
6220 "src": {},
6221 }),
6222 )
6223 .await;
6224 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6225 let project =
6226 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
6227 .await;
6228 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
6229
6230 let (multi_workspace, cx) =
6231 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6232 let sidebar = setup_sidebar(&multi_workspace, cx);
6233
6234 let mut state = TestState::new(fs);
6235 let mut executed: Vec<String> = Vec::new();
6236
6237 for &raw_op in &raw_operations {
6238 let project_group_count =
6239 multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().count());
6240 let operation = state.generate_operation(raw_op, project_group_count);
6241 executed.push(format!("{:?}", operation));
6242 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
6243 cx.run_until_parked();
6244
6245 update_sidebar(&sidebar, cx);
6246 cx.run_until_parked();
6247
6248 let result =
6249 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
6250 if let Err(err) = result {
6251 let log = executed.join("\n ");
6252 panic!(
6253 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
6254 executed.len(),
6255 );
6256 }
6257 }
6258 }
6259}