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