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