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