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