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