1use super::*;
2use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
3use agent::ThreadStore;
4use agent_ui::{
5 ThreadId,
6 test_support::{active_session_id, open_thread_with_connection, send_message},
7 thread_metadata_store::{ThreadMetadata, WorktreePaths},
8};
9use chrono::DateTime;
10use fs::{FakeFs, Fs};
11use gpui::TestAppContext;
12use pretty_assertions::assert_eq;
13use project::AgentId;
14use settings::SettingsStore;
15use std::{
16 path::{Path, PathBuf},
17 sync::Arc,
18};
19use util::{path_list::PathList, rel_path::rel_path};
20
21fn init_test(cx: &mut TestAppContext) {
22 cx.update(|cx| {
23 let settings_store = SettingsStore::test(cx);
24 cx.set_global(settings_store);
25 theme_settings::init(theme::LoadThemes::JustBase, cx);
26 editor::init(cx);
27 ThreadStore::init_global(cx);
28 ThreadMetadataStore::init_global(cx);
29 language_model::LanguageModelRegistry::test(cx);
30 prompt_store::init(cx);
31 });
32}
33
34#[track_caller]
35fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
36 let active = sidebar.active_entry.as_ref();
37 let matches = active.is_some_and(|entry| {
38 // Match by session_id directly on active_entry.
39 entry.session_id.as_ref() == Some(session_id)
40 // Or match by finding the thread in sidebar entries.
41 || sidebar.contents.entries.iter().any(|list_entry| {
42 matches!(list_entry, ListEntry::Thread(t)
43 if t.metadata.session_id.as_ref() == Some(session_id)
44 && entry.matches_entry(list_entry))
45 })
46 });
47 assert!(
48 matches,
49 "{msg}: expected active_entry for session {session_id:?}, got {:?}",
50 active,
51 );
52}
53
54#[track_caller]
55fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
56 let thread_id = sidebar
57 .contents
58 .entries
59 .iter()
60 .find_map(|entry| match entry {
61 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
62 Some(t.metadata.thread_id)
63 }
64 _ => None,
65 });
66 match thread_id {
67 Some(tid) => {
68 matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid)
69 }
70 // Thread not in sidebar entries — can't confirm it's active.
71 None => false,
72 }
73}
74
75#[track_caller]
76fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
77 assert!(
78 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace),
79 "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
80 workspace.entity_id(),
81 sidebar.active_entry,
82 );
83}
84
85fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
86 sidebar
87 .contents
88 .entries
89 .iter()
90 .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
91}
92
93#[track_caller]
94fn assert_remote_project_integration_sidebar_state(
95 sidebar: &mut Sidebar,
96 main_thread_id: &acp::SessionId,
97 remote_thread_id: &acp::SessionId,
98) {
99 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
100 if let ListEntry::ProjectHeader { label, .. } = entry {
101 Some(label.as_ref())
102 } else {
103 None
104 }
105 });
106
107 let Some(project_header) = project_headers.next() else {
108 panic!("expected exactly one sidebar project header named `project`, found none");
109 };
110 assert_eq!(
111 project_header, "project",
112 "expected the only sidebar project header to be `project`"
113 );
114 if let Some(unexpected_header) = project_headers.next() {
115 panic!(
116 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
117 );
118 }
119
120 let mut saw_main_thread = false;
121 let mut saw_remote_thread = false;
122 for entry in &sidebar.contents.entries {
123 match entry {
124 ListEntry::ProjectHeader { label, .. } => {
125 assert_eq!(
126 label.as_ref(),
127 "project",
128 "expected the only sidebar project header to be `project`"
129 );
130 }
131 ListEntry::Thread(thread)
132 if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
133 {
134 saw_main_thread = true;
135 }
136 ListEntry::Thread(thread)
137 if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
138 {
139 saw_remote_thread = true;
140 }
141 ListEntry::Thread(thread) if thread.is_draft => {}
142 ListEntry::Thread(thread) => {
143 let title = thread.metadata.display_title();
144 panic!(
145 "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
146 title
147 );
148 }
149 ListEntry::ViewMore { .. } => {
150 panic!(
151 "unexpected `View More` entry while simulating remote project integration flicker"
152 );
153 }
154 }
155 }
156
157 assert!(
158 saw_main_thread,
159 "expected the sidebar to keep showing `Main Thread` under `project`"
160 );
161 assert!(
162 saw_remote_thread,
163 "expected the sidebar to keep showing `Worktree Thread` under `project`"
164 );
165}
166
167async fn init_test_project(
168 worktree_path: &str,
169 cx: &mut TestAppContext,
170) -> Entity<project::Project> {
171 init_test(cx);
172 let fs = FakeFs::new(cx.executor());
173 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
174 .await;
175 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
176 project::Project::test(fs, [worktree_path.as_ref()], cx).await
177}
178
179fn setup_sidebar(
180 multi_workspace: &Entity<MultiWorkspace>,
181 cx: &mut gpui::VisualTestContext,
182) -> Entity<Sidebar> {
183 let sidebar = setup_sidebar_closed(multi_workspace, cx);
184 multi_workspace.update_in(cx, |mw, window, cx| {
185 mw.toggle_sidebar(window, cx);
186 });
187 cx.run_until_parked();
188 sidebar
189}
190
191fn setup_sidebar_closed(
192 multi_workspace: &Entity<MultiWorkspace>,
193 cx: &mut gpui::VisualTestContext,
194) -> Entity<Sidebar> {
195 let multi_workspace = multi_workspace.clone();
196 let sidebar =
197 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
198 multi_workspace.update(cx, |mw, cx| {
199 mw.register_sidebar(sidebar.clone(), cx);
200 });
201 cx.run_until_parked();
202 sidebar
203}
204
205async fn save_n_test_threads(
206 count: u32,
207 project: &Entity<project::Project>,
208 cx: &mut gpui::VisualTestContext,
209) {
210 for i in 0..count {
211 save_thread_metadata(
212 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
213 Some(format!("Thread {}", i + 1).into()),
214 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
215 None,
216 project,
217 cx,
218 )
219 }
220 cx.run_until_parked();
221}
222
223async fn save_test_thread_metadata(
224 session_id: &acp::SessionId,
225 project: &Entity<project::Project>,
226 cx: &mut TestAppContext,
227) {
228 save_thread_metadata(
229 session_id.clone(),
230 Some("Test".into()),
231 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
232 None,
233 project,
234 cx,
235 )
236}
237
238async fn save_named_thread_metadata(
239 session_id: &str,
240 title: &str,
241 project: &Entity<project::Project>,
242 cx: &mut gpui::VisualTestContext,
243) {
244 save_thread_metadata(
245 acp::SessionId::new(Arc::from(session_id)),
246 Some(SharedString::from(title.to_string())),
247 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
248 None,
249 project,
250 cx,
251 );
252 cx.run_until_parked();
253}
254
255fn save_thread_metadata(
256 session_id: acp::SessionId,
257 title: Option<SharedString>,
258 updated_at: DateTime<Utc>,
259 created_at: Option<DateTime<Utc>>,
260 project: &Entity<project::Project>,
261 cx: &mut TestAppContext,
262) {
263 cx.update(|cx| {
264 let worktree_paths = project.read(cx).worktree_paths(cx);
265 let remote_connection = project.read(cx).remote_connection_options(cx);
266 let thread_id = ThreadMetadataStore::global(cx)
267 .read(cx)
268 .entries()
269 .find(|e| e.session_id.as_ref() == Some(&session_id))
270 .map(|e| e.thread_id)
271 .unwrap_or_else(ThreadId::new);
272 let metadata = ThreadMetadata {
273 thread_id,
274 session_id: Some(session_id),
275 agent_id: agent::ZED_AGENT_ID.clone(),
276 title,
277 updated_at,
278 created_at,
279 worktree_paths,
280 archived: false,
281 remote_connection,
282 };
283 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
284 });
285 cx.run_until_parked();
286}
287
288fn save_thread_metadata_with_main_paths(
289 session_id: &str,
290 title: &str,
291 folder_paths: PathList,
292 main_worktree_paths: PathList,
293 updated_at: DateTime<Utc>,
294 cx: &mut TestAppContext,
295) {
296 let session_id = acp::SessionId::new(Arc::from(session_id));
297 let title = SharedString::from(title.to_string());
298 let thread_id = cx.update(|cx| {
299 ThreadMetadataStore::global(cx)
300 .read(cx)
301 .entries()
302 .find(|e| e.session_id.as_ref() == Some(&session_id))
303 .map(|e| e.thread_id)
304 .unwrap_or_else(ThreadId::new)
305 });
306 let metadata = ThreadMetadata {
307 thread_id,
308 session_id: Some(session_id),
309 agent_id: agent::ZED_AGENT_ID.clone(),
310 title: Some(title),
311 updated_at,
312 created_at: None,
313 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
314 archived: false,
315 remote_connection: None,
316 };
317 cx.update(|cx| {
318 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
319 });
320 cx.run_until_parked();
321}
322
323fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
324 sidebar.update_in(cx, |_, window, cx| {
325 cx.focus_self(window);
326 });
327 cx.run_until_parked();
328}
329
330fn request_test_tool_authorization(
331 thread: &Entity<AcpThread>,
332 tool_call_id: &str,
333 option_id: &str,
334 cx: &mut gpui::VisualTestContext,
335) {
336 let tool_call_id = acp::ToolCallId::new(tool_call_id);
337 let label = format!("Tool {tool_call_id}");
338 let option_id = acp::PermissionOptionId::new(option_id);
339 let _authorization_task = cx.update(|_, cx| {
340 thread.update(cx, |thread, cx| {
341 thread
342 .request_tool_call_authorization(
343 acp::ToolCall::new(tool_call_id, label)
344 .kind(acp::ToolKind::Edit)
345 .into(),
346 PermissionOptions::Flat(vec![acp::PermissionOption::new(
347 option_id,
348 "Allow",
349 acp::PermissionOptionKind::AllowOnce,
350 )]),
351 cx,
352 )
353 .unwrap()
354 })
355 });
356 cx.run_until_parked();
357}
358
359fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
360 let mut seen = Vec::new();
361 let mut chips = Vec::new();
362 for wt in worktrees {
363 if wt.kind == ui::WorktreeKind::Main {
364 continue;
365 }
366 if !seen.contains(&wt.name) {
367 seen.push(wt.name.clone());
368 chips.push(format!("{{{}}}", wt.name));
369 }
370 }
371 if chips.is_empty() {
372 String::new()
373 } else {
374 format!(" {}", chips.join(", "))
375 }
376}
377
378fn visible_entries_as_strings(
379 sidebar: &Entity<Sidebar>,
380 cx: &mut gpui::VisualTestContext,
381) -> Vec<String> {
382 sidebar.read_with(cx, |sidebar, cx| {
383 sidebar
384 .contents
385 .entries
386 .iter()
387 .enumerate()
388 .map(|(ix, entry)| {
389 let selected = if sidebar.selection == Some(ix) {
390 " <== selected"
391 } else {
392 ""
393 };
394 match entry {
395 ListEntry::ProjectHeader {
396 label,
397 key,
398 highlight_positions: _,
399 ..
400 } => {
401 let icon = if sidebar.is_group_collapsed(key, cx) {
402 ">"
403 } else {
404 "v"
405 };
406 format!("{} [{}]{}", icon, label, selected)
407 }
408 ListEntry::Thread(thread) => {
409 let title = thread.metadata.display_title();
410 let worktree = format_linked_worktree_chips(&thread.worktrees);
411
412 if thread.is_draft {
413 let is_active = sidebar
414 .active_entry
415 .as_ref()
416 .is_some_and(|e| e.matches_entry(entry));
417 let active_marker = if is_active { " *" } else { "" };
418 format!(" [~ Draft{worktree}]{active_marker}{selected}")
419 } else {
420 let live = if thread.is_live { " *" } else { "" };
421 let status_str = match thread.status {
422 AgentThreadStatus::Running => " (running)",
423 AgentThreadStatus::Error => " (error)",
424 AgentThreadStatus::WaitingForConfirmation => " (waiting)",
425 _ => "",
426 };
427 let notified = if sidebar
428 .contents
429 .is_thread_notified(&thread.metadata.thread_id)
430 {
431 " (!)"
432 } else {
433 ""
434 };
435 format!(" {title}{worktree}{live}{status_str}{notified}{selected}")
436 }
437 }
438 ListEntry::ViewMore {
439 is_fully_expanded, ..
440 } => {
441 if *is_fully_expanded {
442 format!(" - Collapse{}", selected)
443 } else {
444 format!(" + View More{}", selected)
445 }
446 }
447 }
448 })
449 .collect()
450 })
451}
452
453#[gpui::test]
454async fn test_serialization_round_trip(cx: &mut TestAppContext) {
455 let project = init_test_project("/my-project", cx).await;
456 let (multi_workspace, cx) =
457 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
458 let sidebar = setup_sidebar(&multi_workspace, cx);
459
460 save_n_test_threads(3, &project, cx).await;
461
462 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
463
464 // Set a custom width, collapse the group, and expand "View More".
465 sidebar.update_in(cx, |sidebar, window, cx| {
466 sidebar.set_width(Some(px(420.0)), cx);
467 sidebar.toggle_collapse(&project_group_key, window, cx);
468 });
469 cx.run_until_parked();
470
471 // Capture the serialized state from the first sidebar.
472 let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
473 let serialized = serialized.expect("serialized_state should return Some");
474
475 // Create a fresh sidebar and restore into it.
476 let sidebar2 =
477 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
478 cx.run_until_parked();
479
480 sidebar2.update_in(cx, |sidebar, window, cx| {
481 sidebar.restore_serialized_state(&serialized, window, cx);
482 });
483 cx.run_until_parked();
484
485 // Assert all serialized fields match.
486 let width1 = sidebar.read_with(cx, |s, _| s.width);
487 let width2 = sidebar2.read_with(cx, |s, _| s.width);
488
489 assert_eq!(width1, width2);
490 assert_eq!(width1, px(420.0));
491}
492
493#[gpui::test]
494async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
495 // A regression test to ensure that restoring a serialized archive view does not panic.
496 let project = init_test_project_with_agent_panel("/my-project", cx).await;
497 let (multi_workspace, cx) =
498 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
499 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
500 cx.update(|_window, cx| {
501 AgentRegistryStore::init_test_global(cx, vec![]);
502 });
503
504 let serialized = serde_json::to_string(&SerializedSidebar {
505 width: Some(400.0),
506 active_view: SerializedSidebarView::Archive,
507 })
508 .expect("serialization should succeed");
509
510 multi_workspace.update_in(cx, |multi_workspace, window, cx| {
511 if let Some(sidebar) = multi_workspace.sidebar() {
512 sidebar.restore_serialized_state(&serialized, window, cx);
513 }
514 });
515 cx.run_until_parked();
516
517 // After the deferred `show_archive` runs, the view should be Archive.
518 sidebar.read_with(cx, |sidebar, _cx| {
519 assert!(
520 matches!(sidebar.view, SidebarView::Archive(_)),
521 "expected sidebar view to be Archive after restore, got ThreadList"
522 );
523 });
524}
525
526#[test]
527fn test_clean_mention_links() {
528 // Simple mention link
529 assert_eq!(
530 Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
531 "check @Button.tsx"
532 );
533
534 // Multiple mention links
535 assert_eq!(
536 Sidebar::clean_mention_links(
537 "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
538 ),
539 "look at @foo.rs and @bar.rs"
540 );
541
542 // No mention links — passthrough
543 assert_eq!(
544 Sidebar::clean_mention_links("plain text with no mentions"),
545 "plain text with no mentions"
546 );
547
548 // Incomplete link syntax — preserved as-is
549 assert_eq!(
550 Sidebar::clean_mention_links("broken [@mention without closing"),
551 "broken [@mention without closing"
552 );
553
554 // Regular markdown link (no @) — not touched
555 assert_eq!(
556 Sidebar::clean_mention_links("see [docs](https://example.com)"),
557 "see [docs](https://example.com)"
558 );
559
560 // Empty input
561 assert_eq!(Sidebar::clean_mention_links(""), "");
562}
563
564#[gpui::test]
565async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
566 let project = init_test_project("/my-project", cx).await;
567 let (multi_workspace, cx) =
568 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
569 let sidebar = setup_sidebar(&multi_workspace, cx);
570
571 let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
572 let weak_sidebar = sidebar.downgrade();
573 let weak_multi_workspace = multi_workspace.downgrade();
574
575 drop(sidebar);
576 drop(multi_workspace);
577 cx.update(|window, _cx| window.remove_window());
578 cx.run_until_parked();
579
580 weak_multi_workspace.assert_released();
581 weak_sidebar.assert_released();
582 weak_workspace.assert_released();
583}
584
585#[gpui::test]
586async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
587 let project = init_test_project_with_agent_panel("/my-project", cx).await;
588 let (multi_workspace, cx) =
589 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
590 let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
591
592 assert_eq!(
593 visible_entries_as_strings(&_sidebar, cx),
594 vec!["v [my-project]"]
595 );
596}
597
598#[gpui::test]
599async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
600 let project = init_test_project("/my-project", cx).await;
601 let (multi_workspace, cx) =
602 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
603 let sidebar = setup_sidebar(&multi_workspace, cx);
604
605 save_thread_metadata(
606 acp::SessionId::new(Arc::from("thread-1")),
607 Some("Fix crash in project panel".into()),
608 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
609 None,
610 &project,
611 cx,
612 );
613
614 save_thread_metadata(
615 acp::SessionId::new(Arc::from("thread-2")),
616 Some("Add inline diff view".into()),
617 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
618 None,
619 &project,
620 cx,
621 );
622 cx.run_until_parked();
623
624 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
625 cx.run_until_parked();
626
627 assert_eq!(
628 visible_entries_as_strings(&sidebar, cx),
629 vec![
630 //
631 "v [my-project]",
632 " Fix crash in project panel",
633 " Add inline diff view",
634 ]
635 );
636}
637
638#[gpui::test]
639async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
640 let project = init_test_project("/project-a", cx).await;
641 let (multi_workspace, cx) =
642 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
643 let sidebar = setup_sidebar(&multi_workspace, cx);
644
645 // Single workspace with a thread
646 save_thread_metadata(
647 acp::SessionId::new(Arc::from("thread-a1")),
648 Some("Thread A1".into()),
649 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
650 None,
651 &project,
652 cx,
653 );
654 cx.run_until_parked();
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![
662 //
663 "v [project-a]",
664 " Thread A1",
665 ]
666 );
667
668 // Add a second workspace
669 multi_workspace.update_in(cx, |mw, window, cx| {
670 mw.create_test_workspace(window, cx).detach();
671 });
672 cx.run_until_parked();
673
674 assert_eq!(
675 visible_entries_as_strings(&sidebar, cx),
676 vec![
677 //
678 "v [project-a]",
679 " Thread A1",
680 ]
681 );
682}
683
684#[gpui::test]
685async fn test_view_more_pagination(cx: &mut TestAppContext) {
686 let project = init_test_project("/my-project", cx).await;
687 let (multi_workspace, cx) =
688 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
689 let sidebar = setup_sidebar(&multi_workspace, cx);
690
691 save_n_test_threads(12, &project, cx).await;
692
693 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
694 cx.run_until_parked();
695
696 assert_eq!(
697 visible_entries_as_strings(&sidebar, cx),
698 vec![
699 //
700 "v [my-project]",
701 " Thread 12",
702 " Thread 11",
703 " Thread 10",
704 " Thread 9",
705 " Thread 8",
706 " + View More",
707 ]
708 );
709}
710
711#[gpui::test]
712async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
713 let project = init_test_project("/my-project", cx).await;
714 let (multi_workspace, cx) =
715 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
716 let sidebar = setup_sidebar(&multi_workspace, cx);
717
718 // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
719 save_n_test_threads(17, &project, cx).await;
720
721 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
722
723 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
724 cx.run_until_parked();
725
726 // Initially shows 5 threads + View More
727 let entries = visible_entries_as_strings(&sidebar, cx);
728 assert_eq!(entries.len(), 7); // header + 5 threads + View More
729 assert!(entries.iter().any(|e| e.contains("View More")));
730
731 // Focus and navigate to View More, then confirm to expand by one batch
732 focus_sidebar(&sidebar, cx);
733 for _ in 0..7 {
734 cx.dispatch_action(SelectNext);
735 }
736 cx.dispatch_action(Confirm);
737 cx.run_until_parked();
738
739 // Now shows 10 threads + View More
740 let entries = visible_entries_as_strings(&sidebar, cx);
741 assert_eq!(entries.len(), 12); // header + 10 threads + View More
742 assert!(entries.iter().any(|e| e.contains("View More")));
743
744 // Expand again by one batch
745 sidebar.update_in(cx, |s, _window, cx| {
746 s.expand_thread_group(&project_group_key, cx);
747 });
748 cx.run_until_parked();
749
750 // Now shows 15 threads + View More
751 let entries = visible_entries_as_strings(&sidebar, cx);
752 assert_eq!(entries.len(), 17); // header + 15 threads + View More
753 assert!(entries.iter().any(|e| e.contains("View More")));
754
755 // Expand one more time - should show all 17 threads with Collapse button
756 sidebar.update_in(cx, |s, _window, cx| {
757 s.expand_thread_group(&project_group_key, cx);
758 });
759 cx.run_until_parked();
760
761 // All 17 threads shown with Collapse button
762 let entries = visible_entries_as_strings(&sidebar, cx);
763 assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
764 assert!(!entries.iter().any(|e| e.contains("View More")));
765 assert!(entries.iter().any(|e| e.contains("Collapse")));
766
767 // Click collapse - should go back to showing 5 threads
768 sidebar.update_in(cx, |s, _window, cx| {
769 s.reset_thread_group_expansion(&project_group_key, cx);
770 });
771 cx.run_until_parked();
772
773 // Back to initial state: 5 threads + View More
774 let entries = visible_entries_as_strings(&sidebar, cx);
775 assert_eq!(entries.len(), 7); // header + 5 threads + View More
776 assert!(entries.iter().any(|e| e.contains("View More")));
777}
778
779#[gpui::test]
780async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
781 let project = init_test_project("/my-project", cx).await;
782 let (multi_workspace, cx) =
783 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
784 let sidebar = setup_sidebar(&multi_workspace, cx);
785
786 save_n_test_threads(1, &project, cx).await;
787
788 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
789
790 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
791 cx.run_until_parked();
792
793 assert_eq!(
794 visible_entries_as_strings(&sidebar, cx),
795 vec![
796 //
797 "v [my-project]",
798 " Thread 1",
799 ]
800 );
801
802 // Collapse
803 sidebar.update_in(cx, |s, window, cx| {
804 s.toggle_collapse(&project_group_key, window, cx);
805 });
806 cx.run_until_parked();
807
808 assert_eq!(
809 visible_entries_as_strings(&sidebar, cx),
810 vec![
811 //
812 "> [my-project]",
813 ]
814 );
815
816 // Expand
817 sidebar.update_in(cx, |s, window, cx| {
818 s.toggle_collapse(&project_group_key, window, cx);
819 });
820 cx.run_until_parked();
821
822 assert_eq!(
823 visible_entries_as_strings(&sidebar, cx),
824 vec![
825 //
826 "v [my-project]",
827 " Thread 1",
828 ]
829 );
830}
831
832#[gpui::test]
833async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
834 // When a worktree is added to a project, the project group key changes.
835 // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
836 // UI state must survive the key change.
837 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
838 let (multi_workspace, cx) =
839 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
840 let sidebar = setup_sidebar(&multi_workspace, cx);
841
842 save_n_test_threads(2, &project, cx).await;
843 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
844 cx.run_until_parked();
845
846 assert_eq!(
847 visible_entries_as_strings(&sidebar, cx),
848 vec!["v [project-a]", " Thread 2", " Thread 1",]
849 );
850
851 // Collapse the group.
852 let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
853 sidebar.update_in(cx, |sidebar, window, cx| {
854 sidebar.toggle_collapse(&old_key, window, cx);
855 });
856 cx.run_until_parked();
857
858 assert_eq!(
859 visible_entries_as_strings(&sidebar, cx),
860 vec!["> [project-a]"]
861 );
862
863 // Add a second worktree — the key changes from [/project-a] to
864 // [/project-a, /project-b].
865 project
866 .update(cx, |project, cx| {
867 project.find_or_create_worktree("/project-b", true, cx)
868 })
869 .await
870 .expect("should add worktree");
871 cx.run_until_parked();
872
873 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
874 cx.run_until_parked();
875
876 // The group should still be collapsed under the new key.
877 assert_eq!(
878 visible_entries_as_strings(&sidebar, cx),
879 vec!["> [project-a, project-b]"]
880 );
881}
882
883#[gpui::test]
884async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
885 use workspace::ProjectGroup;
886
887 let project = init_test_project("/my-project", cx).await;
888 let (multi_workspace, cx) =
889 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
890 let sidebar = setup_sidebar(&multi_workspace, cx);
891
892 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
893 let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
894 let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
895
896 // Set the collapsed group state through multi_workspace
897 multi_workspace.update(cx, |mw, _cx| {
898 mw.test_add_project_group(ProjectGroup {
899 key: ProjectGroupKey::new(None, collapsed_path.clone()),
900 workspaces: Vec::new(),
901 expanded: false,
902 visible_thread_count: None,
903 });
904 });
905
906 sidebar.update_in(cx, |s, _window, _cx| {
907 let notified_thread_id = ThreadId::new();
908 s.contents.notified_threads.insert(notified_thread_id);
909 s.contents.entries = vec![
910 // Expanded project header
911 ListEntry::ProjectHeader {
912 key: ProjectGroupKey::new(None, expanded_path.clone()),
913 label: "expanded-project".into(),
914 highlight_positions: Vec::new(),
915 has_running_threads: false,
916 waiting_thread_count: 0,
917 is_active: true,
918 has_threads: true,
919 },
920 ListEntry::Thread(ThreadEntry {
921 metadata: ThreadMetadata {
922 thread_id: ThreadId::new(),
923 session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
924 agent_id: AgentId::new("zed-agent"),
925 worktree_paths: WorktreePaths::default(),
926 title: Some("Completed thread".into()),
927 updated_at: Utc::now(),
928 created_at: Some(Utc::now()),
929 archived: false,
930 remote_connection: None,
931 },
932 icon: IconName::ZedAgent,
933 icon_from_external_svg: None,
934 status: AgentThreadStatus::Completed,
935 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
936 is_live: false,
937 is_background: false,
938 is_title_generating: false,
939 is_draft: false,
940 highlight_positions: Vec::new(),
941 worktrees: Vec::new(),
942 diff_stats: DiffStats::default(),
943 }),
944 // Active thread with Running status
945 ListEntry::Thread(ThreadEntry {
946 metadata: ThreadMetadata {
947 thread_id: ThreadId::new(),
948 session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
949 agent_id: AgentId::new("zed-agent"),
950 worktree_paths: WorktreePaths::default(),
951 title: Some("Running thread".into()),
952 updated_at: Utc::now(),
953 created_at: Some(Utc::now()),
954 archived: false,
955 remote_connection: None,
956 },
957 icon: IconName::ZedAgent,
958 icon_from_external_svg: None,
959 status: AgentThreadStatus::Running,
960 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
961 is_live: true,
962 is_background: false,
963 is_title_generating: false,
964 is_draft: false,
965 highlight_positions: Vec::new(),
966 worktrees: Vec::new(),
967 diff_stats: DiffStats::default(),
968 }),
969 // Active thread with Error status
970 ListEntry::Thread(ThreadEntry {
971 metadata: ThreadMetadata {
972 thread_id: ThreadId::new(),
973 session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
974 agent_id: AgentId::new("zed-agent"),
975 worktree_paths: WorktreePaths::default(),
976 title: Some("Error thread".into()),
977 updated_at: Utc::now(),
978 created_at: Some(Utc::now()),
979 archived: false,
980 remote_connection: None,
981 },
982 icon: IconName::ZedAgent,
983 icon_from_external_svg: None,
984 status: AgentThreadStatus::Error,
985 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
986 is_live: true,
987 is_background: false,
988 is_title_generating: false,
989 is_draft: false,
990 highlight_positions: Vec::new(),
991 worktrees: Vec::new(),
992 diff_stats: DiffStats::default(),
993 }),
994 // Thread with WaitingForConfirmation status, not active
995 // remote_connection: None,
996 ListEntry::Thread(ThreadEntry {
997 metadata: ThreadMetadata {
998 thread_id: ThreadId::new(),
999 session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
1000 agent_id: AgentId::new("zed-agent"),
1001 worktree_paths: WorktreePaths::default(),
1002 title: Some("Waiting thread".into()),
1003 updated_at: Utc::now(),
1004 created_at: Some(Utc::now()),
1005 archived: false,
1006 remote_connection: None,
1007 },
1008 icon: IconName::ZedAgent,
1009 icon_from_external_svg: None,
1010 status: AgentThreadStatus::WaitingForConfirmation,
1011 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
1012 is_live: false,
1013 is_background: false,
1014 is_title_generating: false,
1015 is_draft: false,
1016 highlight_positions: Vec::new(),
1017 worktrees: Vec::new(),
1018 diff_stats: DiffStats::default(),
1019 }),
1020 // Background thread that completed (should show notification)
1021 // remote_connection: None,
1022 ListEntry::Thread(ThreadEntry {
1023 metadata: ThreadMetadata {
1024 thread_id: notified_thread_id,
1025 session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
1026 agent_id: AgentId::new("zed-agent"),
1027 worktree_paths: WorktreePaths::default(),
1028 title: Some("Notified thread".into()),
1029 updated_at: Utc::now(),
1030 created_at: Some(Utc::now()),
1031 archived: false,
1032 remote_connection: None,
1033 },
1034 icon: IconName::ZedAgent,
1035 icon_from_external_svg: None,
1036 status: AgentThreadStatus::Completed,
1037 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
1038 is_live: true,
1039 is_background: true,
1040 is_title_generating: false,
1041 is_draft: false,
1042 highlight_positions: Vec::new(),
1043 worktrees: Vec::new(),
1044 diff_stats: DiffStats::default(),
1045 }),
1046 // View More entry
1047 ListEntry::ViewMore {
1048 key: ProjectGroupKey::new(None, expanded_path.clone()),
1049 is_fully_expanded: false,
1050 },
1051 // Collapsed project header
1052 ListEntry::ProjectHeader {
1053 key: ProjectGroupKey::new(None, collapsed_path.clone()),
1054 label: "collapsed-project".into(),
1055 highlight_positions: Vec::new(),
1056 has_running_threads: false,
1057 waiting_thread_count: 0,
1058 is_active: false,
1059 has_threads: false,
1060 },
1061 ];
1062
1063 // Select the Running thread (index 2)
1064 s.selection = Some(2);
1065 });
1066
1067 assert_eq!(
1068 visible_entries_as_strings(&sidebar, cx),
1069 vec![
1070 //
1071 "v [expanded-project]",
1072 " Completed thread",
1073 " Running thread * (running) <== selected",
1074 " Error thread * (error)",
1075 " Waiting thread (waiting)",
1076 " Notified thread * (!)",
1077 " + View More",
1078 "> [collapsed-project]",
1079 ]
1080 );
1081
1082 // Move selection to the collapsed header
1083 sidebar.update_in(cx, |s, _window, _cx| {
1084 s.selection = Some(7);
1085 });
1086
1087 assert_eq!(
1088 visible_entries_as_strings(&sidebar, cx).last().cloned(),
1089 Some("> [collapsed-project] <== selected".to_string()),
1090 );
1091
1092 // Clear selection
1093 sidebar.update_in(cx, |s, _window, _cx| {
1094 s.selection = None;
1095 });
1096
1097 // No entry should have the selected marker
1098 let entries = visible_entries_as_strings(&sidebar, cx);
1099 for entry in &entries {
1100 assert!(
1101 !entry.contains("<== selected"),
1102 "unexpected selection marker in: {}",
1103 entry
1104 );
1105 }
1106}
1107
1108#[gpui::test]
1109async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
1110 let project = init_test_project("/my-project", cx).await;
1111 let (multi_workspace, cx) =
1112 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1113 let sidebar = setup_sidebar(&multi_workspace, cx);
1114
1115 save_n_test_threads(3, &project, cx).await;
1116
1117 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1118 cx.run_until_parked();
1119
1120 // Entries: [header, thread3, thread2, thread1]
1121 // Focusing the sidebar does not set a selection; select_next/select_previous
1122 // handle None gracefully by starting from the first or last entry.
1123 focus_sidebar(&sidebar, cx);
1124 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1125
1126 // First SelectNext from None starts at index 0
1127 cx.dispatch_action(SelectNext);
1128 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1129
1130 // Move down through remaining entries
1131 cx.dispatch_action(SelectNext);
1132 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1133
1134 cx.dispatch_action(SelectNext);
1135 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1136
1137 cx.dispatch_action(SelectNext);
1138 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1139
1140 // At the end, wraps back to first entry
1141 cx.dispatch_action(SelectNext);
1142 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1143
1144 // Navigate back to the end
1145 cx.dispatch_action(SelectNext);
1146 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1147 cx.dispatch_action(SelectNext);
1148 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1149 cx.dispatch_action(SelectNext);
1150 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1151
1152 // Move back up
1153 cx.dispatch_action(SelectPrevious);
1154 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1155
1156 cx.dispatch_action(SelectPrevious);
1157 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1158
1159 cx.dispatch_action(SelectPrevious);
1160 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1161
1162 // At the top, selection clears (focus returns to editor)
1163 cx.dispatch_action(SelectPrevious);
1164 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1165}
1166
1167#[gpui::test]
1168async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
1169 let project = init_test_project("/my-project", cx).await;
1170 let (multi_workspace, cx) =
1171 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1172 let sidebar = setup_sidebar(&multi_workspace, cx);
1173
1174 save_n_test_threads(3, &project, cx).await;
1175 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1176 cx.run_until_parked();
1177
1178 focus_sidebar(&sidebar, cx);
1179
1180 // SelectLast jumps to the end
1181 cx.dispatch_action(SelectLast);
1182 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1183
1184 // SelectFirst jumps to the beginning
1185 cx.dispatch_action(SelectFirst);
1186 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1187}
1188
1189#[gpui::test]
1190async fn test_keyboard_focus_in_does_not_set_selection(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 // Initially no selection
1197 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1198
1199 // Open the sidebar so it's rendered, then focus it to trigger focus_in.
1200 // focus_in no longer sets a default selection.
1201 focus_sidebar(&sidebar, cx);
1202 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1203
1204 // Manually set a selection, blur, then refocus — selection should be preserved
1205 sidebar.update_in(cx, |sidebar, _window, _cx| {
1206 sidebar.selection = Some(0);
1207 });
1208
1209 cx.update(|window, _cx| {
1210 window.blur();
1211 });
1212 cx.run_until_parked();
1213
1214 sidebar.update_in(cx, |_, window, cx| {
1215 cx.focus_self(window);
1216 });
1217 cx.run_until_parked();
1218 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1219}
1220
1221#[gpui::test]
1222async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
1223 let project = init_test_project("/my-project", cx).await;
1224 let (multi_workspace, cx) =
1225 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1226 let sidebar = setup_sidebar(&multi_workspace, cx);
1227
1228 save_n_test_threads(1, &project, cx).await;
1229 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1230 cx.run_until_parked();
1231
1232 assert_eq!(
1233 visible_entries_as_strings(&sidebar, cx),
1234 vec![
1235 //
1236 "v [my-project]",
1237 " Thread 1",
1238 ]
1239 );
1240
1241 // Focus the sidebar and select the header
1242 focus_sidebar(&sidebar, cx);
1243 sidebar.update_in(cx, |sidebar, _window, _cx| {
1244 sidebar.selection = Some(0);
1245 });
1246
1247 // Confirm on project header collapses the group
1248 cx.dispatch_action(Confirm);
1249 cx.run_until_parked();
1250
1251 assert_eq!(
1252 visible_entries_as_strings(&sidebar, cx),
1253 vec![
1254 //
1255 "> [my-project] <== selected",
1256 ]
1257 );
1258
1259 // Confirm again expands the group
1260 cx.dispatch_action(Confirm);
1261 cx.run_until_parked();
1262
1263 assert_eq!(
1264 visible_entries_as_strings(&sidebar, cx),
1265 vec![
1266 //
1267 "v [my-project] <== selected",
1268 " Thread 1",
1269 ]
1270 );
1271}
1272
1273#[gpui::test]
1274async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
1275 let project = init_test_project("/my-project", cx).await;
1276 let (multi_workspace, cx) =
1277 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1278 let sidebar = setup_sidebar(&multi_workspace, cx);
1279
1280 save_n_test_threads(8, &project, cx).await;
1281 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1282 cx.run_until_parked();
1283
1284 // Should show header + 5 threads + "View More"
1285 let entries = visible_entries_as_strings(&sidebar, cx);
1286 assert_eq!(entries.len(), 7);
1287 assert!(entries.iter().any(|e| e.contains("View More")));
1288
1289 // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
1290 focus_sidebar(&sidebar, cx);
1291 for _ in 0..7 {
1292 cx.dispatch_action(SelectNext);
1293 }
1294 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
1295
1296 // Confirm on "View More" to expand
1297 cx.dispatch_action(Confirm);
1298 cx.run_until_parked();
1299
1300 // All 8 threads should now be visible with a "Collapse" button
1301 let entries = visible_entries_as_strings(&sidebar, cx);
1302 assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
1303 assert!(!entries.iter().any(|e| e.contains("View More")));
1304 assert!(entries.iter().any(|e| e.contains("Collapse")));
1305}
1306
1307#[gpui::test]
1308async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
1309 let project = init_test_project("/my-project", cx).await;
1310 let (multi_workspace, cx) =
1311 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1312 let sidebar = setup_sidebar(&multi_workspace, cx);
1313
1314 save_n_test_threads(1, &project, cx).await;
1315 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1316 cx.run_until_parked();
1317
1318 assert_eq!(
1319 visible_entries_as_strings(&sidebar, cx),
1320 vec![
1321 //
1322 "v [my-project]",
1323 " Thread 1",
1324 ]
1325 );
1326
1327 // Focus sidebar and manually select the header (index 0). Press left to collapse.
1328 focus_sidebar(&sidebar, cx);
1329 sidebar.update_in(cx, |sidebar, _window, _cx| {
1330 sidebar.selection = Some(0);
1331 });
1332
1333 cx.dispatch_action(SelectParent);
1334 cx.run_until_parked();
1335
1336 assert_eq!(
1337 visible_entries_as_strings(&sidebar, cx),
1338 vec![
1339 //
1340 "> [my-project] <== selected",
1341 ]
1342 );
1343
1344 // Press right to expand
1345 cx.dispatch_action(SelectChild);
1346 cx.run_until_parked();
1347
1348 assert_eq!(
1349 visible_entries_as_strings(&sidebar, cx),
1350 vec![
1351 //
1352 "v [my-project] <== selected",
1353 " Thread 1",
1354 ]
1355 );
1356
1357 // Press right again on already-expanded header moves selection down
1358 cx.dispatch_action(SelectChild);
1359 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1360}
1361
1362#[gpui::test]
1363async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
1364 let project = init_test_project("/my-project", cx).await;
1365 let (multi_workspace, cx) =
1366 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1367 let sidebar = setup_sidebar(&multi_workspace, cx);
1368
1369 save_n_test_threads(1, &project, cx).await;
1370 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1371 cx.run_until_parked();
1372
1373 // Focus sidebar (selection starts at None), then navigate down to the thread (child)
1374 focus_sidebar(&sidebar, cx);
1375 cx.dispatch_action(SelectNext);
1376 cx.dispatch_action(SelectNext);
1377 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1378
1379 assert_eq!(
1380 visible_entries_as_strings(&sidebar, cx),
1381 vec![
1382 //
1383 "v [my-project]",
1384 " Thread 1 <== selected",
1385 ]
1386 );
1387
1388 // Pressing left on a child collapses the parent group and selects it
1389 cx.dispatch_action(SelectParent);
1390 cx.run_until_parked();
1391
1392 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1393 assert_eq!(
1394 visible_entries_as_strings(&sidebar, cx),
1395 vec![
1396 //
1397 "> [my-project] <== selected",
1398 ]
1399 );
1400}
1401
1402#[gpui::test]
1403async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
1404 let project = init_test_project_with_agent_panel("/empty-project", cx).await;
1405 let (multi_workspace, cx) =
1406 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1407 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1408
1409 // An empty project has only the header (no auto-created draft).
1410 assert_eq!(
1411 visible_entries_as_strings(&sidebar, cx),
1412 vec!["v [empty-project]"]
1413 );
1414
1415 // Focus sidebar — focus_in does not set a selection
1416 focus_sidebar(&sidebar, cx);
1417 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1418
1419 // First SelectNext from None starts at index 0 (header)
1420 cx.dispatch_action(SelectNext);
1421 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1422
1423 // SelectNext with only one entry stays at index 0
1424 cx.dispatch_action(SelectNext);
1425 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1426
1427 // SelectPrevious from first entry clears selection (returns to editor)
1428 cx.dispatch_action(SelectPrevious);
1429 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1430
1431 // SelectPrevious from None selects the last entry
1432 cx.dispatch_action(SelectPrevious);
1433 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1434}
1435
1436#[gpui::test]
1437async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
1438 let project = init_test_project("/my-project", cx).await;
1439 let (multi_workspace, cx) =
1440 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1441 let sidebar = setup_sidebar(&multi_workspace, cx);
1442
1443 save_n_test_threads(1, &project, cx).await;
1444 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1445 cx.run_until_parked();
1446
1447 // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
1448 focus_sidebar(&sidebar, cx);
1449 cx.dispatch_action(SelectNext);
1450 cx.dispatch_action(SelectNext);
1451 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1452
1453 // Collapse the group, which removes the thread from the list
1454 cx.dispatch_action(SelectParent);
1455 cx.run_until_parked();
1456
1457 // Selection should be clamped to the last valid index (0 = header)
1458 let selection = sidebar.read_with(cx, |s, _| s.selection);
1459 let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
1460 assert!(
1461 selection.unwrap_or(0) < entry_count,
1462 "selection {} should be within bounds (entries: {})",
1463 selection.unwrap_or(0),
1464 entry_count,
1465 );
1466}
1467
1468async fn init_test_project_with_agent_panel(
1469 worktree_path: &str,
1470 cx: &mut TestAppContext,
1471) -> Entity<project::Project> {
1472 agent_ui::test_support::init_test(cx);
1473 cx.update(|cx| {
1474 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
1475 ThreadStore::init_global(cx);
1476 ThreadMetadataStore::init_global(cx);
1477 language_model::LanguageModelRegistry::test(cx);
1478 prompt_store::init(cx);
1479 });
1480
1481 let fs = FakeFs::new(cx.executor());
1482 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1483 .await;
1484 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1485 project::Project::test(fs, [worktree_path.as_ref()], cx).await
1486}
1487
1488fn add_agent_panel(
1489 workspace: &Entity<Workspace>,
1490 cx: &mut gpui::VisualTestContext,
1491) -> Entity<AgentPanel> {
1492 workspace.update_in(cx, |workspace, window, cx| {
1493 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
1494 workspace.add_panel(panel.clone(), window, cx);
1495 panel
1496 })
1497}
1498
1499fn setup_sidebar_with_agent_panel(
1500 multi_workspace: &Entity<MultiWorkspace>,
1501 cx: &mut gpui::VisualTestContext,
1502) -> (Entity<Sidebar>, Entity<AgentPanel>) {
1503 let sidebar = setup_sidebar(multi_workspace, cx);
1504 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
1505 let panel = add_agent_panel(&workspace, cx);
1506 (sidebar, panel)
1507}
1508
1509#[gpui::test]
1510async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
1511 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1512 let (multi_workspace, cx) =
1513 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1514 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1515
1516 // Open thread A and keep it generating.
1517 let connection = StubAgentConnection::new();
1518 open_thread_with_connection(&panel, connection.clone(), cx);
1519 send_message(&panel, cx);
1520
1521 let session_id_a = active_session_id(&panel, cx);
1522 save_test_thread_metadata(&session_id_a, &project, cx).await;
1523
1524 cx.update(|_, cx| {
1525 connection.send_update(
1526 session_id_a.clone(),
1527 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
1528 cx,
1529 );
1530 });
1531 cx.run_until_parked();
1532
1533 // Open thread B (idle, default response) — thread A goes to background.
1534 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1535 acp::ContentChunk::new("Done".into()),
1536 )]);
1537 open_thread_with_connection(&panel, connection, cx);
1538 send_message(&panel, cx);
1539
1540 let session_id_b = active_session_id(&panel, cx);
1541 save_test_thread_metadata(&session_id_b, &project, cx).await;
1542
1543 cx.run_until_parked();
1544
1545 let mut entries = visible_entries_as_strings(&sidebar, cx);
1546 entries[1..].sort();
1547 assert_eq!(
1548 entries,
1549 vec![
1550 //
1551 "v [my-project]",
1552 " Hello *",
1553 " Hello * (running)",
1554 ]
1555 );
1556}
1557
1558#[gpui::test]
1559async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
1560 cx: &mut TestAppContext,
1561) {
1562 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1563 let (multi_workspace, cx) =
1564 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1565 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1566
1567 let connection = StubAgentConnection::new().with_supports_load_session(true);
1568 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1569 acp::ContentChunk::new("Done".into()),
1570 )]);
1571 open_thread_with_connection(&panel, connection, cx);
1572 send_message(&panel, cx);
1573
1574 let parent_session_id = active_session_id(&panel, cx);
1575 save_test_thread_metadata(&parent_session_id, &project, cx).await;
1576
1577 let subagent_session_id = acp::SessionId::new("subagent-session");
1578 cx.update(|_, cx| {
1579 let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
1580 parent_thread.update(cx, |thread: &mut AcpThread, cx| {
1581 thread.subagent_spawned(subagent_session_id.clone(), cx);
1582 });
1583 });
1584 cx.run_until_parked();
1585
1586 let subagent_thread = panel.read_with(cx, |panel, cx| {
1587 panel
1588 .active_conversation_view()
1589 .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id, cx))
1590 .map(|thread_view| thread_view.read(cx).thread.clone())
1591 .expect("Expected subagent thread to be loaded into the conversation")
1592 });
1593 request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
1594
1595 let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
1596 sidebar
1597 .contents
1598 .entries
1599 .iter()
1600 .find_map(|entry| match entry {
1601 ListEntry::Thread(thread)
1602 if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
1603 {
1604 Some(thread.status)
1605 }
1606 _ => None,
1607 })
1608 .expect("Expected parent thread entry in sidebar")
1609 });
1610
1611 assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
1612}
1613
1614#[gpui::test]
1615async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
1616 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
1617 let (multi_workspace, cx) =
1618 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1619 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1620
1621 // Open thread on workspace A and keep it generating.
1622 let connection_a = StubAgentConnection::new();
1623 open_thread_with_connection(&panel_a, connection_a.clone(), cx);
1624 send_message(&panel_a, cx);
1625
1626 let session_id_a = active_session_id(&panel_a, cx);
1627 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
1628
1629 cx.update(|_, cx| {
1630 connection_a.send_update(
1631 session_id_a.clone(),
1632 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
1633 cx,
1634 );
1635 });
1636 cx.run_until_parked();
1637
1638 // Add a second workspace and activate it (making workspace A the background).
1639 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
1640 let project_b = project::Project::test(fs, [], cx).await;
1641 multi_workspace.update_in(cx, |mw, window, cx| {
1642 mw.test_add_workspace(project_b, window, cx);
1643 });
1644 cx.run_until_parked();
1645
1646 // Thread A is still running; no notification yet.
1647 assert_eq!(
1648 visible_entries_as_strings(&sidebar, cx),
1649 vec![
1650 //
1651 "v [project-a]",
1652 " Hello * (running)",
1653 ]
1654 );
1655
1656 // Complete thread A's turn (transition Running → Completed).
1657 connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
1658 cx.run_until_parked();
1659
1660 // The completed background thread shows a notification indicator.
1661 assert_eq!(
1662 visible_entries_as_strings(&sidebar, cx),
1663 vec![
1664 //
1665 "v [project-a]",
1666 " Hello * (!)",
1667 ]
1668 );
1669}
1670
1671fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
1672 sidebar.update_in(cx, |sidebar, window, cx| {
1673 window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
1674 sidebar.filter_editor.update(cx, |editor, cx| {
1675 editor.set_text(query, window, cx);
1676 });
1677 });
1678 cx.run_until_parked();
1679}
1680
1681#[gpui::test]
1682async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
1683 let project = init_test_project("/my-project", cx).await;
1684 let (multi_workspace, cx) =
1685 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1686 let sidebar = setup_sidebar(&multi_workspace, cx);
1687
1688 for (id, title, hour) in [
1689 ("t-1", "Fix crash in project panel", 3),
1690 ("t-2", "Add inline diff view", 2),
1691 ("t-3", "Refactor settings module", 1),
1692 ] {
1693 save_thread_metadata(
1694 acp::SessionId::new(Arc::from(id)),
1695 Some(title.into()),
1696 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1697 None,
1698 &project,
1699 cx,
1700 );
1701 }
1702 cx.run_until_parked();
1703
1704 assert_eq!(
1705 visible_entries_as_strings(&sidebar, cx),
1706 vec![
1707 //
1708 "v [my-project]",
1709 " Fix crash in project panel",
1710 " Add inline diff view",
1711 " Refactor settings module",
1712 ]
1713 );
1714
1715 // User types "diff" in the search box — only the matching thread remains,
1716 // with its workspace header preserved for context.
1717 type_in_search(&sidebar, "diff", cx);
1718 assert_eq!(
1719 visible_entries_as_strings(&sidebar, cx),
1720 vec![
1721 //
1722 "v [my-project]",
1723 " Add inline diff view <== selected",
1724 ]
1725 );
1726
1727 // User changes query to something with no matches — list is empty.
1728 type_in_search(&sidebar, "nonexistent", cx);
1729 assert_eq!(
1730 visible_entries_as_strings(&sidebar, cx),
1731 Vec::<String>::new()
1732 );
1733}
1734
1735#[gpui::test]
1736async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
1737 // Scenario: A user remembers a thread title but not the exact casing.
1738 // Search should match case-insensitively so they can still find it.
1739 let project = init_test_project("/my-project", cx).await;
1740 let (multi_workspace, cx) =
1741 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1742 let sidebar = setup_sidebar(&multi_workspace, cx);
1743
1744 save_thread_metadata(
1745 acp::SessionId::new(Arc::from("thread-1")),
1746 Some("Fix Crash In Project Panel".into()),
1747 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1748 None,
1749 &project,
1750 cx,
1751 );
1752 cx.run_until_parked();
1753
1754 // Lowercase query matches mixed-case title.
1755 type_in_search(&sidebar, "fix crash", cx);
1756 assert_eq!(
1757 visible_entries_as_strings(&sidebar, cx),
1758 vec![
1759 //
1760 "v [my-project]",
1761 " Fix Crash In Project Panel <== selected",
1762 ]
1763 );
1764
1765 // Uppercase query also matches the same title.
1766 type_in_search(&sidebar, "FIX CRASH", cx);
1767 assert_eq!(
1768 visible_entries_as_strings(&sidebar, cx),
1769 vec![
1770 //
1771 "v [my-project]",
1772 " Fix Crash In Project Panel <== selected",
1773 ]
1774 );
1775}
1776
1777#[gpui::test]
1778async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
1779 // Scenario: A user searches, finds what they need, then presses Escape
1780 // to dismiss the filter and see the full list again.
1781 let project = init_test_project("/my-project", cx).await;
1782 let (multi_workspace, cx) =
1783 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1784 let sidebar = setup_sidebar(&multi_workspace, cx);
1785
1786 for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
1787 save_thread_metadata(
1788 acp::SessionId::new(Arc::from(id)),
1789 Some(title.into()),
1790 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1791 None,
1792 &project,
1793 cx,
1794 )
1795 }
1796 cx.run_until_parked();
1797
1798 // Confirm the full list is showing.
1799 assert_eq!(
1800 visible_entries_as_strings(&sidebar, cx),
1801 vec![
1802 //
1803 "v [my-project]",
1804 " Alpha thread",
1805 " Beta thread",
1806 ]
1807 );
1808
1809 // User types a search query to filter down.
1810 focus_sidebar(&sidebar, cx);
1811 type_in_search(&sidebar, "alpha", cx);
1812 assert_eq!(
1813 visible_entries_as_strings(&sidebar, cx),
1814 vec![
1815 //
1816 "v [my-project]",
1817 " Alpha thread <== selected",
1818 ]
1819 );
1820
1821 // User presses Escape — filter clears, full list is restored.
1822 // The selection index (1) now points at the first thread entry.
1823 cx.dispatch_action(Cancel);
1824 cx.run_until_parked();
1825 assert_eq!(
1826 visible_entries_as_strings(&sidebar, cx),
1827 vec![
1828 //
1829 "v [my-project]",
1830 " Alpha thread <== selected",
1831 " Beta thread",
1832 ]
1833 );
1834}
1835
1836#[gpui::test]
1837async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
1838 let project_a = init_test_project("/project-a", cx).await;
1839 let (multi_workspace, cx) =
1840 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1841 let sidebar = setup_sidebar(&multi_workspace, cx);
1842
1843 for (id, title, hour) in [
1844 ("a1", "Fix bug in sidebar", 2),
1845 ("a2", "Add tests for editor", 1),
1846 ] {
1847 save_thread_metadata(
1848 acp::SessionId::new(Arc::from(id)),
1849 Some(title.into()),
1850 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1851 None,
1852 &project_a,
1853 cx,
1854 )
1855 }
1856
1857 // Add a second workspace.
1858 multi_workspace.update_in(cx, |mw, window, cx| {
1859 mw.create_test_workspace(window, cx).detach();
1860 });
1861 cx.run_until_parked();
1862
1863 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1864 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1865 });
1866
1867 for (id, title, hour) in [
1868 ("b1", "Refactor sidebar layout", 3),
1869 ("b2", "Fix typo in README", 1),
1870 ] {
1871 save_thread_metadata(
1872 acp::SessionId::new(Arc::from(id)),
1873 Some(title.into()),
1874 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1875 None,
1876 &project_b,
1877 cx,
1878 )
1879 }
1880 cx.run_until_parked();
1881
1882 assert_eq!(
1883 visible_entries_as_strings(&sidebar, cx),
1884 vec![
1885 //
1886 "v [project-a]",
1887 " Fix bug in sidebar",
1888 " Add tests for editor",
1889 ]
1890 );
1891
1892 // "sidebar" matches a thread in each workspace — both headers stay visible.
1893 type_in_search(&sidebar, "sidebar", cx);
1894 assert_eq!(
1895 visible_entries_as_strings(&sidebar, cx),
1896 vec![
1897 //
1898 "v [project-a]",
1899 " Fix bug in sidebar <== selected",
1900 ]
1901 );
1902
1903 // "typo" only matches in the second workspace — the first header disappears.
1904 type_in_search(&sidebar, "typo", cx);
1905 assert_eq!(
1906 visible_entries_as_strings(&sidebar, cx),
1907 Vec::<String>::new()
1908 );
1909
1910 // "project-a" matches the first workspace name — the header appears
1911 // with all child threads included.
1912 type_in_search(&sidebar, "project-a", cx);
1913 assert_eq!(
1914 visible_entries_as_strings(&sidebar, cx),
1915 vec![
1916 //
1917 "v [project-a]",
1918 " Fix bug in sidebar <== selected",
1919 " Add tests for editor",
1920 ]
1921 );
1922}
1923
1924#[gpui::test]
1925async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
1926 let project_a = init_test_project("/alpha-project", cx).await;
1927 let (multi_workspace, cx) =
1928 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1929 let sidebar = setup_sidebar(&multi_workspace, cx);
1930
1931 for (id, title, hour) in [
1932 ("a1", "Fix bug in sidebar", 2),
1933 ("a2", "Add tests for editor", 1),
1934 ] {
1935 save_thread_metadata(
1936 acp::SessionId::new(Arc::from(id)),
1937 Some(title.into()),
1938 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1939 None,
1940 &project_a,
1941 cx,
1942 )
1943 }
1944
1945 // Add a second workspace.
1946 multi_workspace.update_in(cx, |mw, window, cx| {
1947 mw.create_test_workspace(window, cx).detach();
1948 });
1949 cx.run_until_parked();
1950
1951 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1952 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1953 });
1954
1955 for (id, title, hour) in [
1956 ("b1", "Refactor sidebar layout", 3),
1957 ("b2", "Fix typo in README", 1),
1958 ] {
1959 save_thread_metadata(
1960 acp::SessionId::new(Arc::from(id)),
1961 Some(title.into()),
1962 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1963 None,
1964 &project_b,
1965 cx,
1966 )
1967 }
1968 cx.run_until_parked();
1969
1970 // "alpha" matches the workspace name "alpha-project" but no thread titles.
1971 // The workspace header should appear with all child threads included.
1972 type_in_search(&sidebar, "alpha", cx);
1973 assert_eq!(
1974 visible_entries_as_strings(&sidebar, cx),
1975 vec![
1976 //
1977 "v [alpha-project]",
1978 " Fix bug in sidebar <== selected",
1979 " Add tests for editor",
1980 ]
1981 );
1982
1983 // "sidebar" matches thread titles in both workspaces but not workspace names.
1984 // Both headers appear with their matching threads.
1985 type_in_search(&sidebar, "sidebar", cx);
1986 assert_eq!(
1987 visible_entries_as_strings(&sidebar, cx),
1988 vec![
1989 //
1990 "v [alpha-project]",
1991 " Fix bug in sidebar <== selected",
1992 ]
1993 );
1994
1995 // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
1996 // doesn't match) — but does not match either workspace name or any thread.
1997 // Actually let's test something simpler: a query that matches both a workspace
1998 // name AND some threads in that workspace. Matching threads should still appear.
1999 type_in_search(&sidebar, "fix", cx);
2000 assert_eq!(
2001 visible_entries_as_strings(&sidebar, cx),
2002 vec![
2003 //
2004 "v [alpha-project]",
2005 " Fix bug in sidebar <== selected",
2006 ]
2007 );
2008
2009 // A query that matches a workspace name AND a thread in that same workspace.
2010 // Both the header (highlighted) and all child threads should appear.
2011 type_in_search(&sidebar, "alpha", cx);
2012 assert_eq!(
2013 visible_entries_as_strings(&sidebar, cx),
2014 vec![
2015 //
2016 "v [alpha-project]",
2017 " Fix bug in sidebar <== selected",
2018 " Add tests for editor",
2019 ]
2020 );
2021
2022 // Now search for something that matches only a workspace name when there
2023 // are also threads with matching titles — the non-matching workspace's
2024 // threads should still appear if their titles match.
2025 type_in_search(&sidebar, "alp", cx);
2026 assert_eq!(
2027 visible_entries_as_strings(&sidebar, cx),
2028 vec![
2029 //
2030 "v [alpha-project]",
2031 " Fix bug in sidebar <== selected",
2032 " Add tests for editor",
2033 ]
2034 );
2035}
2036
2037#[gpui::test]
2038async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
2039 let project = init_test_project("/my-project", cx).await;
2040 let (multi_workspace, cx) =
2041 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2042 let sidebar = setup_sidebar(&multi_workspace, cx);
2043
2044 // Create 8 threads. The oldest one has a unique name and will be
2045 // behind View More (only 5 shown by default).
2046 for i in 0..8u32 {
2047 let title = if i == 0 {
2048 "Hidden gem thread".to_string()
2049 } else {
2050 format!("Thread {}", i + 1)
2051 };
2052 save_thread_metadata(
2053 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
2054 Some(title.into()),
2055 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
2056 None,
2057 &project,
2058 cx,
2059 )
2060 }
2061 cx.run_until_parked();
2062
2063 // Confirm the thread is not visible and View More is shown.
2064 let entries = visible_entries_as_strings(&sidebar, cx);
2065 assert!(
2066 entries.iter().any(|e| e.contains("View More")),
2067 "should have View More button"
2068 );
2069 assert!(
2070 !entries.iter().any(|e| e.contains("Hidden gem")),
2071 "Hidden gem should be behind View More"
2072 );
2073
2074 // User searches for the hidden thread — it appears, and View More is gone.
2075 type_in_search(&sidebar, "hidden gem", cx);
2076 let filtered = visible_entries_as_strings(&sidebar, cx);
2077 assert_eq!(
2078 filtered,
2079 vec![
2080 //
2081 "v [my-project]",
2082 " Hidden gem thread <== selected",
2083 ]
2084 );
2085 assert!(
2086 !filtered.iter().any(|e| e.contains("View More")),
2087 "View More should not appear when filtering"
2088 );
2089}
2090
2091#[gpui::test]
2092async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
2093 let project = init_test_project("/my-project", cx).await;
2094 let (multi_workspace, cx) =
2095 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2096 let sidebar = setup_sidebar(&multi_workspace, cx);
2097
2098 save_thread_metadata(
2099 acp::SessionId::new(Arc::from("thread-1")),
2100 Some("Important thread".into()),
2101 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2102 None,
2103 &project,
2104 cx,
2105 );
2106 cx.run_until_parked();
2107
2108 // User focuses the sidebar and collapses the group using keyboard:
2109 // manually select the header, then press SelectParent to collapse.
2110 focus_sidebar(&sidebar, cx);
2111 sidebar.update_in(cx, |sidebar, _window, _cx| {
2112 sidebar.selection = Some(0);
2113 });
2114 cx.dispatch_action(SelectParent);
2115 cx.run_until_parked();
2116
2117 assert_eq!(
2118 visible_entries_as_strings(&sidebar, cx),
2119 vec![
2120 //
2121 "> [my-project] <== selected",
2122 ]
2123 );
2124
2125 // User types a search — the thread appears even though its group is collapsed.
2126 type_in_search(&sidebar, "important", cx);
2127 assert_eq!(
2128 visible_entries_as_strings(&sidebar, cx),
2129 vec![
2130 //
2131 "> [my-project]",
2132 " Important thread <== selected",
2133 ]
2134 );
2135}
2136
2137#[gpui::test]
2138async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
2139 let project = init_test_project("/my-project", cx).await;
2140 let (multi_workspace, cx) =
2141 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2142 let sidebar = setup_sidebar(&multi_workspace, cx);
2143
2144 for (id, title, hour) in [
2145 ("t-1", "Fix crash in panel", 3),
2146 ("t-2", "Fix lint warnings", 2),
2147 ("t-3", "Add new feature", 1),
2148 ] {
2149 save_thread_metadata(
2150 acp::SessionId::new(Arc::from(id)),
2151 Some(title.into()),
2152 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2153 None,
2154 &project,
2155 cx,
2156 )
2157 }
2158 cx.run_until_parked();
2159
2160 focus_sidebar(&sidebar, cx);
2161
2162 // User types "fix" — two threads match.
2163 type_in_search(&sidebar, "fix", cx);
2164 assert_eq!(
2165 visible_entries_as_strings(&sidebar, cx),
2166 vec![
2167 //
2168 "v [my-project]",
2169 " Fix crash in panel <== selected",
2170 " Fix lint warnings",
2171 ]
2172 );
2173
2174 // Selection starts on the first matching thread. User presses
2175 // SelectNext to move to the second match.
2176 cx.dispatch_action(SelectNext);
2177 assert_eq!(
2178 visible_entries_as_strings(&sidebar, cx),
2179 vec![
2180 //
2181 "v [my-project]",
2182 " Fix crash in panel",
2183 " Fix lint warnings <== selected",
2184 ]
2185 );
2186
2187 // User can also jump back with SelectPrevious.
2188 cx.dispatch_action(SelectPrevious);
2189 assert_eq!(
2190 visible_entries_as_strings(&sidebar, cx),
2191 vec![
2192 //
2193 "v [my-project]",
2194 " Fix crash in panel <== selected",
2195 " Fix lint warnings",
2196 ]
2197 );
2198}
2199
2200#[gpui::test]
2201async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
2202 let project = init_test_project("/my-project", cx).await;
2203 let (multi_workspace, cx) =
2204 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2205 let sidebar = setup_sidebar(&multi_workspace, cx);
2206
2207 multi_workspace.update_in(cx, |mw, window, cx| {
2208 mw.create_test_workspace(window, cx).detach();
2209 });
2210 cx.run_until_parked();
2211
2212 let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
2213 (
2214 mw.workspaces().next().unwrap().clone(),
2215 mw.workspaces().nth(1).unwrap().clone(),
2216 )
2217 });
2218
2219 save_thread_metadata(
2220 acp::SessionId::new(Arc::from("hist-1")),
2221 Some("Historical Thread".into()),
2222 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2223 None,
2224 &project,
2225 cx,
2226 );
2227 cx.run_until_parked();
2228 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2229 cx.run_until_parked();
2230
2231 assert_eq!(
2232 visible_entries_as_strings(&sidebar, cx),
2233 vec![
2234 //
2235 "v [my-project]",
2236 " Historical Thread",
2237 ]
2238 );
2239
2240 // Switch to workspace 1 so we can verify the confirm switches back.
2241 multi_workspace.update_in(cx, |mw, window, cx| {
2242 let workspace = mw.workspaces().nth(1).unwrap().clone();
2243 mw.activate(workspace, window, cx);
2244 });
2245 cx.run_until_parked();
2246 assert_eq!(
2247 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2248 workspace_1
2249 );
2250
2251 // Confirm on the historical (non-live) thread at index 1.
2252 // Before a previous fix, the workspace field was Option<usize> and
2253 // historical threads had None, so activate_thread early-returned
2254 // without switching the workspace.
2255 sidebar.update_in(cx, |sidebar, window, cx| {
2256 sidebar.selection = Some(1);
2257 sidebar.confirm(&Confirm, window, cx);
2258 });
2259 cx.run_until_parked();
2260
2261 assert_eq!(
2262 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2263 workspace_0
2264 );
2265}
2266
2267#[gpui::test]
2268async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
2269 cx: &mut TestAppContext,
2270) {
2271 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2272 let (multi_workspace, cx) =
2273 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2274 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2275
2276 let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
2277 let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
2278 save_thread_metadata(
2279 newer_session_id,
2280 Some("Newer Historical Thread".into()),
2281 newer_timestamp,
2282 Some(newer_timestamp),
2283 &project,
2284 cx,
2285 );
2286
2287 let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
2288 let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
2289 save_thread_metadata(
2290 older_session_id.clone(),
2291 Some("Older Historical Thread".into()),
2292 older_timestamp,
2293 Some(older_timestamp),
2294 &project,
2295 cx,
2296 );
2297
2298 cx.run_until_parked();
2299 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2300 cx.run_until_parked();
2301
2302 let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2303 .into_iter()
2304 .filter(|entry| entry.contains("Historical Thread"))
2305 .collect();
2306 assert_eq!(
2307 historical_entries_before,
2308 vec![
2309 " Newer Historical Thread".to_string(),
2310 " Older Historical Thread".to_string(),
2311 ],
2312 "expected the sidebar to sort historical threads by their saved timestamp before activation"
2313 );
2314
2315 let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
2316 sidebar
2317 .contents
2318 .entries
2319 .iter()
2320 .position(|entry| {
2321 matches!(entry, ListEntry::Thread(thread)
2322 if thread.metadata.session_id.as_ref() == Some(&older_session_id))
2323 })
2324 .expect("expected Older Historical Thread to appear in the sidebar")
2325 });
2326
2327 sidebar.update_in(cx, |sidebar, window, cx| {
2328 sidebar.selection = Some(older_entry_index);
2329 sidebar.confirm(&Confirm, window, cx);
2330 });
2331 cx.run_until_parked();
2332
2333 let older_metadata = cx.update(|_, cx| {
2334 ThreadMetadataStore::global(cx)
2335 .read(cx)
2336 .entry_by_session(&older_session_id)
2337 .cloned()
2338 .expect("expected metadata for Older Historical Thread after activation")
2339 });
2340 assert_eq!(
2341 older_metadata.created_at,
2342 Some(older_timestamp),
2343 "activating a historical thread should not rewrite its saved created_at timestamp"
2344 );
2345
2346 let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2347 .into_iter()
2348 .filter(|entry| entry.contains("Historical Thread"))
2349 .collect();
2350 assert_eq!(
2351 historical_entries_after,
2352 vec![
2353 " Newer Historical Thread".to_string(),
2354 " Older Historical Thread <== selected".to_string(),
2355 ],
2356 "activating an older historical thread should not reorder it ahead of a newer historical thread"
2357 );
2358}
2359
2360#[gpui::test]
2361async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
2362 cx: &mut TestAppContext,
2363) {
2364 use workspace::ProjectGroup;
2365
2366 agent_ui::test_support::init_test(cx);
2367 cx.update(|cx| {
2368 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
2369 ThreadStore::init_global(cx);
2370 ThreadMetadataStore::init_global(cx);
2371 language_model::LanguageModelRegistry::test(cx);
2372 prompt_store::init(cx);
2373 });
2374
2375 let fs = FakeFs::new(cx.executor());
2376 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
2377 .await;
2378 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
2379 .await;
2380 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2381
2382 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
2383 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
2384
2385 let (multi_workspace, cx) =
2386 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2387 let sidebar = setup_sidebar(&multi_workspace, cx);
2388
2389 let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
2390 multi_workspace.update(cx, |mw, _cx| {
2391 mw.test_add_project_group(ProjectGroup {
2392 key: project_b_key.clone(),
2393 workspaces: Vec::new(),
2394 expanded: true,
2395 visible_thread_count: None,
2396 });
2397 });
2398
2399 let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
2400 save_thread_metadata(
2401 session_id.clone(),
2402 Some("Historical Thread in New Group".into()),
2403 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2404 None,
2405 &project_b,
2406 cx,
2407 );
2408 cx.run_until_parked();
2409
2410 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2411 cx.run_until_parked();
2412
2413 let entries_before = visible_entries_as_strings(&sidebar, cx);
2414 assert_eq!(
2415 entries_before,
2416 vec![
2417 "v [project-a]",
2418 "v [project-b]",
2419 " Historical Thread in New Group",
2420 ],
2421 "expected the closed project group to show the historical thread before first open"
2422 );
2423
2424 assert_eq!(
2425 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2426 1,
2427 "should start without an open workspace for the new project group"
2428 );
2429
2430 sidebar.update_in(cx, |sidebar, window, cx| {
2431 sidebar.selection = Some(2);
2432 sidebar.confirm(&Confirm, window, cx);
2433 });
2434
2435 cx.run_until_parked();
2436
2437 assert_eq!(
2438 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2439 2,
2440 "confirming the historical thread should open a workspace for the new project group"
2441 );
2442
2443 let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
2444 mw.workspaces()
2445 .find(|workspace| {
2446 PathList::new(&workspace.read(cx).root_paths(cx))
2447 == project_b_key.path_list().clone()
2448 })
2449 .cloned()
2450 .expect("expected workspace for project-b after opening the historical thread")
2451 });
2452
2453 assert_eq!(
2454 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2455 workspace_b,
2456 "opening the historical thread should activate the new project's workspace"
2457 );
2458
2459 let panel = workspace_b.read_with(cx, |workspace, cx| {
2460 workspace
2461 .panel::<AgentPanel>(cx)
2462 .expect("expected first-open activation to bootstrap the agent panel")
2463 });
2464
2465 let expected_thread_id = cx.update(|_, cx| {
2466 ThreadMetadataStore::global(cx)
2467 .read(cx)
2468 .entries()
2469 .find(|e| e.session_id.as_ref() == Some(&session_id))
2470 .map(|e| e.thread_id)
2471 .expect("metadata should still map session id to thread id")
2472 });
2473
2474 assert_eq!(
2475 panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
2476 Some(expected_thread_id),
2477 "expected the agent panel to activate the real historical thread rather than a draft"
2478 );
2479
2480 let entries_after = visible_entries_as_strings(&sidebar, cx);
2481 let matching_rows: Vec<_> = entries_after
2482 .iter()
2483 .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
2484 .cloned()
2485 .collect();
2486 assert_eq!(
2487 matching_rows.len(),
2488 1,
2489 "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
2490 );
2491 assert!(
2492 matching_rows[0].contains("Historical Thread in New Group"),
2493 "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
2494 );
2495 assert!(
2496 !matching_rows[0].contains("Draft"),
2497 "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
2498 );
2499}
2500
2501#[gpui::test]
2502async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
2503 let project = init_test_project("/my-project", cx).await;
2504 let (multi_workspace, cx) =
2505 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2506 let sidebar = setup_sidebar(&multi_workspace, cx);
2507
2508 save_thread_metadata(
2509 acp::SessionId::new(Arc::from("t-1")),
2510 Some("Thread A".into()),
2511 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2512 None,
2513 &project,
2514 cx,
2515 );
2516
2517 save_thread_metadata(
2518 acp::SessionId::new(Arc::from("t-2")),
2519 Some("Thread B".into()),
2520 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2521 None,
2522 &project,
2523 cx,
2524 );
2525
2526 cx.run_until_parked();
2527 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2528 cx.run_until_parked();
2529
2530 assert_eq!(
2531 visible_entries_as_strings(&sidebar, cx),
2532 vec![
2533 //
2534 "v [my-project]",
2535 " Thread A",
2536 " Thread B",
2537 ]
2538 );
2539
2540 // Keyboard confirm preserves selection.
2541 sidebar.update_in(cx, |sidebar, window, cx| {
2542 sidebar.selection = Some(1);
2543 sidebar.confirm(&Confirm, window, cx);
2544 });
2545 assert_eq!(
2546 sidebar.read_with(cx, |sidebar, _| sidebar.selection),
2547 Some(1)
2548 );
2549
2550 // Click handlers clear selection to None so no highlight lingers
2551 // after a click regardless of focus state. The hover style provides
2552 // visual feedback during mouse interaction instead.
2553 sidebar.update_in(cx, |sidebar, window, cx| {
2554 sidebar.selection = None;
2555 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2556 let project_group_key = ProjectGroupKey::new(None, path_list);
2557 sidebar.toggle_collapse(&project_group_key, window, cx);
2558 });
2559 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2560
2561 // When the user tabs back into the sidebar, focus_in no longer
2562 // restores selection — it stays None.
2563 sidebar.update_in(cx, |sidebar, window, cx| {
2564 sidebar.focus_in(window, cx);
2565 });
2566 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2567}
2568
2569#[gpui::test]
2570async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
2571 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2572 let (multi_workspace, cx) =
2573 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2574 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2575
2576 let connection = StubAgentConnection::new();
2577 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2578 acp::ContentChunk::new("Hi there!".into()),
2579 )]);
2580 open_thread_with_connection(&panel, connection, cx);
2581 send_message(&panel, cx);
2582
2583 let session_id = active_session_id(&panel, cx);
2584 save_test_thread_metadata(&session_id, &project, cx).await;
2585 cx.run_until_parked();
2586
2587 assert_eq!(
2588 visible_entries_as_strings(&sidebar, cx),
2589 vec![
2590 //
2591 "v [my-project]",
2592 " Hello *",
2593 ]
2594 );
2595
2596 // Simulate the agent generating a title. The notification chain is:
2597 // AcpThread::set_title emits TitleUpdated →
2598 // ConnectionView::handle_thread_event calls cx.notify() →
2599 // AgentPanel observer fires and emits AgentPanelEvent →
2600 // Sidebar subscription calls update_entries / rebuild_contents.
2601 //
2602 // Before the fix, handle_thread_event did NOT call cx.notify() for
2603 // TitleUpdated, so the AgentPanel observer never fired and the
2604 // sidebar kept showing the old title.
2605 let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2606 thread.update(cx, |thread, cx| {
2607 thread
2608 .set_title("Friendly Greeting with AI".into(), cx)
2609 .detach();
2610 });
2611 cx.run_until_parked();
2612
2613 assert_eq!(
2614 visible_entries_as_strings(&sidebar, cx),
2615 vec![
2616 //
2617 "v [my-project]",
2618 " Friendly Greeting with AI *",
2619 ]
2620 );
2621}
2622
2623#[gpui::test]
2624async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
2625 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2626 let (multi_workspace, cx) =
2627 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2628 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2629
2630 // Save a thread so it appears in the list.
2631 let connection_a = StubAgentConnection::new();
2632 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2633 acp::ContentChunk::new("Done".into()),
2634 )]);
2635 open_thread_with_connection(&panel_a, connection_a, cx);
2636 send_message(&panel_a, cx);
2637 let session_id_a = active_session_id(&panel_a, cx);
2638 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
2639
2640 // Add a second workspace with its own agent panel.
2641 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2642 fs.as_fake()
2643 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2644 .await;
2645 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2646 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2647 mw.test_add_workspace(project_b.clone(), window, cx)
2648 });
2649 let panel_b = add_agent_panel(&workspace_b, cx);
2650 cx.run_until_parked();
2651
2652 let workspace_a =
2653 multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2654
2655 // ── 1. Initial state: focused thread derived from active panel ─────
2656 sidebar.read_with(cx, |sidebar, _cx| {
2657 assert_active_thread(
2658 sidebar,
2659 &session_id_a,
2660 "The active panel's thread should be focused on startup",
2661 );
2662 });
2663
2664 let thread_metadata_a = cx.update(|_window, cx| {
2665 ThreadMetadataStore::global(cx)
2666 .read(cx)
2667 .entry_by_session(&session_id_a)
2668 .cloned()
2669 .expect("session_id_a should exist in metadata store")
2670 });
2671 sidebar.update_in(cx, |sidebar, window, cx| {
2672 sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
2673 });
2674 cx.run_until_parked();
2675
2676 sidebar.read_with(cx, |sidebar, _cx| {
2677 assert_active_thread(
2678 sidebar,
2679 &session_id_a,
2680 "After clicking a thread, it should be the focused thread",
2681 );
2682 assert!(
2683 has_thread_entry(sidebar, &session_id_a),
2684 "The clicked thread should be present in the entries"
2685 );
2686 });
2687
2688 workspace_a.read_with(cx, |workspace, cx| {
2689 assert!(
2690 workspace.panel::<AgentPanel>(cx).is_some(),
2691 "Agent panel should exist"
2692 );
2693 let dock = workspace.left_dock().read(cx);
2694 assert!(
2695 dock.is_open(),
2696 "Clicking a thread should open the agent panel dock"
2697 );
2698 });
2699
2700 let connection_b = StubAgentConnection::new();
2701 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2702 acp::ContentChunk::new("Thread B".into()),
2703 )]);
2704 open_thread_with_connection(&panel_b, connection_b, cx);
2705 send_message(&panel_b, cx);
2706 let session_id_b = active_session_id(&panel_b, cx);
2707 save_test_thread_metadata(&session_id_b, &project_b, cx).await;
2708 cx.run_until_parked();
2709
2710 // Workspace A is currently active. Click a thread in workspace B,
2711 // which also triggers a workspace switch.
2712 let thread_metadata_b = cx.update(|_window, cx| {
2713 ThreadMetadataStore::global(cx)
2714 .read(cx)
2715 .entry_by_session(&session_id_b)
2716 .cloned()
2717 .expect("session_id_b should exist in metadata store")
2718 });
2719 sidebar.update_in(cx, |sidebar, window, cx| {
2720 sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
2721 });
2722 cx.run_until_parked();
2723
2724 sidebar.read_with(cx, |sidebar, _cx| {
2725 assert_active_thread(
2726 sidebar,
2727 &session_id_b,
2728 "Clicking a thread in another workspace should focus that thread",
2729 );
2730 assert!(
2731 has_thread_entry(sidebar, &session_id_b),
2732 "The cross-workspace thread should be present in the entries"
2733 );
2734 });
2735
2736 multi_workspace.update_in(cx, |mw, window, cx| {
2737 let workspace = mw.workspaces().next().unwrap().clone();
2738 mw.activate(workspace, window, cx);
2739 });
2740 cx.run_until_parked();
2741
2742 sidebar.read_with(cx, |sidebar, _cx| {
2743 assert_active_thread(
2744 sidebar,
2745 &session_id_a,
2746 "Switching workspace should seed focused_thread from the new active panel",
2747 );
2748 assert!(
2749 has_thread_entry(sidebar, &session_id_a),
2750 "The seeded thread should be present in the entries"
2751 );
2752 });
2753
2754 let connection_b2 = StubAgentConnection::new();
2755 connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2756 acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
2757 )]);
2758 open_thread_with_connection(&panel_b, connection_b2, cx);
2759 send_message(&panel_b, cx);
2760 let session_id_b2 = active_session_id(&panel_b, cx);
2761 save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
2762 cx.run_until_parked();
2763
2764 // Panel B is not the active workspace's panel (workspace A is
2765 // active), so opening a thread there should not change focused_thread.
2766 // This prevents running threads in background workspaces from causing
2767 // the selection highlight to jump around.
2768 sidebar.read_with(cx, |sidebar, _cx| {
2769 assert_active_thread(
2770 sidebar,
2771 &session_id_a,
2772 "Opening a thread in a non-active panel should not change focused_thread",
2773 );
2774 });
2775
2776 workspace_b.update_in(cx, |workspace, window, cx| {
2777 workspace.focus_handle(cx).focus(window, cx);
2778 });
2779 cx.run_until_parked();
2780
2781 sidebar.read_with(cx, |sidebar, _cx| {
2782 assert_active_thread(
2783 sidebar,
2784 &session_id_a,
2785 "Defocusing the sidebar should not change focused_thread",
2786 );
2787 });
2788
2789 // Switching workspaces via the multi_workspace (simulates clicking
2790 // a workspace header) should clear focused_thread.
2791 multi_workspace.update_in(cx, |mw, window, cx| {
2792 let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
2793 if let Some(workspace) = workspace {
2794 mw.activate(workspace, window, cx);
2795 }
2796 });
2797 cx.run_until_parked();
2798
2799 sidebar.read_with(cx, |sidebar, _cx| {
2800 assert_active_thread(
2801 sidebar,
2802 &session_id_b2,
2803 "Switching workspace should seed focused_thread from the new active panel",
2804 );
2805 assert!(
2806 has_thread_entry(sidebar, &session_id_b2),
2807 "The seeded thread should be present in the entries"
2808 );
2809 });
2810
2811 // ── 8. Focusing the agent panel thread keeps focused_thread ────
2812 // Workspace B still has session_id_b2 loaded in the agent panel.
2813 // Clicking into the thread (simulated by focusing its view) should
2814 // keep focused_thread since it was already seeded on workspace switch.
2815 panel_b.update_in(cx, |panel, window, cx| {
2816 if let Some(thread_view) = panel.active_conversation_view() {
2817 thread_view.read(cx).focus_handle(cx).focus(window, cx);
2818 }
2819 });
2820 cx.run_until_parked();
2821
2822 sidebar.read_with(cx, |sidebar, _cx| {
2823 assert_active_thread(
2824 sidebar,
2825 &session_id_b2,
2826 "Focusing the agent panel thread should set focused_thread",
2827 );
2828 assert!(
2829 has_thread_entry(sidebar, &session_id_b2),
2830 "The focused thread should be present in the entries"
2831 );
2832 });
2833}
2834
2835#[gpui::test]
2836async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
2837 let project = init_test_project_with_agent_panel("/project-a", cx).await;
2838 let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
2839 let (multi_workspace, cx) =
2840 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2841 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2842
2843 // Start a thread and send a message so it has history.
2844 let connection = StubAgentConnection::new();
2845 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2846 acp::ContentChunk::new("Done".into()),
2847 )]);
2848 open_thread_with_connection(&panel, connection, cx);
2849 send_message(&panel, cx);
2850 let session_id = active_session_id(&panel, cx);
2851 save_test_thread_metadata(&session_id, &project, cx).await;
2852 cx.run_until_parked();
2853
2854 // Verify the thread appears in the sidebar.
2855 assert_eq!(
2856 visible_entries_as_strings(&sidebar, cx),
2857 vec![
2858 //
2859 "v [project-a]",
2860 " Hello *",
2861 ]
2862 );
2863
2864 // The "New Thread" button should NOT be in "active/draft" state
2865 // because the panel has a thread with messages.
2866 sidebar.read_with(cx, |sidebar, _cx| {
2867 assert!(
2868 matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
2869 "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
2870 sidebar.active_entry,
2871 );
2872 });
2873
2874 // Now add a second folder to the workspace, changing the path_list.
2875 fs.as_fake()
2876 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2877 .await;
2878 project
2879 .update(cx, |project, cx| {
2880 project.find_or_create_worktree("/project-b", true, cx)
2881 })
2882 .await
2883 .expect("should add worktree");
2884 cx.run_until_parked();
2885
2886 // The workspace path_list is now [project-a, project-b]. The active
2887 // thread's metadata was re-saved with the new paths by the agent panel's
2888 // project subscription. The old [project-a] key is replaced by the new
2889 // key since no other workspace claims it.
2890 let entries = visible_entries_as_strings(&sidebar, cx);
2891 // After adding a worktree, the thread migrates to the new group key.
2892 // A reconciliation draft may appear during the transition.
2893 assert!(
2894 entries.contains(&" Hello *".to_string()),
2895 "thread should still be present after adding folder: {entries:?}"
2896 );
2897 assert_eq!(entries[0], "v [project-a, project-b]");
2898
2899 // The "New Thread" button must still be clickable (not stuck in
2900 // "active/draft" state). Verify that `active_thread_is_draft` is
2901 // false — the panel still has the old thread with messages.
2902 sidebar.read_with(cx, |sidebar, _cx| {
2903 assert!(
2904 matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
2905 "After adding a folder the panel still has a thread with messages, \
2906 so active_entry should be Thread, got {:?}",
2907 sidebar.active_entry,
2908 );
2909 });
2910
2911 // Actually click "New Thread" by calling create_new_thread and
2912 // verify a new draft is created.
2913 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2914 sidebar.update_in(cx, |sidebar, window, cx| {
2915 sidebar.create_new_thread(&workspace, window, cx);
2916 });
2917 cx.run_until_parked();
2918
2919 // After creating a new thread, the panel should now be in draft
2920 // state (no messages on the new thread).
2921 sidebar.read_with(cx, |sidebar, _cx| {
2922 assert_active_draft(
2923 sidebar,
2924 &workspace,
2925 "After creating a new thread active_entry should be Draft",
2926 );
2927 });
2928}
2929#[gpui::test]
2930async fn test_draft_title_updates_from_editor_text(cx: &mut TestAppContext) {
2931 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2932 let (multi_workspace, cx) =
2933 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2934 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2935
2936 // Create a new thread (activates the draft as base view and connects).
2937 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2938 let panel = workspace.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
2939 let connection = StubAgentConnection::new();
2940 open_thread_with_connection(&panel, connection, cx);
2941 cx.run_until_parked();
2942
2943 // Type into the draft's message editor.
2944 let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
2945 let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
2946 message_editor.update_in(cx, |editor, window, cx| {
2947 editor.set_text("Fix the login bug", window, cx);
2948 });
2949 cx.run_until_parked();
2950
2951 // The sidebar draft title should now reflect the editor text.
2952 let draft_title = sidebar.read_with(cx, |sidebar, _cx| {
2953 sidebar
2954 .contents
2955 .entries
2956 .iter()
2957 .find_map(|entry| match entry {
2958 ListEntry::Thread(thread) if thread.is_draft => {
2959 Some(thread.metadata.display_title())
2960 }
2961 _ => None,
2962 })
2963 .expect("should still have a draft entry")
2964 });
2965 assert_eq!(
2966 draft_title.as_ref(),
2967 "Fix the login bug",
2968 "draft title should update to match editor text"
2969 );
2970}
2971
2972#[gpui::test]
2973async fn test_draft_title_updates_across_two_groups(cx: &mut TestAppContext) {
2974 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2975 let (multi_workspace, cx) =
2976 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2977 let (sidebar, _panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2978
2979 // Add a second project group.
2980 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2981 fs.as_fake()
2982 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2983 .await;
2984 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2985 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2986 mw.test_add_workspace(project_b.clone(), window, cx)
2987 });
2988 let panel_b = add_agent_panel(&workspace_b, cx);
2989 cx.run_until_parked();
2990
2991 // Open a thread in each group's panel to get Connected state.
2992 let workspace_a =
2993 multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2994 let panel_a = workspace_a.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
2995
2996 let connection_a = StubAgentConnection::new();
2997 open_thread_with_connection(&panel_a, connection_a, cx);
2998 cx.run_until_parked();
2999
3000 let connection_b = StubAgentConnection::new();
3001 open_thread_with_connection(&panel_b, connection_b, cx);
3002 cx.run_until_parked();
3003
3004 // Type into group A's draft editor.
3005 let thread_view_a = panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
3006 let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone());
3007 editor_a.update_in(cx, |editor, window, cx| {
3008 editor.set_text("Fix the login bug", window, cx);
3009 });
3010 cx.run_until_parked();
3011
3012 // Type into group B's draft editor.
3013 let thread_view_b = panel_b.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
3014 let editor_b = thread_view_b.read_with(cx, |view, _cx| view.message_editor.clone());
3015 editor_b.update_in(cx, |editor, window, cx| {
3016 editor.set_text("Refactor the database", window, cx);
3017 });
3018 cx.run_until_parked();
3019
3020 // Both draft titles should reflect their respective editor text.
3021 let draft_titles: Vec<SharedString> = sidebar.read_with(cx, |sidebar, _cx| {
3022 sidebar
3023 .contents
3024 .entries
3025 .iter()
3026 .filter_map(|entry| match entry {
3027 ListEntry::Thread(thread) if thread.is_draft => {
3028 Some(thread.metadata.display_title())
3029 }
3030 _ => None,
3031 })
3032 .collect()
3033 });
3034 assert_eq!(draft_titles.len(), 2, "should still have two drafts");
3035 assert!(
3036 draft_titles.contains(&SharedString::from("Fix the login bug")),
3037 "group A draft should show editor text, got: {:?}",
3038 draft_titles
3039 );
3040 assert!(
3041 draft_titles.contains(&SharedString::from("Refactor the database")),
3042 "group B draft should show editor text, got: {:?}",
3043 draft_titles
3044 );
3045}
3046
3047#[gpui::test]
3048async fn test_draft_title_survives_folder_addition(cx: &mut TestAppContext) {
3049 // When a folder is added to the project, the group key changes.
3050 // The draft's editor observation should still work and the title
3051 // should update when the user types.
3052 init_test(cx);
3053 let fs = FakeFs::new(cx.executor());
3054 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3055 .await;
3056 fs.insert_tree("/project-b", serde_json::json!({ "lib": {} }))
3057 .await;
3058 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3059
3060 let project = project::Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
3061 let (multi_workspace, cx) =
3062 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3063 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3064
3065 // Create a thread with a connection (has a session_id, considered
3066 // a draft by the panel until messages are sent).
3067 let connection = StubAgentConnection::new();
3068 open_thread_with_connection(&panel, connection, cx);
3069 cx.run_until_parked();
3070
3071 // Type into the editor.
3072 let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
3073 let editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
3074 editor.update_in(cx, |editor, window, cx| {
3075 editor.set_text("Initial text", window, cx);
3076 });
3077 let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
3078 cx.run_until_parked();
3079
3080 // The thread without a title should show the editor text via
3081 // the draft title override.
3082 sidebar.read_with(cx, |sidebar, _cx| {
3083 let thread = sidebar
3084 .contents
3085 .entries
3086 .iter()
3087 .find_map(|entry| match entry {
3088 ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
3089 _ => None,
3090 });
3091 assert_eq!(
3092 thread.and_then(|t| t.metadata.title.as_ref().map(|s| s.as_ref())),
3093 Some("Initial text"),
3094 "draft title should show editor text before folder add"
3095 );
3096 });
3097
3098 // Add a second folder to the project — this changes the group key.
3099 project
3100 .update(cx, |project, cx| {
3101 project.find_or_create_worktree("/project-b", true, cx)
3102 })
3103 .await
3104 .expect("should add worktree");
3105 cx.run_until_parked();
3106
3107 // Update editor text.
3108 editor.update_in(cx, |editor, window, cx| {
3109 editor.set_text("Updated after folder add", window, cx);
3110 });
3111 cx.run_until_parked();
3112
3113 // The draft title should still update. After adding a folder the
3114 // group key changes, so the thread may not appear in the sidebar
3115 // if its metadata was saved under the old path list. If it IS
3116 // found, verify the title was overridden.
3117 sidebar.read_with(cx, |sidebar, _cx| {
3118 let thread = sidebar
3119 .contents
3120 .entries
3121 .iter()
3122 .find_map(|entry| match entry {
3123 ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
3124 _ => None,
3125 });
3126 if let Some(thread) = thread {
3127 assert_eq!(
3128 thread.metadata.title.as_ref().map(|s| s.as_ref()),
3129 Some("Updated after folder add"),
3130 "draft title should update even after adding a folder"
3131 );
3132 }
3133 });
3134}
3135
3136#[gpui::test]
3137async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
3138 // When the user presses Cmd-N (NewThread action) while viewing a
3139 // non-empty thread, the sidebar should show the "New Thread" entry.
3140 // This exercises the same code path as the workspace action handler
3141 // (which bypasses the sidebar's create_new_thread method).
3142 let project = init_test_project_with_agent_panel("/my-project", cx).await;
3143 let (multi_workspace, cx) =
3144 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3145 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3146
3147 // Create a non-empty thread (has messages).
3148 let connection = StubAgentConnection::new();
3149 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3150 acp::ContentChunk::new("Done".into()),
3151 )]);
3152 open_thread_with_connection(&panel, connection, cx);
3153 send_message(&panel, cx);
3154
3155 let session_id = active_session_id(&panel, cx);
3156 save_test_thread_metadata(&session_id, &project, cx).await;
3157 cx.run_until_parked();
3158
3159 assert_eq!(
3160 visible_entries_as_strings(&sidebar, cx),
3161 vec![
3162 //
3163 "v [my-project]",
3164 " Hello *",
3165 ]
3166 );
3167
3168 // Simulate cmd-n
3169 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
3170 panel.update_in(cx, |panel, window, cx| {
3171 panel.new_thread(&NewThread, window, cx);
3172 });
3173 workspace.update_in(cx, |workspace, window, cx| {
3174 workspace.focus_panel::<AgentPanel>(window, cx);
3175 });
3176 cx.run_until_parked();
3177
3178 assert_eq!(
3179 visible_entries_as_strings(&sidebar, cx),
3180 vec!["v [my-project]", " [~ Draft] *", " Hello *"],
3181 "After Cmd-N the sidebar should show a highlighted Draft entry"
3182 );
3183
3184 sidebar.read_with(cx, |sidebar, _cx| {
3185 assert_active_draft(
3186 sidebar,
3187 &workspace,
3188 "active_entry should be Draft after Cmd-N",
3189 );
3190 });
3191}
3192
3193#[gpui::test]
3194async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
3195 let project = init_test_project_with_agent_panel("/my-project", cx).await;
3196 let (multi_workspace, cx) =
3197 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3198 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3199
3200 // Create a saved thread so the workspace has history.
3201 let connection = StubAgentConnection::new();
3202 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3203 acp::ContentChunk::new("Done".into()),
3204 )]);
3205 open_thread_with_connection(&panel, connection, cx);
3206 send_message(&panel, cx);
3207 let saved_session_id = active_session_id(&panel, cx);
3208 save_test_thread_metadata(&saved_session_id, &project, cx).await;
3209 cx.run_until_parked();
3210
3211 assert_eq!(
3212 visible_entries_as_strings(&sidebar, cx),
3213 vec![
3214 //
3215 "v [my-project]",
3216 " Hello *",
3217 ]
3218 );
3219
3220 // Create a new draft via Cmd-N. Since new_thread() now creates a
3221 // tracked draft in the AgentPanel, it appears in the sidebar.
3222 panel.update_in(cx, |panel, window, cx| {
3223 panel.new_thread(&NewThread, window, cx);
3224 });
3225 cx.run_until_parked();
3226
3227 assert_eq!(
3228 visible_entries_as_strings(&sidebar, cx),
3229 vec!["v [my-project]", " [~ Draft] *", " Hello *"],
3230 );
3231
3232 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
3233 sidebar.read_with(cx, |sidebar, _cx| {
3234 assert_active_draft(
3235 sidebar,
3236 &workspace,
3237 "Draft with server session should be Draft, not Thread",
3238 );
3239 });
3240}
3241
3242#[gpui::test]
3243async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext) {
3244 // When the user sends a message from a draft thread, the draft
3245 // should be removed from the sidebar and the active_entry should
3246 // transition to a Thread pointing at the new session.
3247 let project = init_test_project_with_agent_panel("/my-project", cx).await;
3248 let (multi_workspace, cx) =
3249 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3250 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3251
3252 // Create a saved thread so the group isn't empty.
3253 let connection = StubAgentConnection::new();
3254 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3255 acp::ContentChunk::new("Done".into()),
3256 )]);
3257 open_thread_with_connection(&panel, connection, cx);
3258 send_message(&panel, cx);
3259 let existing_session_id = active_session_id(&panel, cx);
3260 save_test_thread_metadata(&existing_session_id, &project, cx).await;
3261 cx.run_until_parked();
3262
3263 // Create a draft via Cmd-N.
3264 panel.update_in(cx, |panel, window, cx| {
3265 panel.new_thread(&NewThread, window, cx);
3266 });
3267 cx.run_until_parked();
3268
3269 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3270 assert_eq!(
3271 visible_entries_as_strings(&sidebar, cx),
3272 vec!["v [my-project]", " [~ Draft] *", " Hello *"],
3273 "draft should be visible before sending",
3274 );
3275 sidebar.read_with(cx, |sidebar, _| {
3276 assert_active_draft(sidebar, &workspace, "should be on draft before sending");
3277 });
3278
3279 // Simulate what happens when a draft sends its first message:
3280 // the AgentPanel's MessageSentOrQueued handler removes the draft
3281 // from `draft_threads`, then the sidebar rebuilds. We can't use
3282 // the NativeAgentServer in tests, so replicate the key steps:
3283 // remove the draft, open a real thread with a stub connection,
3284 // and send.
3285 let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
3286 panel.update_in(cx, |panel, _window, cx| {
3287 panel.remove_thread(thread_id, cx);
3288 });
3289 let draft_connection = StubAgentConnection::new();
3290 draft_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3291 acp::ContentChunk::new("World".into()),
3292 )]);
3293 open_thread_with_connection(&panel, draft_connection, cx);
3294 send_message(&panel, cx);
3295 let new_session_id = active_session_id(&panel, cx);
3296 save_test_thread_metadata(&new_session_id, &project, cx).await;
3297 cx.run_until_parked();
3298
3299 // The draft should be gone and the new thread should be active.
3300 let entries = visible_entries_as_strings(&sidebar, cx);
3301 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
3302 assert_eq!(
3303 draft_count, 0,
3304 "draft should be removed after sending a message"
3305 );
3306
3307 sidebar.read_with(cx, |sidebar, _| {
3308 assert_active_thread(
3309 sidebar,
3310 &new_session_id,
3311 "active_entry should transition to the new thread after sending",
3312 );
3313 });
3314}
3315
3316#[gpui::test]
3317async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
3318 // When the active workspace is an absorbed git worktree, cmd-n
3319 // should still show the "New Thread" entry under the main repo's
3320 // header and highlight it as active.
3321 agent_ui::test_support::init_test(cx);
3322 cx.update(|cx| {
3323 ThreadStore::init_global(cx);
3324 ThreadMetadataStore::init_global(cx);
3325 language_model::LanguageModelRegistry::test(cx);
3326 prompt_store::init(cx);
3327 });
3328
3329 let fs = FakeFs::new(cx.executor());
3330
3331 // Main repo with a linked worktree.
3332 fs.insert_tree(
3333 "/project",
3334 serde_json::json!({
3335 ".git": {},
3336 "src": {},
3337 }),
3338 )
3339 .await;
3340
3341 // Worktree checkout pointing back to the main repo.
3342 fs.add_linked_worktree_for_repo(
3343 Path::new("/project/.git"),
3344 false,
3345 git::repository::Worktree {
3346 path: std::path::PathBuf::from("/wt-feature-a"),
3347 ref_name: Some("refs/heads/feature-a".into()),
3348 sha: "aaa".into(),
3349 is_main: false,
3350 is_bare: false,
3351 },
3352 )
3353 .await;
3354
3355 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3356
3357 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3358 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3359
3360 main_project
3361 .update(cx, |p, cx| p.git_scans_complete(cx))
3362 .await;
3363 worktree_project
3364 .update(cx, |p, cx| p.git_scans_complete(cx))
3365 .await;
3366
3367 let (multi_workspace, cx) =
3368 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3369
3370 let sidebar = setup_sidebar(&multi_workspace, cx);
3371
3372 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3373 mw.test_add_workspace(worktree_project.clone(), window, cx)
3374 });
3375
3376 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3377
3378 // Switch to the worktree workspace.
3379 multi_workspace.update_in(cx, |mw, window, cx| {
3380 let workspace = mw.workspaces().nth(1).unwrap().clone();
3381 mw.activate(workspace, window, cx);
3382 });
3383
3384 // Create a non-empty thread in the worktree workspace.
3385 let connection = StubAgentConnection::new();
3386 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3387 acp::ContentChunk::new("Done".into()),
3388 )]);
3389 open_thread_with_connection(&worktree_panel, connection, cx);
3390 send_message(&worktree_panel, cx);
3391
3392 let session_id = active_session_id(&worktree_panel, cx);
3393 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3394 cx.run_until_parked();
3395
3396 assert_eq!(
3397 visible_entries_as_strings(&sidebar, cx),
3398 vec![
3399 //
3400 "v [project]",
3401 " Hello {wt-feature-a} *",
3402 ]
3403 );
3404
3405 // Simulate Cmd-N in the worktree workspace.
3406 worktree_panel.update_in(cx, |panel, window, cx| {
3407 panel.new_thread(&NewThread, window, cx);
3408 });
3409 worktree_workspace.update_in(cx, |workspace, window, cx| {
3410 workspace.focus_panel::<AgentPanel>(window, cx);
3411 });
3412 cx.run_until_parked();
3413
3414 assert_eq!(
3415 visible_entries_as_strings(&sidebar, cx),
3416 vec![
3417 //
3418 "v [project]",
3419 " [~ Draft {wt-feature-a}] *",
3420 " Hello {wt-feature-a} *"
3421 ],
3422 "After Cmd-N in an absorbed worktree, the sidebar should show \
3423 a highlighted Draft entry under the main repo header"
3424 );
3425
3426 sidebar.read_with(cx, |sidebar, _cx| {
3427 assert_active_draft(
3428 sidebar,
3429 &worktree_workspace,
3430 "active_entry should be Draft after Cmd-N",
3431 );
3432 });
3433}
3434
3435async fn init_test_project_with_git(
3436 worktree_path: &str,
3437 cx: &mut TestAppContext,
3438) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
3439 init_test(cx);
3440 let fs = FakeFs::new(cx.executor());
3441 fs.insert_tree(
3442 worktree_path,
3443 serde_json::json!({
3444 ".git": {},
3445 "src": {},
3446 }),
3447 )
3448 .await;
3449 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3450 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
3451 (project, fs)
3452}
3453
3454#[gpui::test]
3455async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
3456 let (project, fs) = init_test_project_with_git("/project", cx).await;
3457
3458 fs.as_fake()
3459 .add_linked_worktree_for_repo(
3460 Path::new("/project/.git"),
3461 false,
3462 git::repository::Worktree {
3463 path: std::path::PathBuf::from("/wt/rosewood"),
3464 ref_name: Some("refs/heads/rosewood".into()),
3465 sha: "abc".into(),
3466 is_main: false,
3467 is_bare: false,
3468 },
3469 )
3470 .await;
3471
3472 project
3473 .update(cx, |project, cx| project.git_scans_complete(cx))
3474 .await;
3475
3476 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3477 worktree_project
3478 .update(cx, |p, cx| p.git_scans_complete(cx))
3479 .await;
3480
3481 let (multi_workspace, cx) =
3482 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3483 let sidebar = setup_sidebar(&multi_workspace, cx);
3484
3485 save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
3486 save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
3487
3488 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3489 cx.run_until_parked();
3490
3491 // Search for "rosewood" — should match the worktree name, not the title.
3492 type_in_search(&sidebar, "rosewood", cx);
3493
3494 assert_eq!(
3495 visible_entries_as_strings(&sidebar, cx),
3496 vec![
3497 //
3498 "v [project]",
3499 " Fix Bug {rosewood} <== selected",
3500 ],
3501 );
3502}
3503
3504#[gpui::test]
3505async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
3506 let (project, fs) = init_test_project_with_git("/project", cx).await;
3507
3508 project
3509 .update(cx, |project, cx| project.git_scans_complete(cx))
3510 .await;
3511
3512 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3513 worktree_project
3514 .update(cx, |p, cx| p.git_scans_complete(cx))
3515 .await;
3516
3517 let (multi_workspace, cx) =
3518 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3519 let sidebar = setup_sidebar(&multi_workspace, cx);
3520
3521 // Save a thread against a worktree path with the correct main
3522 // worktree association (as if the git state had been resolved).
3523 save_thread_metadata_with_main_paths(
3524 "wt-thread",
3525 "Worktree Thread",
3526 PathList::new(&[PathBuf::from("/wt/rosewood")]),
3527 PathList::new(&[PathBuf::from("/project")]),
3528 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3529 cx,
3530 );
3531
3532 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3533 cx.run_until_parked();
3534
3535 // Thread is visible because its main_worktree_paths match the group.
3536 // The chip name is derived from the path even before git discovery.
3537 assert_eq!(
3538 visible_entries_as_strings(&sidebar, cx),
3539 vec!["v [project]", " Worktree Thread {rosewood}"]
3540 );
3541
3542 // Now add the worktree to the git state and trigger a rescan.
3543 fs.as_fake()
3544 .add_linked_worktree_for_repo(
3545 Path::new("/project/.git"),
3546 true,
3547 git::repository::Worktree {
3548 path: std::path::PathBuf::from("/wt/rosewood"),
3549 ref_name: Some("refs/heads/rosewood".into()),
3550 sha: "abc".into(),
3551 is_main: false,
3552 is_bare: false,
3553 },
3554 )
3555 .await;
3556
3557 cx.run_until_parked();
3558
3559 assert_eq!(
3560 visible_entries_as_strings(&sidebar, cx),
3561 vec![
3562 //
3563 "v [project]",
3564 " Worktree Thread {rosewood}",
3565 ]
3566 );
3567}
3568
3569#[gpui::test]
3570async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
3571 init_test(cx);
3572 let fs = FakeFs::new(cx.executor());
3573
3574 // Create the main repo directory (not opened as a workspace yet).
3575 fs.insert_tree(
3576 "/project",
3577 serde_json::json!({
3578 ".git": {
3579 },
3580 "src": {},
3581 }),
3582 )
3583 .await;
3584
3585 // Two worktree checkouts whose .git files point back to the main repo.
3586 fs.add_linked_worktree_for_repo(
3587 Path::new("/project/.git"),
3588 false,
3589 git::repository::Worktree {
3590 path: std::path::PathBuf::from("/wt-feature-a"),
3591 ref_name: Some("refs/heads/feature-a".into()),
3592 sha: "aaa".into(),
3593 is_main: false,
3594 is_bare: false,
3595 },
3596 )
3597 .await;
3598 fs.add_linked_worktree_for_repo(
3599 Path::new("/project/.git"),
3600 false,
3601 git::repository::Worktree {
3602 path: std::path::PathBuf::from("/wt-feature-b"),
3603 ref_name: Some("refs/heads/feature-b".into()),
3604 sha: "bbb".into(),
3605 is_main: false,
3606 is_bare: false,
3607 },
3608 )
3609 .await;
3610
3611 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3612
3613 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3614 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3615
3616 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3617 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3618
3619 // Open both worktrees as workspaces — no main repo yet.
3620 let (multi_workspace, cx) =
3621 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3622 multi_workspace.update_in(cx, |mw, window, cx| {
3623 mw.test_add_workspace(project_b.clone(), window, cx);
3624 });
3625 let sidebar = setup_sidebar(&multi_workspace, cx);
3626
3627 save_thread_metadata(
3628 acp::SessionId::new(Arc::from("thread-a")),
3629 Some("Thread A".into()),
3630 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3631 None,
3632 &project_a,
3633 cx,
3634 );
3635 save_thread_metadata(
3636 acp::SessionId::new(Arc::from("thread-b")),
3637 Some("Thread B".into()),
3638 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
3639 None,
3640 &project_b,
3641 cx,
3642 );
3643
3644 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3645 cx.run_until_parked();
3646
3647 // Without the main repo, each worktree has its own header.
3648 assert_eq!(
3649 visible_entries_as_strings(&sidebar, cx),
3650 vec![
3651 //
3652 "v [project]",
3653 " Thread B {wt-feature-b}",
3654 " Thread A {wt-feature-a}",
3655 ]
3656 );
3657
3658 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3659 main_project
3660 .update(cx, |p, cx| p.git_scans_complete(cx))
3661 .await;
3662
3663 multi_workspace.update_in(cx, |mw, window, cx| {
3664 mw.test_add_workspace(main_project.clone(), window, cx);
3665 });
3666 cx.run_until_parked();
3667
3668 // Both worktree workspaces should now be absorbed under the main
3669 // repo header, with worktree chips.
3670 assert_eq!(
3671 visible_entries_as_strings(&sidebar, cx),
3672 vec![
3673 //
3674 "v [project]",
3675 " Thread B {wt-feature-b}",
3676 " Thread A {wt-feature-a}",
3677 ]
3678 );
3679}
3680
3681#[gpui::test]
3682async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
3683 // When a group has two workspaces — one with threads and one
3684 // without — the threadless workspace should appear as a
3685 // "New Thread" button with its worktree chip.
3686 init_test(cx);
3687 let fs = FakeFs::new(cx.executor());
3688
3689 // Main repo with two linked worktrees.
3690 fs.insert_tree(
3691 "/project",
3692 serde_json::json!({
3693 ".git": {},
3694 "src": {},
3695 }),
3696 )
3697 .await;
3698 fs.add_linked_worktree_for_repo(
3699 Path::new("/project/.git"),
3700 false,
3701 git::repository::Worktree {
3702 path: std::path::PathBuf::from("/wt-feature-a"),
3703 ref_name: Some("refs/heads/feature-a".into()),
3704 sha: "aaa".into(),
3705 is_main: false,
3706 is_bare: false,
3707 },
3708 )
3709 .await;
3710 fs.add_linked_worktree_for_repo(
3711 Path::new("/project/.git"),
3712 false,
3713 git::repository::Worktree {
3714 path: std::path::PathBuf::from("/wt-feature-b"),
3715 ref_name: Some("refs/heads/feature-b".into()),
3716 sha: "bbb".into(),
3717 is_main: false,
3718 is_bare: false,
3719 },
3720 )
3721 .await;
3722
3723 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3724
3725 // Workspace A: worktree feature-a (has threads).
3726 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3727 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3728
3729 // Workspace B: worktree feature-b (no threads).
3730 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3731 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3732
3733 let (multi_workspace, cx) =
3734 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3735 multi_workspace.update_in(cx, |mw, window, cx| {
3736 mw.test_add_workspace(project_b.clone(), window, cx);
3737 });
3738 let sidebar = setup_sidebar(&multi_workspace, cx);
3739
3740 // Only save a thread for workspace A.
3741 save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
3742
3743 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3744 cx.run_until_parked();
3745
3746 // Workspace A's thread appears normally. Workspace B (threadless)
3747 // appears as a "New Thread" button with its worktree chip.
3748 assert_eq!(
3749 visible_entries_as_strings(&sidebar, cx),
3750 vec!["v [project]", " Thread A {wt-feature-a}",]
3751 );
3752}
3753
3754#[gpui::test]
3755async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
3756 // A thread created in a workspace with roots from different git
3757 // worktrees should show a chip for each distinct worktree name.
3758 init_test(cx);
3759 let fs = FakeFs::new(cx.executor());
3760
3761 // Two main repos.
3762 fs.insert_tree(
3763 "/project_a",
3764 serde_json::json!({
3765 ".git": {},
3766 "src": {},
3767 }),
3768 )
3769 .await;
3770 fs.insert_tree(
3771 "/project_b",
3772 serde_json::json!({
3773 ".git": {},
3774 "src": {},
3775 }),
3776 )
3777 .await;
3778
3779 // Worktree checkouts.
3780 for repo in &["project_a", "project_b"] {
3781 let git_path = format!("/{repo}/.git");
3782 for branch in &["olivetti", "selectric"] {
3783 fs.add_linked_worktree_for_repo(
3784 Path::new(&git_path),
3785 false,
3786 git::repository::Worktree {
3787 path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
3788 ref_name: Some(format!("refs/heads/{branch}").into()),
3789 sha: "aaa".into(),
3790 is_main: false,
3791 is_bare: false,
3792 },
3793 )
3794 .await;
3795 }
3796 }
3797
3798 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3799
3800 // Open a workspace with the worktree checkout paths as roots
3801 // (this is the workspace the thread was created in).
3802 let project = project::Project::test(
3803 fs.clone(),
3804 [
3805 "/worktrees/project_a/olivetti/project_a".as_ref(),
3806 "/worktrees/project_b/selectric/project_b".as_ref(),
3807 ],
3808 cx,
3809 )
3810 .await;
3811 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3812
3813 let (multi_workspace, cx) =
3814 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3815 let sidebar = setup_sidebar(&multi_workspace, cx);
3816
3817 // Save a thread under the same paths as the workspace roots.
3818 save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
3819
3820 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3821 cx.run_until_parked();
3822
3823 // Should show two distinct worktree chips.
3824 assert_eq!(
3825 visible_entries_as_strings(&sidebar, cx),
3826 vec![
3827 //
3828 "v [project_a, project_b]",
3829 " Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
3830 ]
3831 );
3832}
3833
3834#[gpui::test]
3835async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
3836 // When a thread's roots span multiple repos but share the same
3837 // worktree name (e.g. both in "olivetti"), only one chip should
3838 // appear.
3839 init_test(cx);
3840 let fs = FakeFs::new(cx.executor());
3841
3842 fs.insert_tree(
3843 "/project_a",
3844 serde_json::json!({
3845 ".git": {},
3846 "src": {},
3847 }),
3848 )
3849 .await;
3850 fs.insert_tree(
3851 "/project_b",
3852 serde_json::json!({
3853 ".git": {},
3854 "src": {},
3855 }),
3856 )
3857 .await;
3858
3859 for repo in &["project_a", "project_b"] {
3860 let git_path = format!("/{repo}/.git");
3861 fs.add_linked_worktree_for_repo(
3862 Path::new(&git_path),
3863 false,
3864 git::repository::Worktree {
3865 path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
3866 ref_name: Some("refs/heads/olivetti".into()),
3867 sha: "aaa".into(),
3868 is_main: false,
3869 is_bare: false,
3870 },
3871 )
3872 .await;
3873 }
3874
3875 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3876
3877 let project = project::Project::test(
3878 fs.clone(),
3879 [
3880 "/worktrees/project_a/olivetti/project_a".as_ref(),
3881 "/worktrees/project_b/olivetti/project_b".as_ref(),
3882 ],
3883 cx,
3884 )
3885 .await;
3886 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3887
3888 let (multi_workspace, cx) =
3889 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3890 let sidebar = setup_sidebar(&multi_workspace, cx);
3891
3892 // Thread with roots in both repos' "olivetti" worktrees.
3893 save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
3894
3895 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3896 cx.run_until_parked();
3897
3898 // Both worktree paths have the name "olivetti", so only one chip.
3899 assert_eq!(
3900 visible_entries_as_strings(&sidebar, cx),
3901 vec![
3902 //
3903 "v [project_a, project_b]",
3904 " Same Branch Thread {olivetti}",
3905 ]
3906 );
3907}
3908
3909#[gpui::test]
3910async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
3911 // When a worktree workspace is absorbed under the main repo, a
3912 // running thread in the worktree's agent panel should still show
3913 // live status (spinner + "(running)") in the sidebar.
3914 agent_ui::test_support::init_test(cx);
3915 cx.update(|cx| {
3916 ThreadStore::init_global(cx);
3917 ThreadMetadataStore::init_global(cx);
3918 language_model::LanguageModelRegistry::test(cx);
3919 prompt_store::init(cx);
3920 });
3921
3922 let fs = FakeFs::new(cx.executor());
3923
3924 // Main repo with a linked worktree.
3925 fs.insert_tree(
3926 "/project",
3927 serde_json::json!({
3928 ".git": {},
3929 "src": {},
3930 }),
3931 )
3932 .await;
3933
3934 // Worktree checkout pointing back to the main repo.
3935 fs.add_linked_worktree_for_repo(
3936 Path::new("/project/.git"),
3937 false,
3938 git::repository::Worktree {
3939 path: std::path::PathBuf::from("/wt-feature-a"),
3940 ref_name: Some("refs/heads/feature-a".into()),
3941 sha: "aaa".into(),
3942 is_main: false,
3943 is_bare: false,
3944 },
3945 )
3946 .await;
3947
3948 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3949
3950 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3951 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3952
3953 main_project
3954 .update(cx, |p, cx| p.git_scans_complete(cx))
3955 .await;
3956 worktree_project
3957 .update(cx, |p, cx| p.git_scans_complete(cx))
3958 .await;
3959
3960 // Create the MultiWorkspace with both projects.
3961 let (multi_workspace, cx) =
3962 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3963
3964 let sidebar = setup_sidebar(&multi_workspace, cx);
3965
3966 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3967 mw.test_add_workspace(worktree_project.clone(), window, cx)
3968 });
3969
3970 // Add an agent panel to the worktree workspace so we can run a
3971 // thread inside it.
3972 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3973
3974 // Switch back to the main workspace before setting up the sidebar.
3975 multi_workspace.update_in(cx, |mw, window, cx| {
3976 let workspace = mw.workspaces().next().unwrap().clone();
3977 mw.activate(workspace, window, cx);
3978 });
3979
3980 // Start a thread in the worktree workspace's panel and keep it
3981 // generating (don't resolve it).
3982 let connection = StubAgentConnection::new();
3983 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3984 send_message(&worktree_panel, cx);
3985
3986 let session_id = active_session_id(&worktree_panel, cx);
3987
3988 // Save metadata so the sidebar knows about this thread.
3989 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3990
3991 // Keep the thread generating by sending a chunk without ending
3992 // the turn.
3993 cx.update(|_, cx| {
3994 connection.send_update(
3995 session_id.clone(),
3996 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3997 cx,
3998 );
3999 });
4000 cx.run_until_parked();
4001
4002 // The worktree thread should be absorbed under the main project
4003 // and show live running status.
4004 let entries = visible_entries_as_strings(&sidebar, cx);
4005 assert_eq!(
4006 entries,
4007 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
4008 );
4009}
4010
4011#[gpui::test]
4012async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
4013 agent_ui::test_support::init_test(cx);
4014 cx.update(|cx| {
4015 ThreadStore::init_global(cx);
4016 ThreadMetadataStore::init_global(cx);
4017 language_model::LanguageModelRegistry::test(cx);
4018 prompt_store::init(cx);
4019 });
4020
4021 let fs = FakeFs::new(cx.executor());
4022
4023 fs.insert_tree(
4024 "/project",
4025 serde_json::json!({
4026 ".git": {},
4027 "src": {},
4028 }),
4029 )
4030 .await;
4031
4032 fs.add_linked_worktree_for_repo(
4033 Path::new("/project/.git"),
4034 false,
4035 git::repository::Worktree {
4036 path: std::path::PathBuf::from("/wt-feature-a"),
4037 ref_name: Some("refs/heads/feature-a".into()),
4038 sha: "aaa".into(),
4039 is_main: false,
4040 is_bare: false,
4041 },
4042 )
4043 .await;
4044
4045 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4046
4047 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4048 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4049
4050 main_project
4051 .update(cx, |p, cx| p.git_scans_complete(cx))
4052 .await;
4053 worktree_project
4054 .update(cx, |p, cx| p.git_scans_complete(cx))
4055 .await;
4056
4057 let (multi_workspace, cx) =
4058 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4059
4060 let sidebar = setup_sidebar(&multi_workspace, cx);
4061
4062 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4063 mw.test_add_workspace(worktree_project.clone(), window, cx)
4064 });
4065
4066 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
4067
4068 multi_workspace.update_in(cx, |mw, window, cx| {
4069 let workspace = mw.workspaces().next().unwrap().clone();
4070 mw.activate(workspace, window, cx);
4071 });
4072
4073 let connection = StubAgentConnection::new();
4074 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
4075 send_message(&worktree_panel, cx);
4076
4077 let session_id = active_session_id(&worktree_panel, cx);
4078 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
4079
4080 cx.update(|_, cx| {
4081 connection.send_update(
4082 session_id.clone(),
4083 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4084 cx,
4085 );
4086 });
4087 cx.run_until_parked();
4088
4089 assert_eq!(
4090 visible_entries_as_strings(&sidebar, cx),
4091 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
4092 );
4093
4094 connection.end_turn(session_id, acp::StopReason::EndTurn);
4095 cx.run_until_parked();
4096
4097 assert_eq!(
4098 visible_entries_as_strings(&sidebar, cx),
4099 vec!["v [project]", " Hello {wt-feature-a} * (!)",]
4100 );
4101}
4102
4103#[gpui::test]
4104async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
4105 init_test(cx);
4106 let fs = FakeFs::new(cx.executor());
4107
4108 fs.insert_tree(
4109 "/project",
4110 serde_json::json!({
4111 ".git": {},
4112 "src": {},
4113 }),
4114 )
4115 .await;
4116
4117 fs.add_linked_worktree_for_repo(
4118 Path::new("/project/.git"),
4119 false,
4120 git::repository::Worktree {
4121 path: std::path::PathBuf::from("/wt-feature-a"),
4122 ref_name: Some("refs/heads/feature-a".into()),
4123 sha: "aaa".into(),
4124 is_main: false,
4125 is_bare: false,
4126 },
4127 )
4128 .await;
4129
4130 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4131
4132 // Only open the main repo — no workspace for the worktree.
4133 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4134 main_project
4135 .update(cx, |p, cx| p.git_scans_complete(cx))
4136 .await;
4137
4138 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4139 worktree_project
4140 .update(cx, |p, cx| p.git_scans_complete(cx))
4141 .await;
4142
4143 let (multi_workspace, cx) =
4144 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4145 let sidebar = setup_sidebar(&multi_workspace, cx);
4146
4147 // Save a thread for the worktree path (no workspace for it).
4148 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
4149
4150 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4151 cx.run_until_parked();
4152
4153 // Thread should appear under the main repo with a worktree chip.
4154 assert_eq!(
4155 visible_entries_as_strings(&sidebar, cx),
4156 vec![
4157 //
4158 "v [project]",
4159 " WT Thread {wt-feature-a}",
4160 ],
4161 );
4162
4163 // Only 1 workspace should exist.
4164 assert_eq!(
4165 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4166 1,
4167 );
4168
4169 // Focus the sidebar and select the worktree thread.
4170 focus_sidebar(&sidebar, cx);
4171 sidebar.update_in(cx, |sidebar, _window, _cx| {
4172 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
4173 });
4174
4175 // Confirm to open the worktree thread.
4176 cx.dispatch_action(Confirm);
4177 cx.run_until_parked();
4178
4179 // A new workspace should have been created for the worktree path.
4180 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
4181 assert_eq!(
4182 mw.workspaces().count(),
4183 2,
4184 "confirming a worktree thread without a workspace should open one",
4185 );
4186 mw.workspaces().nth(1).unwrap().clone()
4187 });
4188
4189 let new_path_list =
4190 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
4191 assert_eq!(
4192 new_path_list,
4193 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
4194 "the new workspace should have been opened for the worktree path",
4195 );
4196}
4197
4198#[gpui::test]
4199async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
4200 cx: &mut TestAppContext,
4201) {
4202 init_test(cx);
4203 let fs = FakeFs::new(cx.executor());
4204
4205 fs.insert_tree(
4206 "/project",
4207 serde_json::json!({
4208 ".git": {},
4209 "src": {},
4210 }),
4211 )
4212 .await;
4213
4214 fs.add_linked_worktree_for_repo(
4215 Path::new("/project/.git"),
4216 false,
4217 git::repository::Worktree {
4218 path: std::path::PathBuf::from("/wt-feature-a"),
4219 ref_name: Some("refs/heads/feature-a".into()),
4220 sha: "aaa".into(),
4221 is_main: false,
4222 is_bare: false,
4223 },
4224 )
4225 .await;
4226
4227 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4228
4229 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4230 main_project
4231 .update(cx, |p, cx| p.git_scans_complete(cx))
4232 .await;
4233
4234 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4235 worktree_project
4236 .update(cx, |p, cx| p.git_scans_complete(cx))
4237 .await;
4238
4239 let (multi_workspace, cx) =
4240 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4241 let sidebar = setup_sidebar(&multi_workspace, cx);
4242
4243 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
4244
4245 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4246 cx.run_until_parked();
4247
4248 assert_eq!(
4249 visible_entries_as_strings(&sidebar, cx),
4250 vec![
4251 //
4252 "v [project]",
4253 " WT Thread {wt-feature-a}",
4254 ],
4255 );
4256
4257 focus_sidebar(&sidebar, cx);
4258 sidebar.update_in(cx, |sidebar, _window, _cx| {
4259 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
4260 });
4261
4262 let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
4263 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
4264 if let ListEntry::ProjectHeader { label, .. } = entry {
4265 Some(label.as_ref())
4266 } else {
4267 None
4268 }
4269 });
4270
4271 let Some(project_header) = project_headers.next() else {
4272 panic!("expected exactly one sidebar project header named `project`, found none");
4273 };
4274 assert_eq!(
4275 project_header, "project",
4276 "expected the only sidebar project header to be `project`"
4277 );
4278 if let Some(unexpected_header) = project_headers.next() {
4279 panic!(
4280 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
4281 );
4282 }
4283
4284 let mut saw_expected_thread = false;
4285 for entry in &sidebar.contents.entries {
4286 match entry {
4287 ListEntry::ProjectHeader { label, .. } => {
4288 assert_eq!(
4289 label.as_ref(),
4290 "project",
4291 "expected the only sidebar project header to be `project`"
4292 );
4293 }
4294 ListEntry::Thread(thread)
4295 if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
4296 && thread.worktrees.first().map(|wt| wt.name.as_ref())
4297 == Some("wt-feature-a") =>
4298 {
4299 saw_expected_thread = true;
4300 }
4301 ListEntry::Thread(thread) if thread.is_draft => {}
4302 ListEntry::Thread(thread) => {
4303 let title = thread.metadata.display_title();
4304 let worktree_name = thread
4305 .worktrees
4306 .first()
4307 .map(|wt| wt.name.as_ref())
4308 .unwrap_or("<none>");
4309 panic!(
4310 "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
4311 title, worktree_name
4312 );
4313 }
4314 ListEntry::ViewMore { .. } => {
4315 panic!("unexpected `View More` entry while opening linked worktree thread");
4316 }
4317 }
4318 }
4319
4320 assert!(
4321 saw_expected_thread,
4322 "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
4323 );
4324 };
4325
4326 sidebar
4327 .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
4328 .detach();
4329
4330 let window = cx.windows()[0];
4331 cx.update_window(window, |_, window, cx| {
4332 window.dispatch_action(Confirm.boxed_clone(), cx);
4333 })
4334 .unwrap();
4335
4336 cx.run_until_parked();
4337
4338 sidebar.update(cx, assert_sidebar_state);
4339}
4340
4341#[gpui::test]
4342async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
4343 cx: &mut TestAppContext,
4344) {
4345 init_test(cx);
4346 let fs = FakeFs::new(cx.executor());
4347
4348 fs.insert_tree(
4349 "/project",
4350 serde_json::json!({
4351 ".git": {},
4352 "src": {},
4353 }),
4354 )
4355 .await;
4356
4357 fs.add_linked_worktree_for_repo(
4358 Path::new("/project/.git"),
4359 false,
4360 git::repository::Worktree {
4361 path: std::path::PathBuf::from("/wt-feature-a"),
4362 ref_name: Some("refs/heads/feature-a".into()),
4363 sha: "aaa".into(),
4364 is_main: false,
4365 is_bare: false,
4366 },
4367 )
4368 .await;
4369
4370 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4371
4372 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4373 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4374
4375 main_project
4376 .update(cx, |p, cx| p.git_scans_complete(cx))
4377 .await;
4378 worktree_project
4379 .update(cx, |p, cx| p.git_scans_complete(cx))
4380 .await;
4381
4382 let (multi_workspace, cx) =
4383 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4384
4385 let sidebar = setup_sidebar(&multi_workspace, cx);
4386
4387 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4388 mw.test_add_workspace(worktree_project.clone(), window, cx)
4389 });
4390
4391 // Activate the main workspace before setting up the sidebar.
4392 let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4393 let workspace = mw.workspaces().next().unwrap().clone();
4394 mw.activate(workspace.clone(), window, cx);
4395 workspace
4396 });
4397
4398 save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
4399 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
4400
4401 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4402 cx.run_until_parked();
4403
4404 // The worktree workspace should be absorbed under the main repo.
4405 let entries = visible_entries_as_strings(&sidebar, cx);
4406 assert_eq!(entries.len(), 3);
4407 assert_eq!(entries[0], "v [project]");
4408 assert!(entries.contains(&" Main Thread".to_string()));
4409 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
4410
4411 let wt_thread_index = entries
4412 .iter()
4413 .position(|e| e.contains("WT Thread"))
4414 .expect("should find the worktree thread entry");
4415
4416 assert_eq!(
4417 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4418 main_workspace,
4419 "main workspace should be active initially"
4420 );
4421
4422 // Focus the sidebar and select the absorbed worktree thread.
4423 focus_sidebar(&sidebar, cx);
4424 sidebar.update_in(cx, |sidebar, _window, _cx| {
4425 sidebar.selection = Some(wt_thread_index);
4426 });
4427
4428 // Confirm to activate the worktree thread.
4429 cx.dispatch_action(Confirm);
4430 cx.run_until_parked();
4431
4432 // The worktree workspace should now be active, not the main one.
4433 let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4434 assert_eq!(
4435 active_workspace, worktree_workspace,
4436 "clicking an absorbed worktree thread should activate the worktree workspace"
4437 );
4438}
4439
4440#[gpui::test]
4441async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
4442 cx: &mut TestAppContext,
4443) {
4444 // Thread has saved metadata in ThreadStore. A matching workspace is
4445 // already open. Expected: activates the matching workspace.
4446 init_test(cx);
4447 let fs = FakeFs::new(cx.executor());
4448 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4449 .await;
4450 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4451 .await;
4452 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4453
4454 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4455 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4456
4457 let (multi_workspace, cx) =
4458 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4459
4460 let sidebar = setup_sidebar(&multi_workspace, cx);
4461
4462 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4463 mw.test_add_workspace(project_b.clone(), window, cx)
4464 });
4465 let workspace_a =
4466 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4467
4468 // Save a thread with path_list pointing to project-b.
4469 let session_id = acp::SessionId::new(Arc::from("archived-1"));
4470 save_test_thread_metadata(&session_id, &project_b, cx).await;
4471
4472 // Ensure workspace A is active.
4473 multi_workspace.update_in(cx, |mw, window, cx| {
4474 let workspace = mw.workspaces().next().unwrap().clone();
4475 mw.activate(workspace, window, cx);
4476 });
4477 cx.run_until_parked();
4478 assert_eq!(
4479 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4480 workspace_a
4481 );
4482
4483 // Call activate_archived_thread – should resolve saved paths and
4484 // switch to the workspace for project-b.
4485 sidebar.update_in(cx, |sidebar, window, cx| {
4486 sidebar.activate_archived_thread(
4487 ThreadMetadata {
4488 thread_id: ThreadId::new(),
4489 session_id: Some(session_id.clone()),
4490 agent_id: agent::ZED_AGENT_ID.clone(),
4491 title: Some("Archived Thread".into()),
4492 updated_at: Utc::now(),
4493 created_at: None,
4494 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4495 "/project-b",
4496 )])),
4497 archived: false,
4498 remote_connection: None,
4499 },
4500 window,
4501 cx,
4502 );
4503 });
4504 cx.run_until_parked();
4505
4506 assert_eq!(
4507 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4508 workspace_b,
4509 "should have switched to the workspace matching the saved paths"
4510 );
4511}
4512
4513#[gpui::test]
4514async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
4515 cx: &mut TestAppContext,
4516) {
4517 // Thread has no saved metadata but session_info has cwd. A matching
4518 // workspace is open. Expected: uses cwd to find and activate it.
4519 init_test(cx);
4520 let fs = FakeFs::new(cx.executor());
4521 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4522 .await;
4523 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4524 .await;
4525 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4526
4527 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4528 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4529
4530 let (multi_workspace, cx) =
4531 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4532
4533 let sidebar = setup_sidebar(&multi_workspace, cx);
4534
4535 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4536 mw.test_add_workspace(project_b, window, cx)
4537 });
4538 let workspace_a =
4539 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4540
4541 // Start with workspace A active.
4542 multi_workspace.update_in(cx, |mw, window, cx| {
4543 let workspace = mw.workspaces().next().unwrap().clone();
4544 mw.activate(workspace, window, cx);
4545 });
4546 cx.run_until_parked();
4547 assert_eq!(
4548 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4549 workspace_a
4550 );
4551
4552 // No thread saved to the store – cwd is the only path hint.
4553 sidebar.update_in(cx, |sidebar, window, cx| {
4554 sidebar.activate_archived_thread(
4555 ThreadMetadata {
4556 thread_id: ThreadId::new(),
4557 session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
4558 agent_id: agent::ZED_AGENT_ID.clone(),
4559 title: Some("CWD Thread".into()),
4560 updated_at: Utc::now(),
4561 created_at: None,
4562 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
4563 std::path::PathBuf::from("/project-b"),
4564 ])),
4565 archived: false,
4566 remote_connection: None,
4567 },
4568 window,
4569 cx,
4570 );
4571 });
4572 cx.run_until_parked();
4573
4574 assert_eq!(
4575 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4576 workspace_b,
4577 "should have activated the workspace matching the cwd"
4578 );
4579}
4580
4581#[gpui::test]
4582async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4583 cx: &mut TestAppContext,
4584) {
4585 // Thread has no saved metadata and no cwd. Expected: falls back to
4586 // the currently active workspace.
4587 init_test(cx);
4588 let fs = FakeFs::new(cx.executor());
4589 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4590 .await;
4591 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4592 .await;
4593 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4594
4595 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4596 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4597
4598 let (multi_workspace, cx) =
4599 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4600
4601 let sidebar = setup_sidebar(&multi_workspace, cx);
4602
4603 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4604 mw.test_add_workspace(project_b, window, cx)
4605 });
4606
4607 // Activate workspace B (index 1) to make it the active one.
4608 multi_workspace.update_in(cx, |mw, window, cx| {
4609 let workspace = mw.workspaces().nth(1).unwrap().clone();
4610 mw.activate(workspace, window, cx);
4611 });
4612 cx.run_until_parked();
4613 assert_eq!(
4614 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4615 workspace_b
4616 );
4617
4618 // No saved thread, no cwd – should fall back to the active workspace.
4619 sidebar.update_in(cx, |sidebar, window, cx| {
4620 sidebar.activate_archived_thread(
4621 ThreadMetadata {
4622 thread_id: ThreadId::new(),
4623 session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
4624 agent_id: agent::ZED_AGENT_ID.clone(),
4625 title: Some("Contextless Thread".into()),
4626 updated_at: Utc::now(),
4627 created_at: None,
4628 worktree_paths: WorktreePaths::default(),
4629 archived: false,
4630 remote_connection: None,
4631 },
4632 window,
4633 cx,
4634 );
4635 });
4636 cx.run_until_parked();
4637
4638 assert_eq!(
4639 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4640 workspace_b,
4641 "should have stayed on the active workspace when no path info is available"
4642 );
4643}
4644
4645#[gpui::test]
4646async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
4647 // Thread has saved metadata pointing to a path with no open workspace.
4648 // Expected: opens a new workspace for that path.
4649 init_test(cx);
4650 let fs = FakeFs::new(cx.executor());
4651 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4652 .await;
4653 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4654 .await;
4655 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4656
4657 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4658
4659 let (multi_workspace, cx) =
4660 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4661
4662 let sidebar = setup_sidebar(&multi_workspace, cx);
4663
4664 // Save a thread with path_list pointing to project-b – which has no
4665 // open workspace.
4666 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4667 let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4668
4669 assert_eq!(
4670 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4671 1,
4672 "should start with one workspace"
4673 );
4674
4675 sidebar.update_in(cx, |sidebar, window, cx| {
4676 sidebar.activate_archived_thread(
4677 ThreadMetadata {
4678 thread_id: ThreadId::new(),
4679 session_id: Some(session_id.clone()),
4680 agent_id: agent::ZED_AGENT_ID.clone(),
4681 title: Some("New WS Thread".into()),
4682 updated_at: Utc::now(),
4683 created_at: None,
4684 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
4685 archived: false,
4686 remote_connection: None,
4687 },
4688 window,
4689 cx,
4690 );
4691 });
4692 cx.run_until_parked();
4693
4694 assert_eq!(
4695 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4696 2,
4697 "should have opened a second workspace for the archived thread's saved paths"
4698 );
4699}
4700
4701#[gpui::test]
4702async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
4703 init_test(cx);
4704 let fs = FakeFs::new(cx.executor());
4705 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4706 .await;
4707 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4708 .await;
4709 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4710
4711 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4712 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4713
4714 let multi_workspace_a =
4715 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4716 let multi_workspace_b =
4717 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4718
4719 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4720 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4721
4722 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4723 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4724
4725 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4726 let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
4727
4728 let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
4729
4730 sidebar.update_in(cx_a, |sidebar, window, cx| {
4731 sidebar.activate_archived_thread(
4732 ThreadMetadata {
4733 thread_id: ThreadId::new(),
4734 session_id: Some(session_id.clone()),
4735 agent_id: agent::ZED_AGENT_ID.clone(),
4736 title: Some("Cross Window Thread".into()),
4737 updated_at: Utc::now(),
4738 created_at: None,
4739 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4740 "/project-b",
4741 )])),
4742 archived: false,
4743 remote_connection: None,
4744 },
4745 window,
4746 cx,
4747 );
4748 });
4749 cx_a.run_until_parked();
4750
4751 assert_eq!(
4752 multi_workspace_a
4753 .read_with(cx_a, |mw, _| mw.workspaces().count())
4754 .unwrap(),
4755 1,
4756 "should not add the other window's workspace into the current window"
4757 );
4758 assert_eq!(
4759 multi_workspace_b
4760 .read_with(cx_a, |mw, _| mw.workspaces().count())
4761 .unwrap(),
4762 1,
4763 "should reuse the existing workspace in the other window"
4764 );
4765 assert!(
4766 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4767 "should activate the window that already owns the matching workspace"
4768 );
4769 sidebar.read_with(cx_a, |sidebar, _| {
4770 assert!(
4771 !is_active_session(&sidebar, &session_id),
4772 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4773 );
4774 });
4775}
4776
4777#[gpui::test]
4778async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
4779 cx: &mut TestAppContext,
4780) {
4781 init_test(cx);
4782 let fs = FakeFs::new(cx.executor());
4783 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4784 .await;
4785 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4786 .await;
4787 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4788
4789 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4790 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4791
4792 let multi_workspace_a =
4793 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4794 let multi_workspace_b =
4795 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
4796
4797 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4798 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4799
4800 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4801 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4802
4803 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4804 let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4805 let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
4806 let _panel_b = add_agent_panel(&workspace_b, cx_b);
4807
4808 let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
4809
4810 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4811 sidebar.activate_archived_thread(
4812 ThreadMetadata {
4813 thread_id: ThreadId::new(),
4814 session_id: Some(session_id.clone()),
4815 agent_id: agent::ZED_AGENT_ID.clone(),
4816 title: Some("Cross Window Thread".into()),
4817 updated_at: Utc::now(),
4818 created_at: None,
4819 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4820 "/project-b",
4821 )])),
4822 archived: false,
4823 remote_connection: None,
4824 },
4825 window,
4826 cx,
4827 );
4828 });
4829 cx_a.run_until_parked();
4830
4831 assert_eq!(
4832 multi_workspace_a
4833 .read_with(cx_a, |mw, _| mw.workspaces().count())
4834 .unwrap(),
4835 1,
4836 "should not add the other window's workspace into the current window"
4837 );
4838 assert_eq!(
4839 multi_workspace_b
4840 .read_with(cx_a, |mw, _| mw.workspaces().count())
4841 .unwrap(),
4842 1,
4843 "should reuse the existing workspace in the other window"
4844 );
4845 assert!(
4846 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4847 "should activate the window that already owns the matching workspace"
4848 );
4849 sidebar_a.read_with(cx_a, |sidebar, _| {
4850 assert!(
4851 !is_active_session(&sidebar, &session_id),
4852 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4853 );
4854 });
4855 sidebar_b.read_with(cx_b, |sidebar, _| {
4856 assert_active_thread(
4857 sidebar,
4858 &session_id,
4859 "target window's sidebar should eagerly focus the activated archived thread",
4860 );
4861 });
4862}
4863
4864#[gpui::test]
4865async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
4866 cx: &mut TestAppContext,
4867) {
4868 init_test(cx);
4869 let fs = FakeFs::new(cx.executor());
4870 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4871 .await;
4872 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4873
4874 let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4875 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4876
4877 let multi_workspace_b =
4878 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4879 let multi_workspace_a =
4880 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4881
4882 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4883 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4884
4885 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4886 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4887
4888 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4889 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4890
4891 let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
4892
4893 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4894 sidebar.activate_archived_thread(
4895 ThreadMetadata {
4896 thread_id: ThreadId::new(),
4897 session_id: Some(session_id.clone()),
4898 agent_id: agent::ZED_AGENT_ID.clone(),
4899 title: Some("Current Window Thread".into()),
4900 updated_at: Utc::now(),
4901 created_at: None,
4902 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4903 "/project-a",
4904 )])),
4905 archived: false,
4906 remote_connection: None,
4907 },
4908 window,
4909 cx,
4910 );
4911 });
4912 cx_a.run_until_parked();
4913
4914 assert!(
4915 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
4916 "should keep activation in the current window when it already has a matching workspace"
4917 );
4918 sidebar_a.read_with(cx_a, |sidebar, _| {
4919 assert_active_thread(
4920 sidebar,
4921 &session_id,
4922 "current window's sidebar should eagerly focus the activated archived thread",
4923 );
4924 });
4925 assert_eq!(
4926 multi_workspace_a
4927 .read_with(cx_a, |mw, _| mw.workspaces().count())
4928 .unwrap(),
4929 1,
4930 "current window should continue reusing its existing workspace"
4931 );
4932 assert_eq!(
4933 multi_workspace_b
4934 .read_with(cx_a, |mw, _| mw.workspaces().count())
4935 .unwrap(),
4936 1,
4937 "other windows should not be activated just because they also match the saved paths"
4938 );
4939}
4940
4941#[gpui::test]
4942async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
4943 // Regression test: archive_thread previously always loaded the next thread
4944 // through group_workspace (the main workspace's ProjectHeader), even when
4945 // the next thread belonged to an absorbed linked-worktree workspace. That
4946 // caused the worktree thread to be loaded in the main panel, which bound it
4947 // to the main project and corrupted its stored folder_paths.
4948 //
4949 // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
4950 // falling back to group_workspace only for Closed workspaces.
4951 agent_ui::test_support::init_test(cx);
4952 cx.update(|cx| {
4953 ThreadStore::init_global(cx);
4954 ThreadMetadataStore::init_global(cx);
4955 language_model::LanguageModelRegistry::test(cx);
4956 prompt_store::init(cx);
4957 });
4958
4959 let fs = FakeFs::new(cx.executor());
4960
4961 fs.insert_tree(
4962 "/project",
4963 serde_json::json!({
4964 ".git": {},
4965 "src": {},
4966 }),
4967 )
4968 .await;
4969
4970 fs.add_linked_worktree_for_repo(
4971 Path::new("/project/.git"),
4972 false,
4973 git::repository::Worktree {
4974 path: std::path::PathBuf::from("/wt-feature-a"),
4975 ref_name: Some("refs/heads/feature-a".into()),
4976 sha: "aaa".into(),
4977 is_main: false,
4978 is_bare: false,
4979 },
4980 )
4981 .await;
4982
4983 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4984
4985 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4986 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4987
4988 main_project
4989 .update(cx, |p, cx| p.git_scans_complete(cx))
4990 .await;
4991 worktree_project
4992 .update(cx, |p, cx| p.git_scans_complete(cx))
4993 .await;
4994
4995 let (multi_workspace, cx) =
4996 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4997
4998 let sidebar = setup_sidebar(&multi_workspace, cx);
4999
5000 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5001 mw.test_add_workspace(worktree_project.clone(), window, cx)
5002 });
5003
5004 // Activate main workspace so the sidebar tracks the main panel.
5005 multi_workspace.update_in(cx, |mw, window, cx| {
5006 let workspace = mw.workspaces().next().unwrap().clone();
5007 mw.activate(workspace, window, cx);
5008 });
5009
5010 let main_workspace =
5011 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5012 let main_panel = add_agent_panel(&main_workspace, cx);
5013 let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
5014
5015 // Open Thread 2 in the main panel and keep it running.
5016 let connection = StubAgentConnection::new();
5017 open_thread_with_connection(&main_panel, connection.clone(), cx);
5018 send_message(&main_panel, cx);
5019
5020 let thread2_session_id = active_session_id(&main_panel, cx);
5021
5022 cx.update(|_, cx| {
5023 connection.send_update(
5024 thread2_session_id.clone(),
5025 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
5026 cx,
5027 );
5028 });
5029
5030 // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
5031 save_thread_metadata(
5032 thread2_session_id.clone(),
5033 Some("Thread 2".into()),
5034 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5035 None,
5036 &main_project,
5037 cx,
5038 );
5039
5040 // Save thread 1's metadata with the worktree path and an older timestamp so
5041 // it sorts below thread 2. archive_thread will find it as the "next" candidate.
5042 let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
5043 save_thread_metadata(
5044 thread1_session_id,
5045 Some("Thread 1".into()),
5046 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5047 None,
5048 &worktree_project,
5049 cx,
5050 );
5051
5052 cx.run_until_parked();
5053
5054 // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
5055 let entries_before = visible_entries_as_strings(&sidebar, cx);
5056 assert!(
5057 entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
5058 "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
5059 entries_before
5060 );
5061
5062 // The sidebar should track T2 as the focused thread (derived from the
5063 // main panel's active view).
5064 sidebar.read_with(cx, |s, _| {
5065 assert_active_thread(
5066 s,
5067 &thread2_session_id,
5068 "focused thread should be Thread 2 before archiving",
5069 );
5070 });
5071
5072 // Archive thread 2.
5073 sidebar.update_in(cx, |sidebar, window, cx| {
5074 sidebar.archive_thread(&thread2_session_id, window, cx);
5075 });
5076
5077 cx.run_until_parked();
5078
5079 // The main panel's active thread must still be thread 2.
5080 let main_active = main_panel.read_with(cx, |panel, cx| {
5081 panel
5082 .active_agent_thread(cx)
5083 .map(|t| t.read(cx).session_id().clone())
5084 });
5085 assert_eq!(
5086 main_active,
5087 Some(thread2_session_id.clone()),
5088 "main panel should not have been taken over by loading the linked-worktree thread T1; \
5089 before the fix, archive_thread used group_workspace instead of next.workspace, \
5090 causing T1 to be loaded in the wrong panel"
5091 );
5092
5093 // Thread 1 should still appear in the sidebar with its worktree chip
5094 // (Thread 2 was archived so it is gone from the list).
5095 let entries_after = visible_entries_as_strings(&sidebar, cx);
5096 assert!(
5097 entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
5098 "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
5099 entries_after
5100 );
5101}
5102
5103#[gpui::test]
5104async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
5105 // When the last non-archived thread for a linked worktree is archived,
5106 // the linked worktree workspace should be removed from the multi-workspace.
5107 // The main worktree workspace should remain (it's always reachable via
5108 // the project header).
5109 init_test(cx);
5110 let fs = FakeFs::new(cx.executor());
5111
5112 fs.insert_tree(
5113 "/project",
5114 serde_json::json!({
5115 ".git": {
5116 "worktrees": {
5117 "feature-a": {
5118 "commondir": "../../",
5119 "HEAD": "ref: refs/heads/feature-a",
5120 },
5121 },
5122 },
5123 "src": {},
5124 }),
5125 )
5126 .await;
5127
5128 fs.insert_tree(
5129 "/wt-feature-a",
5130 serde_json::json!({
5131 ".git": "gitdir: /project/.git/worktrees/feature-a",
5132 "src": {},
5133 }),
5134 )
5135 .await;
5136
5137 fs.add_linked_worktree_for_repo(
5138 Path::new("/project/.git"),
5139 false,
5140 git::repository::Worktree {
5141 path: PathBuf::from("/wt-feature-a"),
5142 ref_name: Some("refs/heads/feature-a".into()),
5143 sha: "abc".into(),
5144 is_main: false,
5145 is_bare: false,
5146 },
5147 )
5148 .await;
5149
5150 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5151
5152 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5153 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5154
5155 main_project
5156 .update(cx, |p, cx| p.git_scans_complete(cx))
5157 .await;
5158 worktree_project
5159 .update(cx, |p, cx| p.git_scans_complete(cx))
5160 .await;
5161
5162 let (multi_workspace, cx) =
5163 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5164 let sidebar = setup_sidebar(&multi_workspace, cx);
5165
5166 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5167 mw.test_add_workspace(worktree_project.clone(), window, cx)
5168 });
5169
5170 // Save a thread for the main project.
5171 save_thread_metadata(
5172 acp::SessionId::new(Arc::from("main-thread")),
5173 Some("Main Thread".into()),
5174 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5175 None,
5176 &main_project,
5177 cx,
5178 );
5179
5180 // Save a thread for the linked worktree.
5181 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
5182 save_thread_metadata(
5183 wt_thread_id.clone(),
5184 Some("Worktree Thread".into()),
5185 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5186 None,
5187 &worktree_project,
5188 cx,
5189 );
5190 cx.run_until_parked();
5191
5192 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5193 cx.run_until_parked();
5194
5195 // Should have 2 workspaces.
5196 assert_eq!(
5197 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5198 2,
5199 "should start with 2 workspaces (main + linked worktree)"
5200 );
5201
5202 // Archive the worktree thread (the only thread for /wt-feature-a).
5203 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5204 sidebar.archive_thread(&wt_thread_id, window, cx);
5205 });
5206
5207 // archive_thread spawns a multi-layered chain of tasks (workspace
5208 // removal → git persist → disk removal), each of which may spawn
5209 // further background work. Each run_until_parked() call drives one
5210 // layer of pending work.
5211
5212 cx.run_until_parked();
5213
5214 // The linked worktree workspace should have been removed.
5215 assert_eq!(
5216 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5217 1,
5218 "linked worktree workspace should be removed after archiving its last thread"
5219 );
5220
5221 // The linked worktree checkout directory should also be removed from disk.
5222 assert!(
5223 !fs.is_dir(Path::new("/wt-feature-a")).await,
5224 "linked worktree directory should be removed from disk after archiving its last thread"
5225 );
5226
5227 // The main thread should still be visible.
5228 let entries = visible_entries_as_strings(&sidebar, cx);
5229 assert!(
5230 entries.iter().any(|e| e.contains("Main Thread")),
5231 "main thread should still be visible: {entries:?}"
5232 );
5233 assert!(
5234 !entries.iter().any(|e| e.contains("Worktree Thread")),
5235 "archived worktree thread should not be visible: {entries:?}"
5236 );
5237
5238 // The archived thread must retain its folder_paths so it can be
5239 // restored to the correct workspace later.
5240 let wt_thread_id = cx.update(|_window, cx| {
5241 ThreadMetadataStore::global(cx)
5242 .read(cx)
5243 .entry_by_session(&wt_thread_id)
5244 .unwrap()
5245 .thread_id
5246 });
5247 let archived_paths = cx.update(|_window, cx| {
5248 ThreadMetadataStore::global(cx)
5249 .read(cx)
5250 .entry(wt_thread_id)
5251 .unwrap()
5252 .folder_paths()
5253 .clone()
5254 });
5255 assert_eq!(
5256 archived_paths.paths(),
5257 &[PathBuf::from("/wt-feature-a")],
5258 "archived thread must retain its folder_paths for restore"
5259 );
5260}
5261
5262#[gpui::test]
5263async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
5264 // restore_worktree_via_git should succeed when the branch has moved
5265 // to a different SHA since archival. The worktree stays in detached
5266 // HEAD and the moved branch is left untouched.
5267 init_test(cx);
5268 let fs = FakeFs::new(cx.executor());
5269
5270 fs.insert_tree(
5271 "/project",
5272 serde_json::json!({
5273 ".git": {
5274 "worktrees": {
5275 "feature-a": {
5276 "commondir": "../../",
5277 "HEAD": "ref: refs/heads/feature-a",
5278 },
5279 },
5280 },
5281 "src": {},
5282 }),
5283 )
5284 .await;
5285 fs.insert_tree(
5286 "/wt-feature-a",
5287 serde_json::json!({
5288 ".git": "gitdir: /project/.git/worktrees/feature-a",
5289 "src": {},
5290 }),
5291 )
5292 .await;
5293 fs.add_linked_worktree_for_repo(
5294 Path::new("/project/.git"),
5295 false,
5296 git::repository::Worktree {
5297 path: PathBuf::from("/wt-feature-a"),
5298 ref_name: Some("refs/heads/feature-a".into()),
5299 sha: "original-sha".into(),
5300 is_main: false,
5301 is_bare: false,
5302 },
5303 )
5304 .await;
5305 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5306
5307 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5308 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5309 main_project
5310 .update(cx, |p, cx| p.git_scans_complete(cx))
5311 .await;
5312 worktree_project
5313 .update(cx, |p, cx| p.git_scans_complete(cx))
5314 .await;
5315
5316 let (multi_workspace, _cx) =
5317 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5318 multi_workspace.update_in(_cx, |mw, window, cx| {
5319 mw.test_add_workspace(worktree_project.clone(), window, cx)
5320 });
5321
5322 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5323 project.repositories(cx).values().next().unwrap().clone()
5324 });
5325 let (staged_hash, unstaged_hash) = cx
5326 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5327 .await
5328 .unwrap()
5329 .unwrap();
5330
5331 // Move the branch to a different SHA.
5332 fs.with_git_state(Path::new("/project/.git"), false, |state| {
5333 state
5334 .refs
5335 .insert("refs/heads/feature-a".into(), "moved-sha".into());
5336 })
5337 .unwrap();
5338
5339 let result = cx
5340 .spawn(|mut cx| async move {
5341 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5342 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5343 id: 1,
5344 worktree_path: PathBuf::from("/wt-feature-a"),
5345 main_repo_path: PathBuf::from("/project"),
5346 branch_name: Some("feature-a".to_string()),
5347 staged_commit_hash: staged_hash,
5348 unstaged_commit_hash: unstaged_hash,
5349 original_commit_hash: "original-sha".to_string(),
5350 },
5351 &mut cx,
5352 )
5353 .await
5354 })
5355 .await;
5356
5357 assert!(
5358 result.is_ok(),
5359 "restore should succeed even when branch has moved: {:?}",
5360 result.err()
5361 );
5362
5363 // The moved branch ref should be completely untouched.
5364 let branch_sha = fs
5365 .with_git_state(Path::new("/project/.git"), false, |state| {
5366 state.refs.get("refs/heads/feature-a").cloned()
5367 })
5368 .unwrap();
5369 assert_eq!(
5370 branch_sha.as_deref(),
5371 Some("moved-sha"),
5372 "the moved branch ref should not be modified by the restore"
5373 );
5374}
5375
5376#[gpui::test]
5377async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
5378 // restore_worktree_via_git should succeed when the branch still
5379 // points at the same SHA as at archive time.
5380 init_test(cx);
5381 let fs = FakeFs::new(cx.executor());
5382
5383 fs.insert_tree(
5384 "/project",
5385 serde_json::json!({
5386 ".git": {
5387 "worktrees": {
5388 "feature-b": {
5389 "commondir": "../../",
5390 "HEAD": "ref: refs/heads/feature-b",
5391 },
5392 },
5393 },
5394 "src": {},
5395 }),
5396 )
5397 .await;
5398 fs.insert_tree(
5399 "/wt-feature-b",
5400 serde_json::json!({
5401 ".git": "gitdir: /project/.git/worktrees/feature-b",
5402 "src": {},
5403 }),
5404 )
5405 .await;
5406 fs.add_linked_worktree_for_repo(
5407 Path::new("/project/.git"),
5408 false,
5409 git::repository::Worktree {
5410 path: PathBuf::from("/wt-feature-b"),
5411 ref_name: Some("refs/heads/feature-b".into()),
5412 sha: "original-sha".into(),
5413 is_main: false,
5414 is_bare: false,
5415 },
5416 )
5417 .await;
5418 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5419
5420 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5421 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
5422 main_project
5423 .update(cx, |p, cx| p.git_scans_complete(cx))
5424 .await;
5425 worktree_project
5426 .update(cx, |p, cx| p.git_scans_complete(cx))
5427 .await;
5428
5429 let (multi_workspace, _cx) =
5430 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5431 multi_workspace.update_in(_cx, |mw, window, cx| {
5432 mw.test_add_workspace(worktree_project.clone(), window, cx)
5433 });
5434
5435 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5436 project.repositories(cx).values().next().unwrap().clone()
5437 });
5438 let (staged_hash, unstaged_hash) = cx
5439 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5440 .await
5441 .unwrap()
5442 .unwrap();
5443
5444 // refs/heads/feature-b already points at "original-sha" (set by
5445 // add_linked_worktree_for_repo), matching original_commit_hash.
5446
5447 let result = cx
5448 .spawn(|mut cx| async move {
5449 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5450 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5451 id: 1,
5452 worktree_path: PathBuf::from("/wt-feature-b"),
5453 main_repo_path: PathBuf::from("/project"),
5454 branch_name: Some("feature-b".to_string()),
5455 staged_commit_hash: staged_hash,
5456 unstaged_commit_hash: unstaged_hash,
5457 original_commit_hash: "original-sha".to_string(),
5458 },
5459 &mut cx,
5460 )
5461 .await
5462 })
5463 .await;
5464
5465 assert!(
5466 result.is_ok(),
5467 "restore should succeed when branch has not moved: {:?}",
5468 result.err()
5469 );
5470}
5471
5472#[gpui::test]
5473async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
5474 // restore_worktree_via_git should succeed when the branch no longer
5475 // exists (e.g. it was deleted while the thread was archived). The
5476 // code should attempt to recreate the branch.
5477 init_test(cx);
5478 let fs = FakeFs::new(cx.executor());
5479
5480 fs.insert_tree(
5481 "/project",
5482 serde_json::json!({
5483 ".git": {
5484 "worktrees": {
5485 "feature-d": {
5486 "commondir": "../../",
5487 "HEAD": "ref: refs/heads/feature-d",
5488 },
5489 },
5490 },
5491 "src": {},
5492 }),
5493 )
5494 .await;
5495 fs.insert_tree(
5496 "/wt-feature-d",
5497 serde_json::json!({
5498 ".git": "gitdir: /project/.git/worktrees/feature-d",
5499 "src": {},
5500 }),
5501 )
5502 .await;
5503 fs.add_linked_worktree_for_repo(
5504 Path::new("/project/.git"),
5505 false,
5506 git::repository::Worktree {
5507 path: PathBuf::from("/wt-feature-d"),
5508 ref_name: Some("refs/heads/feature-d".into()),
5509 sha: "original-sha".into(),
5510 is_main: false,
5511 is_bare: false,
5512 },
5513 )
5514 .await;
5515 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5516
5517 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5518 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
5519 main_project
5520 .update(cx, |p, cx| p.git_scans_complete(cx))
5521 .await;
5522 worktree_project
5523 .update(cx, |p, cx| p.git_scans_complete(cx))
5524 .await;
5525
5526 let (multi_workspace, _cx) =
5527 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5528 multi_workspace.update_in(_cx, |mw, window, cx| {
5529 mw.test_add_workspace(worktree_project.clone(), window, cx)
5530 });
5531
5532 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5533 project.repositories(cx).values().next().unwrap().clone()
5534 });
5535 let (staged_hash, unstaged_hash) = cx
5536 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5537 .await
5538 .unwrap()
5539 .unwrap();
5540
5541 // Remove the branch ref so change_branch will fail.
5542 fs.with_git_state(Path::new("/project/.git"), false, |state| {
5543 state.refs.remove("refs/heads/feature-d");
5544 })
5545 .unwrap();
5546
5547 let result = cx
5548 .spawn(|mut cx| async move {
5549 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5550 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5551 id: 1,
5552 worktree_path: PathBuf::from("/wt-feature-d"),
5553 main_repo_path: PathBuf::from("/project"),
5554 branch_name: Some("feature-d".to_string()),
5555 staged_commit_hash: staged_hash,
5556 unstaged_commit_hash: unstaged_hash,
5557 original_commit_hash: "original-sha".to_string(),
5558 },
5559 &mut cx,
5560 )
5561 .await
5562 })
5563 .await;
5564
5565 assert!(
5566 result.is_ok(),
5567 "restore should succeed when branch does not exist: {:?}",
5568 result.err()
5569 );
5570}
5571
5572#[gpui::test]
5573async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
5574 // Activating an archived linked worktree thread whose directory has
5575 // been deleted should reuse the existing main repo workspace, not
5576 // create a new one. The provisional ProjectGroupKey must be derived
5577 // from main_worktree_paths so that find_or_create_local_workspace
5578 // matches the main repo workspace when the worktree path is absent.
5579 init_test(cx);
5580 let fs = FakeFs::new(cx.executor());
5581
5582 fs.insert_tree(
5583 "/project",
5584 serde_json::json!({
5585 ".git": {
5586 "worktrees": {
5587 "feature-c": {
5588 "commondir": "../../",
5589 "HEAD": "ref: refs/heads/feature-c",
5590 },
5591 },
5592 },
5593 "src": {},
5594 }),
5595 )
5596 .await;
5597
5598 fs.insert_tree(
5599 "/wt-feature-c",
5600 serde_json::json!({
5601 ".git": "gitdir: /project/.git/worktrees/feature-c",
5602 "src": {},
5603 }),
5604 )
5605 .await;
5606
5607 fs.add_linked_worktree_for_repo(
5608 Path::new("/project/.git"),
5609 false,
5610 git::repository::Worktree {
5611 path: PathBuf::from("/wt-feature-c"),
5612 ref_name: Some("refs/heads/feature-c".into()),
5613 sha: "original-sha".into(),
5614 is_main: false,
5615 is_bare: false,
5616 },
5617 )
5618 .await;
5619
5620 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5621
5622 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5623 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
5624
5625 main_project
5626 .update(cx, |p, cx| p.git_scans_complete(cx))
5627 .await;
5628 worktree_project
5629 .update(cx, |p, cx| p.git_scans_complete(cx))
5630 .await;
5631
5632 let (multi_workspace, cx) =
5633 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5634 let sidebar = setup_sidebar(&multi_workspace, cx);
5635
5636 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5637 mw.test_add_workspace(worktree_project.clone(), window, cx)
5638 });
5639
5640 // Save thread metadata for the linked worktree.
5641 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
5642 save_thread_metadata(
5643 wt_session_id.clone(),
5644 Some("Worktree Thread C".into()),
5645 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5646 None,
5647 &worktree_project,
5648 cx,
5649 );
5650 cx.run_until_parked();
5651
5652 let thread_id = cx.update(|_window, cx| {
5653 ThreadMetadataStore::global(cx)
5654 .read(cx)
5655 .entry_by_session(&wt_session_id)
5656 .unwrap()
5657 .thread_id
5658 });
5659
5660 // Archive the thread without creating ArchivedGitWorktree records.
5661 let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
5662 cx.update(|_window, cx| {
5663 store.update(cx, |store, cx| store.archive(thread_id, None, cx));
5664 });
5665 cx.run_until_parked();
5666
5667 // Remove the worktree workspace and delete the worktree from disk.
5668 let main_workspace =
5669 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5670 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
5671 mw.remove(
5672 vec![worktree_workspace],
5673 move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
5674 window,
5675 cx,
5676 )
5677 });
5678 remove_task.await.ok();
5679 cx.run_until_parked();
5680 cx.run_until_parked();
5681 fs.remove_dir(
5682 Path::new("/wt-feature-c"),
5683 fs::RemoveOptions {
5684 recursive: true,
5685 ignore_if_not_exists: true,
5686 },
5687 )
5688 .await
5689 .unwrap();
5690
5691 let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5692 assert_eq!(
5693 workspace_count_before, 1,
5694 "should have only the main workspace"
5695 );
5696
5697 // Activate the archived thread. The worktree path is missing from
5698 // disk, so find_or_create_local_workspace falls back to the
5699 // provisional ProjectGroupKey to find a matching workspace.
5700 let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
5701 sidebar.update_in(cx, |sidebar, window, cx| {
5702 sidebar.activate_archived_thread(metadata, window, cx);
5703 });
5704 cx.run_until_parked();
5705
5706 // The provisional key should use [/project] (the main repo),
5707 // which matches the existing main workspace. If it incorrectly
5708 // used [/wt-feature-c] (the linked worktree path), no workspace
5709 // would match and a spurious new one would be created.
5710 let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5711 assert_eq!(
5712 workspace_count_after, 1,
5713 "restoring a linked worktree thread should reuse the main repo workspace, \
5714 not create a new one (workspace count went from {workspace_count_before} to \
5715 {workspace_count_after})"
5716 );
5717}
5718
5719#[gpui::test]
5720async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
5721 cx: &mut TestAppContext,
5722) {
5723 // A remote thread at the same path as a local linked worktree thread
5724 // should not prevent the local workspace from being removed when the
5725 // local thread is archived (the last local thread for that worktree).
5726 init_test(cx);
5727 let fs = FakeFs::new(cx.executor());
5728
5729 fs.insert_tree(
5730 "/project",
5731 serde_json::json!({
5732 ".git": {
5733 "worktrees": {
5734 "feature-a": {
5735 "commondir": "../../",
5736 "HEAD": "ref: refs/heads/feature-a",
5737 },
5738 },
5739 },
5740 "src": {},
5741 }),
5742 )
5743 .await;
5744
5745 fs.insert_tree(
5746 "/wt-feature-a",
5747 serde_json::json!({
5748 ".git": "gitdir: /project/.git/worktrees/feature-a",
5749 "src": {},
5750 }),
5751 )
5752 .await;
5753
5754 fs.add_linked_worktree_for_repo(
5755 Path::new("/project/.git"),
5756 false,
5757 git::repository::Worktree {
5758 path: PathBuf::from("/wt-feature-a"),
5759 ref_name: Some("refs/heads/feature-a".into()),
5760 sha: "abc".into(),
5761 is_main: false,
5762 is_bare: false,
5763 },
5764 )
5765 .await;
5766
5767 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5768
5769 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5770 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5771
5772 main_project
5773 .update(cx, |p, cx| p.git_scans_complete(cx))
5774 .await;
5775 worktree_project
5776 .update(cx, |p, cx| p.git_scans_complete(cx))
5777 .await;
5778
5779 let (multi_workspace, cx) =
5780 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5781 let sidebar = setup_sidebar(&multi_workspace, cx);
5782
5783 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5784 mw.test_add_workspace(worktree_project.clone(), window, cx)
5785 });
5786
5787 // Save a thread for the main project.
5788 save_thread_metadata(
5789 acp::SessionId::new(Arc::from("main-thread")),
5790 Some("Main Thread".into()),
5791 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5792 None,
5793 &main_project,
5794 cx,
5795 );
5796
5797 // Save a local thread for the linked worktree.
5798 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
5799 save_thread_metadata(
5800 wt_thread_id.clone(),
5801 Some("Local Worktree Thread".into()),
5802 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5803 None,
5804 &worktree_project,
5805 cx,
5806 );
5807
5808 // Save a remote thread at the same /wt-feature-a path but on a
5809 // different host. This should NOT count as a remaining thread for
5810 // the local linked worktree workspace.
5811 let remote_host =
5812 remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
5813 cx.update(|_window, cx| {
5814 let metadata = ThreadMetadata {
5815 thread_id: ThreadId::new(),
5816 session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
5817 agent_id: agent::ZED_AGENT_ID.clone(),
5818 title: Some("Remote Worktree Thread".into()),
5819 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5820 created_at: None,
5821 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
5822 "/wt-feature-a",
5823 )])),
5824 archived: false,
5825 remote_connection: Some(remote_host),
5826 };
5827 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5828 store.save(metadata, cx);
5829 });
5830 });
5831 cx.run_until_parked();
5832
5833 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5834 cx.run_until_parked();
5835
5836 assert_eq!(
5837 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5838 2,
5839 "should start with 2 workspaces (main + linked worktree)"
5840 );
5841
5842 // The remote thread should NOT appear in the sidebar (it belongs
5843 // to a different host and no matching remote project group exists).
5844 let entries_before = visible_entries_as_strings(&sidebar, cx);
5845 assert!(
5846 !entries_before
5847 .iter()
5848 .any(|e| e.contains("Remote Worktree Thread")),
5849 "remote thread should not appear in local sidebar: {entries_before:?}"
5850 );
5851
5852 // Archive the local worktree thread.
5853 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5854 sidebar.archive_thread(&wt_thread_id, window, cx);
5855 });
5856
5857 cx.run_until_parked();
5858
5859 // The linked worktree workspace should be removed because the
5860 // only *local* thread for it was archived. The remote thread at
5861 // the same path should not have prevented removal.
5862 assert_eq!(
5863 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5864 1,
5865 "linked worktree workspace should be removed; the remote thread at the same path \
5866 should not count as a remaining local thread"
5867 );
5868
5869 let entries = visible_entries_as_strings(&sidebar, cx);
5870 assert!(
5871 entries.iter().any(|e| e.contains("Main Thread")),
5872 "main thread should still be visible: {entries:?}"
5873 );
5874 assert!(
5875 !entries.iter().any(|e| e.contains("Local Worktree Thread")),
5876 "archived local worktree thread should not be visible: {entries:?}"
5877 );
5878 assert!(
5879 !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
5880 "remote thread should still not appear in local sidebar: {entries:?}"
5881 );
5882}
5883
5884#[gpui::test]
5885async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
5886 // When a multi-root workspace (e.g. [/other, /project]) shares a
5887 // repo with a single-root workspace (e.g. [/project]), linked
5888 // worktree threads from the shared repo should only appear under
5889 // the dedicated group [project], not under [other, project].
5890 agent_ui::test_support::init_test(cx);
5891 cx.update(|cx| {
5892 ThreadStore::init_global(cx);
5893 ThreadMetadataStore::init_global(cx);
5894 language_model::LanguageModelRegistry::test(cx);
5895 prompt_store::init(cx);
5896 });
5897 let fs = FakeFs::new(cx.executor());
5898
5899 // Two independent repos, each with their own git history.
5900 fs.insert_tree(
5901 "/project",
5902 serde_json::json!({
5903 ".git": {},
5904 "src": {},
5905 }),
5906 )
5907 .await;
5908 fs.insert_tree(
5909 "/other",
5910 serde_json::json!({
5911 ".git": {},
5912 "src": {},
5913 }),
5914 )
5915 .await;
5916
5917 // Register the linked worktree in the main repo.
5918 fs.add_linked_worktree_for_repo(
5919 Path::new("/project/.git"),
5920 false,
5921 git::repository::Worktree {
5922 path: std::path::PathBuf::from("/wt-feature-a"),
5923 ref_name: Some("refs/heads/feature-a".into()),
5924 sha: "aaa".into(),
5925 is_main: false,
5926 is_bare: false,
5927 },
5928 )
5929 .await;
5930
5931 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5932
5933 // Workspace 1: just /project.
5934 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5935 project_only
5936 .update(cx, |p, cx| p.git_scans_complete(cx))
5937 .await;
5938
5939 // Workspace 2: /other and /project together (multi-root).
5940 let multi_root =
5941 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
5942 multi_root
5943 .update(cx, |p, cx| p.git_scans_complete(cx))
5944 .await;
5945
5946 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5947 worktree_project
5948 .update(cx, |p, cx| p.git_scans_complete(cx))
5949 .await;
5950
5951 // Save a thread under the linked worktree path BEFORE setting up
5952 // the sidebar and panels, so that reconciliation sees the [project]
5953 // group as non-empty and doesn't create a spurious draft there.
5954 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
5955 save_thread_metadata(
5956 wt_session_id,
5957 Some("Worktree Thread".into()),
5958 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5959 None,
5960 &worktree_project,
5961 cx,
5962 );
5963
5964 let (multi_workspace, cx) =
5965 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
5966 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5967 let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5968 mw.test_add_workspace(multi_root.clone(), window, cx)
5969 });
5970 add_agent_panel(&multi_root_workspace, cx);
5971 cx.run_until_parked();
5972
5973 // The thread should appear only under [project] (the dedicated
5974 // group for the /project repo), not under [other, project].
5975 assert_eq!(
5976 visible_entries_as_strings(&sidebar, cx),
5977 vec![
5978 //
5979 "v [other, project]",
5980 "v [project]",
5981 " Worktree Thread {wt-feature-a}",
5982 ]
5983 );
5984}
5985
5986#[gpui::test]
5987async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
5988 let project = init_test_project_with_agent_panel("/my-project", cx).await;
5989 let (multi_workspace, cx) =
5990 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5991 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5992
5993 let switcher_ids =
5994 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
5995 sidebar.read_with(cx, |sidebar, cx| {
5996 let switcher = sidebar
5997 .thread_switcher
5998 .as_ref()
5999 .expect("switcher should be open");
6000 switcher
6001 .read(cx)
6002 .entries()
6003 .iter()
6004 .map(|e| e.session_id.clone())
6005 .collect()
6006 })
6007 };
6008
6009 let switcher_selected_id =
6010 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
6011 sidebar.read_with(cx, |sidebar, cx| {
6012 let switcher = sidebar
6013 .thread_switcher
6014 .as_ref()
6015 .expect("switcher should be open");
6016 let s = switcher.read(cx);
6017 s.selected_entry()
6018 .expect("should have selection")
6019 .session_id
6020 .clone()
6021 })
6022 };
6023
6024 // ── Setup: create three threads with distinct created_at times ──────
6025 // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
6026 // We send messages in each so they also get last_message_sent_or_queued timestamps.
6027 let connection_c = StubAgentConnection::new();
6028 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6029 acp::ContentChunk::new("Done C".into()),
6030 )]);
6031 open_thread_with_connection(&panel, connection_c, cx);
6032 send_message(&panel, cx);
6033 let session_id_c = active_session_id(&panel, cx);
6034 save_thread_metadata(
6035 session_id_c.clone(),
6036 Some("Thread C".into()),
6037 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6038 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
6039 &project,
6040 cx,
6041 );
6042
6043 let connection_b = StubAgentConnection::new();
6044 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6045 acp::ContentChunk::new("Done B".into()),
6046 )]);
6047 open_thread_with_connection(&panel, connection_b, cx);
6048 send_message(&panel, cx);
6049 let session_id_b = active_session_id(&panel, cx);
6050 save_thread_metadata(
6051 session_id_b.clone(),
6052 Some("Thread B".into()),
6053 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6054 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
6055 &project,
6056 cx,
6057 );
6058
6059 let connection_a = StubAgentConnection::new();
6060 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6061 acp::ContentChunk::new("Done A".into()),
6062 )]);
6063 open_thread_with_connection(&panel, connection_a, cx);
6064 send_message(&panel, cx);
6065 let session_id_a = active_session_id(&panel, cx);
6066 save_thread_metadata(
6067 session_id_a.clone(),
6068 Some("Thread A".into()),
6069 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
6070 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
6071 &project,
6072 cx,
6073 );
6074
6075 // All three threads are now live. Thread A was opened last, so it's
6076 // the one being viewed. Opening each thread called record_thread_access,
6077 // so all three have last_accessed_at set.
6078 // Access order is: A (most recent), B, C (oldest).
6079
6080 // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
6081 focus_sidebar(&sidebar, cx);
6082 sidebar.update_in(cx, |sidebar, window, cx| {
6083 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6084 });
6085 cx.run_until_parked();
6086
6087 // All three have last_accessed_at, so they sort by access time.
6088 // A was accessed most recently (it's the currently viewed thread),
6089 // then B, then C.
6090 assert_eq!(
6091 switcher_ids(&sidebar, cx),
6092 vec![
6093 session_id_a.clone(),
6094 session_id_b.clone(),
6095 session_id_c.clone()
6096 ],
6097 );
6098 // First ctrl-tab selects the second entry (B).
6099 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
6100
6101 // Dismiss the switcher without confirming.
6102 sidebar.update_in(cx, |sidebar, _window, cx| {
6103 sidebar.dismiss_thread_switcher(cx);
6104 });
6105 cx.run_until_parked();
6106
6107 // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
6108 sidebar.update_in(cx, |sidebar, window, cx| {
6109 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6110 });
6111 cx.run_until_parked();
6112
6113 // Cycle twice to land on Thread C (index 2).
6114 sidebar.read_with(cx, |sidebar, cx| {
6115 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6116 assert_eq!(switcher.read(cx).selected_index(), 1);
6117 });
6118 sidebar.update_in(cx, |sidebar, _window, cx| {
6119 sidebar
6120 .thread_switcher
6121 .as_ref()
6122 .unwrap()
6123 .update(cx, |s, cx| s.cycle_selection(cx));
6124 });
6125 cx.run_until_parked();
6126 assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
6127
6128 assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
6129
6130 // Confirm on Thread C.
6131 sidebar.update_in(cx, |sidebar, window, cx| {
6132 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6133 let focus = switcher.focus_handle(cx);
6134 focus.dispatch_action(&menu::Confirm, window, cx);
6135 });
6136 cx.run_until_parked();
6137
6138 // Switcher should be dismissed after confirm.
6139 sidebar.read_with(cx, |sidebar, _cx| {
6140 assert!(
6141 sidebar.thread_switcher.is_none(),
6142 "switcher should be dismissed"
6143 );
6144 });
6145
6146 sidebar.update(cx, |sidebar, _cx| {
6147 let last_accessed = sidebar
6148 .thread_last_accessed
6149 .keys()
6150 .cloned()
6151 .collect::<Vec<_>>();
6152 assert_eq!(last_accessed.len(), 1);
6153 assert!(last_accessed.contains(&session_id_c));
6154 assert!(
6155 is_active_session(&sidebar, &session_id_c),
6156 "active_entry should be Thread({session_id_c:?})"
6157 );
6158 });
6159
6160 sidebar.update_in(cx, |sidebar, window, cx| {
6161 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6162 });
6163 cx.run_until_parked();
6164
6165 assert_eq!(
6166 switcher_ids(&sidebar, cx),
6167 vec![
6168 session_id_c.clone(),
6169 session_id_a.clone(),
6170 session_id_b.clone()
6171 ],
6172 );
6173
6174 // Confirm on Thread A.
6175 sidebar.update_in(cx, |sidebar, window, cx| {
6176 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6177 let focus = switcher.focus_handle(cx);
6178 focus.dispatch_action(&menu::Confirm, window, cx);
6179 });
6180 cx.run_until_parked();
6181
6182 sidebar.update(cx, |sidebar, _cx| {
6183 let last_accessed = sidebar
6184 .thread_last_accessed
6185 .keys()
6186 .cloned()
6187 .collect::<Vec<_>>();
6188 assert_eq!(last_accessed.len(), 2);
6189 assert!(last_accessed.contains(&session_id_c));
6190 assert!(last_accessed.contains(&session_id_a));
6191 assert!(
6192 is_active_session(&sidebar, &session_id_a),
6193 "active_entry should be Thread({session_id_a:?})"
6194 );
6195 });
6196
6197 sidebar.update_in(cx, |sidebar, window, cx| {
6198 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6199 });
6200 cx.run_until_parked();
6201
6202 assert_eq!(
6203 switcher_ids(&sidebar, cx),
6204 vec![
6205 session_id_a.clone(),
6206 session_id_c.clone(),
6207 session_id_b.clone(),
6208 ],
6209 );
6210
6211 sidebar.update_in(cx, |sidebar, _window, cx| {
6212 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6213 switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
6214 });
6215 cx.run_until_parked();
6216
6217 // Confirm on Thread B.
6218 sidebar.update_in(cx, |sidebar, window, cx| {
6219 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6220 let focus = switcher.focus_handle(cx);
6221 focus.dispatch_action(&menu::Confirm, window, cx);
6222 });
6223 cx.run_until_parked();
6224
6225 sidebar.update(cx, |sidebar, _cx| {
6226 let last_accessed = sidebar
6227 .thread_last_accessed
6228 .keys()
6229 .cloned()
6230 .collect::<Vec<_>>();
6231 assert_eq!(last_accessed.len(), 3);
6232 assert!(last_accessed.contains(&session_id_c));
6233 assert!(last_accessed.contains(&session_id_a));
6234 assert!(last_accessed.contains(&session_id_b));
6235 assert!(
6236 is_active_session(&sidebar, &session_id_b),
6237 "active_entry should be Thread({session_id_b:?})"
6238 );
6239 });
6240
6241 // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
6242 // This thread was never opened in a panel — it only exists in metadata.
6243 save_thread_metadata(
6244 acp::SessionId::new(Arc::from("thread-historical")),
6245 Some("Historical Thread".into()),
6246 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
6247 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
6248 &project,
6249 cx,
6250 );
6251
6252 sidebar.update_in(cx, |sidebar, window, cx| {
6253 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6254 });
6255 cx.run_until_parked();
6256
6257 // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
6258 // so it falls to tier 3 (sorted by created_at). It should appear after all
6259 // accessed threads, even though its created_at (June 2024) is much later
6260 // than the others.
6261 //
6262 // But the live threads (A, B, C) each had send_message called which sets
6263 // last_message_sent_or_queued. So for the accessed threads (tier 1) the
6264 // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
6265 let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
6266
6267 let ids = switcher_ids(&sidebar, cx);
6268 assert_eq!(
6269 ids,
6270 vec![
6271 session_id_b.clone(),
6272 session_id_a.clone(),
6273 session_id_c.clone(),
6274 session_id_hist.clone()
6275 ],
6276 );
6277
6278 sidebar.update_in(cx, |sidebar, _window, cx| {
6279 sidebar.dismiss_thread_switcher(cx);
6280 });
6281 cx.run_until_parked();
6282
6283 // ── 4. Add another historical thread with older created_at ─────────
6284 save_thread_metadata(
6285 acp::SessionId::new(Arc::from("thread-old-historical")),
6286 Some("Old Historical Thread".into()),
6287 chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
6288 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
6289 &project,
6290 cx,
6291 );
6292
6293 sidebar.update_in(cx, |sidebar, window, cx| {
6294 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6295 });
6296 cx.run_until_parked();
6297
6298 // Both historical threads have no access or message times. They should
6299 // appear after accessed threads, sorted by created_at (newest first).
6300 let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
6301 let ids = switcher_ids(&sidebar, cx);
6302 assert_eq!(
6303 ids,
6304 vec![
6305 session_id_b,
6306 session_id_a,
6307 session_id_c,
6308 session_id_hist,
6309 session_id_old_hist,
6310 ],
6311 );
6312
6313 sidebar.update_in(cx, |sidebar, _window, cx| {
6314 sidebar.dismiss_thread_switcher(cx);
6315 });
6316 cx.run_until_parked();
6317}
6318
6319#[gpui::test]
6320async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
6321 let project = init_test_project("/my-project", cx).await;
6322 let (multi_workspace, cx) =
6323 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6324 let sidebar = setup_sidebar(&multi_workspace, cx);
6325
6326 save_thread_metadata(
6327 acp::SessionId::new(Arc::from("thread-to-archive")),
6328 Some("Thread To Archive".into()),
6329 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6330 None,
6331 &project,
6332 cx,
6333 );
6334 cx.run_until_parked();
6335
6336 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6337 cx.run_until_parked();
6338
6339 let entries = visible_entries_as_strings(&sidebar, cx);
6340 assert!(
6341 entries.iter().any(|e| e.contains("Thread To Archive")),
6342 "expected thread to be visible before archiving, got: {entries:?}"
6343 );
6344
6345 sidebar.update_in(cx, |sidebar, window, cx| {
6346 sidebar.archive_thread(
6347 &acp::SessionId::new(Arc::from("thread-to-archive")),
6348 window,
6349 cx,
6350 );
6351 });
6352 cx.run_until_parked();
6353
6354 let entries = visible_entries_as_strings(&sidebar, cx);
6355 assert!(
6356 !entries.iter().any(|e| e.contains("Thread To Archive")),
6357 "expected thread to be hidden after archiving, got: {entries:?}"
6358 );
6359
6360 cx.update(|_, cx| {
6361 let store = ThreadMetadataStore::global(cx);
6362 let archived: Vec<_> = store.read(cx).archived_entries().collect();
6363 assert_eq!(archived.len(), 1);
6364 assert_eq!(
6365 archived[0].session_id.as_ref().unwrap().0.as_ref(),
6366 "thread-to-archive"
6367 );
6368 assert!(archived[0].archived);
6369 });
6370}
6371
6372#[gpui::test]
6373async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
6374 // Tests two archive scenarios:
6375 // 1. Archiving a thread in a non-active workspace leaves active_entry
6376 // as the current draft.
6377 // 2. Archiving the thread the user is looking at falls back to a draft
6378 // on the same workspace.
6379 agent_ui::test_support::init_test(cx);
6380 cx.update(|cx| {
6381 ThreadStore::init_global(cx);
6382 ThreadMetadataStore::init_global(cx);
6383 language_model::LanguageModelRegistry::test(cx);
6384 prompt_store::init(cx);
6385 });
6386
6387 let fs = FakeFs::new(cx.executor());
6388 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6389 .await;
6390 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6391 .await;
6392 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6393
6394 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6395 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6396
6397 let (multi_workspace, cx) =
6398 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6399 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6400
6401 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6402 mw.test_add_workspace(project_b.clone(), window, cx)
6403 });
6404 let panel_b = add_agent_panel(&workspace_b, cx);
6405 cx.run_until_parked();
6406
6407 // Explicitly create a draft on workspace_b so the sidebar tracks one.
6408 sidebar.update_in(cx, |sidebar, window, cx| {
6409 sidebar.create_new_thread(&workspace_b, window, cx);
6410 });
6411 cx.run_until_parked();
6412
6413 // --- Scenario 1: archive a thread in the non-active workspace ---
6414
6415 // Create a thread in project-a (non-active — project-b is active).
6416 let connection = acp_thread::StubAgentConnection::new();
6417 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6418 acp::ContentChunk::new("Done".into()),
6419 )]);
6420 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
6421 agent_ui::test_support::send_message(&panel_a, cx);
6422 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
6423 cx.run_until_parked();
6424
6425 sidebar.update_in(cx, |sidebar, window, cx| {
6426 sidebar.archive_thread(&thread_a, window, cx);
6427 });
6428 cx.run_until_parked();
6429
6430 // active_entry should still be a draft on workspace_b (the active one).
6431 sidebar.read_with(cx, |sidebar, _| {
6432 assert!(
6433 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
6434 "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
6435 sidebar.active_entry,
6436 );
6437 });
6438
6439 // --- Scenario 2: archive the thread the user is looking at ---
6440
6441 // Create a thread in project-b (the active workspace) and verify it
6442 // becomes the active entry.
6443 let connection = acp_thread::StubAgentConnection::new();
6444 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6445 acp::ContentChunk::new("Done".into()),
6446 )]);
6447 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6448 agent_ui::test_support::send_message(&panel_b, cx);
6449 let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
6450 cx.run_until_parked();
6451
6452 sidebar.read_with(cx, |sidebar, _| {
6453 assert!(
6454 is_active_session(&sidebar, &thread_b),
6455 "expected active_entry to be Thread({thread_b}), got: {:?}",
6456 sidebar.active_entry,
6457 );
6458 });
6459
6460 sidebar.update_in(cx, |sidebar, window, cx| {
6461 sidebar.archive_thread(&thread_b, window, cx);
6462 });
6463 cx.run_until_parked();
6464
6465 // Archiving the active thread clears active_entry (no draft is created).
6466 sidebar.read_with(cx, |sidebar, _| {
6467 assert!(
6468 sidebar.active_entry.is_none(),
6469 "expected None after archiving active thread, got: {:?}",
6470 sidebar.active_entry,
6471 );
6472 });
6473}
6474
6475#[gpui::test]
6476async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
6477 // Full flow: create a thread, archive it (removing the workspace),
6478 // then unarchive. Only the restored thread should appear — no
6479 // leftover drafts or previously-serialized threads.
6480 let project = init_test_project_with_agent_panel("/my-project", cx).await;
6481 let (multi_workspace, cx) =
6482 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6483 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6484 cx.run_until_parked();
6485
6486 // Create a thread and send a message so it's a real thread.
6487 let connection = acp_thread::StubAgentConnection::new();
6488 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6489 acp::ContentChunk::new("Hello".into()),
6490 )]);
6491 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6492 agent_ui::test_support::send_message(&panel, cx);
6493 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6494 cx.run_until_parked();
6495
6496 // Archive it.
6497 sidebar.update_in(cx, |sidebar, window, cx| {
6498 sidebar.archive_thread(&session_id, window, cx);
6499 });
6500 cx.run_until_parked();
6501
6502 // Grab metadata for unarchive.
6503 let thread_id = cx.update(|_, cx| {
6504 ThreadMetadataStore::global(cx)
6505 .read(cx)
6506 .entries()
6507 .find(|e| e.session_id.as_ref() == Some(&session_id))
6508 .map(|e| e.thread_id)
6509 .expect("thread should exist")
6510 });
6511 let metadata = cx.update(|_, cx| {
6512 ThreadMetadataStore::global(cx)
6513 .read(cx)
6514 .entry(thread_id)
6515 .cloned()
6516 .expect("metadata should exist")
6517 });
6518
6519 // Unarchive it — the draft should be replaced by the restored thread.
6520 sidebar.update_in(cx, |sidebar, window, cx| {
6521 sidebar.activate_archived_thread(metadata, window, cx);
6522 });
6523 cx.run_until_parked();
6524
6525 // Only the unarchived thread should be visible — no drafts, no other threads.
6526 let entries = visible_entries_as_strings(&sidebar, cx);
6527 let thread_count = entries
6528 .iter()
6529 .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
6530 .count();
6531 assert_eq!(
6532 thread_count, 1,
6533 "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
6534 );
6535 assert!(
6536 !entries.iter().any(|e| e.contains("Draft")),
6537 "expected no drafts after restoring, got entries: {entries:?}"
6538 );
6539}
6540
6541#[gpui::test]
6542async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
6543 cx: &mut TestAppContext,
6544) {
6545 // When a thread is unarchived into a project group that has no open
6546 // workspace, the sidebar opens a new workspace and loads the thread.
6547 // No spurious draft should appear alongside the unarchived thread.
6548 agent_ui::test_support::init_test(cx);
6549 cx.update(|cx| {
6550 ThreadStore::init_global(cx);
6551 ThreadMetadataStore::init_global(cx);
6552 language_model::LanguageModelRegistry::test(cx);
6553 prompt_store::init(cx);
6554 });
6555
6556 let fs = FakeFs::new(cx.executor());
6557 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6558 .await;
6559 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6560 .await;
6561 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6562
6563 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6564 let (multi_workspace, cx) =
6565 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6566 let sidebar = setup_sidebar(&multi_workspace, cx);
6567 cx.run_until_parked();
6568
6569 // Save an archived thread whose folder_paths point to project-b,
6570 // which has no open workspace.
6571 let session_id = acp::SessionId::new(Arc::from("archived-thread"));
6572 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6573 let thread_id = ThreadId::new();
6574 cx.update(|_, cx| {
6575 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6576 store.save(
6577 ThreadMetadata {
6578 thread_id,
6579 session_id: Some(session_id.clone()),
6580 agent_id: agent::ZED_AGENT_ID.clone(),
6581 title: Some("Unarchived Thread".into()),
6582 updated_at: Utc::now(),
6583 created_at: None,
6584 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6585 archived: true,
6586 remote_connection: None,
6587 },
6588 cx,
6589 )
6590 });
6591 });
6592 cx.run_until_parked();
6593
6594 // Verify no workspace for project-b exists yet.
6595 assert_eq!(
6596 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6597 1,
6598 "should start with only the project-a workspace"
6599 );
6600
6601 // Un-archive the thread — should open project-b workspace and load it.
6602 let metadata = cx.update(|_, cx| {
6603 ThreadMetadataStore::global(cx)
6604 .read(cx)
6605 .entry(thread_id)
6606 .cloned()
6607 .expect("metadata should exist")
6608 });
6609
6610 sidebar.update_in(cx, |sidebar, window, cx| {
6611 sidebar.activate_archived_thread(metadata, window, cx);
6612 });
6613 cx.run_until_parked();
6614
6615 // A second workspace should have been created for project-b.
6616 assert_eq!(
6617 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6618 2,
6619 "should have opened a workspace for the unarchived thread"
6620 );
6621
6622 // The sidebar should show the unarchived thread without a spurious draft
6623 // in the project-b group.
6624 let entries = visible_entries_as_strings(&sidebar, cx);
6625 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6626 // project-a gets a draft (it's the active workspace with no threads),
6627 // but project-b should NOT have one — only the unarchived thread.
6628 assert!(
6629 draft_count <= 1,
6630 "expected at most one draft (for project-a), got entries: {entries:?}"
6631 );
6632 assert!(
6633 entries.iter().any(|e| e.contains("Unarchived Thread")),
6634 "expected unarchived thread to appear, got entries: {entries:?}"
6635 );
6636}
6637
6638#[gpui::test]
6639async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
6640 cx: &mut TestAppContext,
6641) {
6642 agent_ui::test_support::init_test(cx);
6643 cx.update(|cx| {
6644 ThreadStore::init_global(cx);
6645 ThreadMetadataStore::init_global(cx);
6646 language_model::LanguageModelRegistry::test(cx);
6647 prompt_store::init(cx);
6648 });
6649
6650 let fs = FakeFs::new(cx.executor());
6651 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6652 .await;
6653 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6654 .await;
6655 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6656
6657 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6658 let (multi_workspace, cx) =
6659 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6660 let sidebar = setup_sidebar(&multi_workspace, cx);
6661 cx.run_until_parked();
6662
6663 let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
6664 let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
6665 let original_thread_id = ThreadId::new();
6666 cx.update(|_, cx| {
6667 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6668 store.save(
6669 ThreadMetadata {
6670 thread_id: original_thread_id,
6671 session_id: Some(session_id.clone()),
6672 agent_id: agent::ZED_AGENT_ID.clone(),
6673 title: Some("Unarchived Thread".into()),
6674 updated_at: Utc::now(),
6675 created_at: None,
6676 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6677 archived: true,
6678 remote_connection: None,
6679 },
6680 cx,
6681 )
6682 });
6683 });
6684 cx.run_until_parked();
6685
6686 let metadata = cx.update(|_, cx| {
6687 ThreadMetadataStore::global(cx)
6688 .read(cx)
6689 .entry(original_thread_id)
6690 .cloned()
6691 .expect("metadata should exist before unarchive")
6692 });
6693
6694 sidebar.update_in(cx, |sidebar, window, cx| {
6695 sidebar.activate_archived_thread(metadata, window, cx);
6696 });
6697
6698 cx.run_until_parked();
6699
6700 assert_eq!(
6701 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6702 2,
6703 "expected unarchive to open the target workspace"
6704 );
6705
6706 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6707 mw.workspaces()
6708 .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
6709 .cloned()
6710 .expect("expected restored workspace for unarchived thread")
6711 });
6712 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6713 workspace
6714 .panel::<AgentPanel>(cx)
6715 .expect("expected unarchive to install an agent panel in the new workspace")
6716 });
6717
6718 let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6719 assert_eq!(
6720 restored_thread_id,
6721 Some(original_thread_id),
6722 "expected the new workspace's agent panel to target the restored archived thread id"
6723 );
6724
6725 let session_entries = cx.update(|_, cx| {
6726 ThreadMetadataStore::global(cx)
6727 .read(cx)
6728 .entries()
6729 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6730 .cloned()
6731 .collect::<Vec<_>>()
6732 });
6733 assert_eq!(
6734 session_entries.len(),
6735 1,
6736 "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
6737 );
6738 assert_eq!(
6739 session_entries[0].thread_id, original_thread_id,
6740 "expected restore into a new workspace to reuse the original thread id"
6741 );
6742 assert!(
6743 !session_entries[0].archived,
6744 "expected restored thread metadata to be unarchived, got: {:?}",
6745 session_entries[0]
6746 );
6747
6748 let mapped_thread_id = cx.update(|_, cx| {
6749 ThreadMetadataStore::global(cx)
6750 .read(cx)
6751 .entries()
6752 .find(|e| e.session_id.as_ref() == Some(&session_id))
6753 .map(|e| e.thread_id)
6754 });
6755 assert_eq!(
6756 mapped_thread_id,
6757 Some(original_thread_id),
6758 "expected session mapping to remain stable after opening the new workspace"
6759 );
6760
6761 let entries = visible_entries_as_strings(&sidebar, cx);
6762 let real_thread_rows = entries
6763 .iter()
6764 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6765 .filter(|entry| !entry.contains("Draft"))
6766 .count();
6767 assert_eq!(
6768 real_thread_rows, 1,
6769 "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
6770 );
6771 assert!(
6772 entries
6773 .iter()
6774 .any(|entry| entry.contains("Unarchived Thread")),
6775 "expected restored thread row to be visible, got entries: {entries:?}"
6776 );
6777}
6778
6779#[gpui::test]
6780async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
6781 // When a workspace already exists with an empty draft and a thread
6782 // is unarchived into it, the draft should be replaced — not kept
6783 // alongside the loaded thread.
6784 agent_ui::test_support::init_test(cx);
6785 cx.update(|cx| {
6786 ThreadStore::init_global(cx);
6787 ThreadMetadataStore::init_global(cx);
6788 language_model::LanguageModelRegistry::test(cx);
6789 prompt_store::init(cx);
6790 });
6791
6792 let fs = FakeFs::new(cx.executor());
6793 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6794 .await;
6795 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6796
6797 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6798 let (multi_workspace, cx) =
6799 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6800 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6801 cx.run_until_parked();
6802
6803 // Create a thread and send a message so it's no longer a draft.
6804 let connection = acp_thread::StubAgentConnection::new();
6805 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6806 acp::ContentChunk::new("Done".into()),
6807 )]);
6808 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6809 agent_ui::test_support::send_message(&panel, cx);
6810 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6811 cx.run_until_parked();
6812
6813 // Archive the thread — the group is left empty (no draft created).
6814 sidebar.update_in(cx, |sidebar, window, cx| {
6815 sidebar.archive_thread(&session_id, window, cx);
6816 });
6817 cx.run_until_parked();
6818
6819 // Un-archive the thread.
6820 let thread_id = cx.update(|_, cx| {
6821 ThreadMetadataStore::global(cx)
6822 .read(cx)
6823 .entries()
6824 .find(|e| e.session_id.as_ref() == Some(&session_id))
6825 .map(|e| e.thread_id)
6826 .expect("thread should exist in store")
6827 });
6828 let metadata = cx.update(|_, cx| {
6829 ThreadMetadataStore::global(cx)
6830 .read(cx)
6831 .entry(thread_id)
6832 .cloned()
6833 .expect("metadata should exist")
6834 });
6835
6836 sidebar.update_in(cx, |sidebar, window, cx| {
6837 sidebar.activate_archived_thread(metadata, window, cx);
6838 });
6839 cx.run_until_parked();
6840
6841 // The draft should be gone — only the unarchived thread remains.
6842 let entries = visible_entries_as_strings(&sidebar, cx);
6843 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6844 assert_eq!(
6845 draft_count, 0,
6846 "expected no drafts after unarchiving, got entries: {entries:?}"
6847 );
6848}
6849
6850#[gpui::test]
6851async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
6852 cx: &mut TestAppContext,
6853) {
6854 agent_ui::test_support::init_test(cx);
6855 cx.update(|cx| {
6856 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6857 ThreadStore::init_global(cx);
6858 ThreadMetadataStore::init_global(cx);
6859 language_model::LanguageModelRegistry::test(cx);
6860 prompt_store::init(cx);
6861 });
6862
6863 let fs = FakeFs::new(cx.executor());
6864 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6865 .await;
6866 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6867 .await;
6868 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6869
6870 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6871 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6872
6873 let (multi_workspace, cx) =
6874 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6875 let sidebar = setup_sidebar(&multi_workspace, cx);
6876
6877 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6878 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6879 mw.test_add_workspace(project_b.clone(), window, cx)
6880 });
6881 let _panel_b = add_agent_panel(&workspace_b, cx);
6882 cx.run_until_parked();
6883
6884 multi_workspace.update_in(cx, |mw, window, cx| {
6885 mw.activate(workspace_a.clone(), window, cx);
6886 });
6887 cx.run_until_parked();
6888
6889 let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
6890 let thread_id = ThreadId::new();
6891 cx.update(|_, cx| {
6892 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6893 store.save(
6894 ThreadMetadata {
6895 thread_id,
6896 session_id: Some(session_id.clone()),
6897 agent_id: agent::ZED_AGENT_ID.clone(),
6898 title: Some("Restored In Inactive Workspace".into()),
6899 updated_at: Utc::now(),
6900 created_at: None,
6901 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
6902 PathBuf::from("/project-b"),
6903 ])),
6904 archived: true,
6905 remote_connection: None,
6906 },
6907 cx,
6908 )
6909 });
6910 });
6911 cx.run_until_parked();
6912
6913 let metadata = cx.update(|_, cx| {
6914 ThreadMetadataStore::global(cx)
6915 .read(cx)
6916 .entry(thread_id)
6917 .cloned()
6918 .expect("archived metadata should exist before restore")
6919 });
6920
6921 sidebar.update_in(cx, |sidebar, window, cx| {
6922 sidebar.activate_archived_thread(metadata, window, cx);
6923 });
6924
6925 let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
6926 workspace.panel::<AgentPanel>(cx).expect(
6927 "target workspace should still have an agent panel immediately after activation",
6928 )
6929 });
6930 let immediate_active_thread_id =
6931 panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6932 let immediate_draft_ids =
6933 panel_b_before_settle.read_with(cx, |panel, cx| panel.draft_thread_ids(cx));
6934
6935 cx.run_until_parked();
6936
6937 sidebar.read_with(cx, |sidebar, _cx| {
6938 assert_active_thread(
6939 sidebar,
6940 &session_id,
6941 "unarchiving into an inactive existing workspace should end on the restored thread",
6942 );
6943 });
6944
6945 let panel_b = workspace_b.read_with(cx, |workspace, cx| {
6946 workspace
6947 .panel::<AgentPanel>(cx)
6948 .expect("target workspace should still have an agent panel")
6949 });
6950 assert_eq!(
6951 panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6952 Some(thread_id),
6953 "expected target panel to activate the restored thread id"
6954 );
6955 assert!(
6956 immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
6957 "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}, draft_ids={immediate_draft_ids:?}"
6958 );
6959
6960 let entries = visible_entries_as_strings(&sidebar, cx);
6961 let target_rows: Vec<_> = entries
6962 .iter()
6963 .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
6964 .cloned()
6965 .collect();
6966 assert_eq!(
6967 target_rows.len(),
6968 1,
6969 "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
6970 );
6971 assert!(
6972 target_rows[0].contains("Restored In Inactive Workspace"),
6973 "expected the remaining row to be the restored thread, got entries: {entries:?}"
6974 );
6975 assert!(
6976 !target_rows[0].contains("Draft"),
6977 "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
6978 );
6979}
6980
6981#[gpui::test]
6982async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
6983 cx: &mut TestAppContext,
6984) {
6985 agent_ui::test_support::init_test(cx);
6986 cx.update(|cx| {
6987 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6988 ThreadStore::init_global(cx);
6989 ThreadMetadataStore::init_global(cx);
6990 language_model::LanguageModelRegistry::test(cx);
6991 prompt_store::init(cx);
6992 });
6993
6994 let fs = FakeFs::new(cx.executor());
6995 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6996 .await;
6997 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6998 .await;
6999 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7000
7001 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
7002 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
7003
7004 let (multi_workspace, cx) =
7005 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
7006 let sidebar = setup_sidebar(&multi_workspace, cx);
7007
7008 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
7009 mw.test_add_workspace(project_b.clone(), window, cx)
7010 });
7011 let panel_b = add_agent_panel(&workspace_b, cx);
7012 cx.run_until_parked();
7013
7014 let connection = acp_thread::StubAgentConnection::new();
7015 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7016 acp::ContentChunk::new("Done".into()),
7017 )]);
7018 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
7019 agent_ui::test_support::send_message(&panel_b, cx);
7020 let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
7021 save_test_thread_metadata(&session_id, &project_b, cx).await;
7022 cx.run_until_parked();
7023
7024 sidebar.update_in(cx, |sidebar, window, cx| {
7025 sidebar.archive_thread(&session_id, window, cx);
7026 });
7027
7028 cx.run_until_parked();
7029
7030 let archived_metadata = cx.update(|_, cx| {
7031 let store = ThreadMetadataStore::global(cx).read(cx);
7032 let thread_id = store
7033 .entries()
7034 .find(|e| e.session_id.as_ref() == Some(&session_id))
7035 .map(|e| e.thread_id)
7036 .expect("archived thread should still exist in metadata store");
7037 let metadata = store
7038 .entry(thread_id)
7039 .cloned()
7040 .expect("archived metadata should still exist after archive");
7041 assert!(
7042 metadata.archived,
7043 "thread should be archived before project removal"
7044 );
7045 metadata
7046 });
7047
7048 let group_key_b =
7049 project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
7050 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
7051 mw.remove_project_group(&group_key_b, window, cx)
7052 });
7053 remove_task
7054 .await
7055 .expect("remove project group task should complete");
7056 cx.run_until_parked();
7057
7058 assert_eq!(
7059 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7060 1,
7061 "removing the archived thread's parent project group should remove its workspace"
7062 );
7063
7064 sidebar.update_in(cx, |sidebar, window, cx| {
7065 sidebar.activate_archived_thread(archived_metadata.clone(), window, cx);
7066 });
7067 cx.run_until_parked();
7068
7069 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
7070 mw.workspaces()
7071 .find(|workspace| {
7072 PathList::new(&workspace.read(cx).root_paths(cx))
7073 == PathList::new(&[PathBuf::from("/project-b")])
7074 })
7075 .cloned()
7076 .expect("expected unarchive to recreate the removed project workspace")
7077 });
7078 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
7079 workspace
7080 .panel::<AgentPanel>(cx)
7081 .expect("expected restored workspace to bootstrap an agent panel")
7082 });
7083
7084 let restored_thread_id = cx.update(|_, cx| {
7085 ThreadMetadataStore::global(cx)
7086 .read(cx)
7087 .entries()
7088 .find(|e| e.session_id.as_ref() == Some(&session_id))
7089 .map(|e| e.thread_id)
7090 .expect("session should still map to restored thread id")
7091 });
7092 assert_eq!(
7093 restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
7094 Some(restored_thread_id),
7095 "expected unarchive after project removal to activate the restored real thread"
7096 );
7097
7098 sidebar.read_with(cx, |sidebar, _cx| {
7099 assert_active_thread(
7100 sidebar,
7101 &session_id,
7102 "expected sidebar active entry to track the restored thread after project removal",
7103 );
7104 });
7105
7106 let entries = visible_entries_as_strings(&sidebar, cx);
7107 let restored_title = archived_metadata.display_title().to_string();
7108 let matching_rows: Vec<_> = entries
7109 .iter()
7110 .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
7111 .cloned()
7112 .collect();
7113 assert_eq!(
7114 matching_rows.len(),
7115 1,
7116 "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
7117 );
7118 assert!(
7119 !matching_rows[0].contains("Draft"),
7120 "expected no draft row after unarchive following project removal, got entries: {entries:?}"
7121 );
7122}
7123
7124#[gpui::test]
7125async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
7126 agent_ui::test_support::init_test(cx);
7127 cx.update(|cx| {
7128 ThreadStore::init_global(cx);
7129 ThreadMetadataStore::init_global(cx);
7130 language_model::LanguageModelRegistry::test(cx);
7131 prompt_store::init(cx);
7132 });
7133
7134 let fs = FakeFs::new(cx.executor());
7135 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
7136 .await;
7137 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7138
7139 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
7140 let (multi_workspace, cx) =
7141 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7142 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
7143 cx.run_until_parked();
7144
7145 let connection = acp_thread::StubAgentConnection::new();
7146 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7147 acp::ContentChunk::new("Done".into()),
7148 )]);
7149 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
7150 agent_ui::test_support::send_message(&panel, cx);
7151 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
7152 cx.run_until_parked();
7153
7154 let original_thread_id = cx.update(|_, cx| {
7155 ThreadMetadataStore::global(cx)
7156 .read(cx)
7157 .entries()
7158 .find(|e| e.session_id.as_ref() == Some(&session_id))
7159 .map(|e| e.thread_id)
7160 .expect("thread should exist in store before archiving")
7161 });
7162
7163 sidebar.update_in(cx, |sidebar, window, cx| {
7164 sidebar.archive_thread(&session_id, window, cx);
7165 });
7166 cx.run_until_parked();
7167
7168 let metadata = cx.update(|_, cx| {
7169 ThreadMetadataStore::global(cx)
7170 .read(cx)
7171 .entry(original_thread_id)
7172 .cloned()
7173 .expect("metadata should exist after archiving")
7174 });
7175
7176 sidebar.update_in(cx, |sidebar, window, cx| {
7177 sidebar.activate_archived_thread(metadata, window, cx);
7178 });
7179 cx.run_until_parked();
7180
7181 let session_entries = cx.update(|_, cx| {
7182 ThreadMetadataStore::global(cx)
7183 .read(cx)
7184 .entries()
7185 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7186 .cloned()
7187 .collect::<Vec<_>>()
7188 });
7189
7190 assert_eq!(
7191 session_entries.len(),
7192 1,
7193 "expected exactly one metadata row for the restored session, got: {session_entries:?}"
7194 );
7195 assert_eq!(
7196 session_entries[0].thread_id, original_thread_id,
7197 "expected unarchive to reuse the original thread id instead of creating a duplicate row"
7198 );
7199 assert!(
7200 !session_entries[0].is_draft(),
7201 "expected restored metadata to be a real thread, got: {:?}",
7202 session_entries[0]
7203 );
7204
7205 let entries = visible_entries_as_strings(&sidebar, cx);
7206 let real_thread_rows = entries
7207 .iter()
7208 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7209 .filter(|entry| !entry.contains("Draft"))
7210 .count();
7211 assert_eq!(
7212 real_thread_rows, 1,
7213 "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
7214 );
7215 assert!(
7216 !entries.iter().any(|entry| entry.contains("Draft")),
7217 "expected no draft rows after restoring, got entries: {entries:?}"
7218 );
7219}
7220
7221#[gpui::test]
7222async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
7223 cx: &mut TestAppContext,
7224) {
7225 // When a thread is archived while the user is in a different workspace,
7226 // the group is left empty (no draft is created). Switching back to that
7227 // workspace should show no active entry.
7228 agent_ui::test_support::init_test(cx);
7229 cx.update(|cx| {
7230 ThreadStore::init_global(cx);
7231 ThreadMetadataStore::init_global(cx);
7232 language_model::LanguageModelRegistry::test(cx);
7233 prompt_store::init(cx);
7234 });
7235
7236 let fs = FakeFs::new(cx.executor());
7237 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
7238 .await;
7239 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
7240 .await;
7241 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7242
7243 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
7244 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
7245
7246 let (multi_workspace, cx) =
7247 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
7248 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
7249
7250 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
7251 mw.test_add_workspace(project_b.clone(), window, cx)
7252 });
7253 let _panel_b = add_agent_panel(&workspace_b, cx);
7254 cx.run_until_parked();
7255
7256 // Create a thread in project-a's panel (currently non-active).
7257 let connection = acp_thread::StubAgentConnection::new();
7258 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7259 acp::ContentChunk::new("Done".into()),
7260 )]);
7261 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
7262 agent_ui::test_support::send_message(&panel_a, cx);
7263 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
7264 cx.run_until_parked();
7265
7266 // Archive it while project-b is active.
7267 sidebar.update_in(cx, |sidebar, window, cx| {
7268 sidebar.archive_thread(&thread_a, window, cx);
7269 });
7270 cx.run_until_parked();
7271
7272 // Switch back to project-a. Its panel was cleared during archiving,
7273 // so active_entry should be None (no draft is created).
7274 let workspace_a =
7275 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7276 multi_workspace.update_in(cx, |mw, window, cx| {
7277 mw.activate(workspace_a.clone(), window, cx);
7278 });
7279 cx.run_until_parked();
7280
7281 sidebar.update_in(cx, |sidebar, _window, cx| {
7282 sidebar.update_entries(cx);
7283 });
7284 cx.run_until_parked();
7285
7286 sidebar.read_with(cx, |sidebar, _| {
7287 assert!(
7288 sidebar.active_entry.is_none(),
7289 "expected no active entry after switching to workspace with archived thread, got: {:?}",
7290 sidebar.active_entry,
7291 );
7292 });
7293}
7294
7295#[gpui::test]
7296async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
7297 let project = init_test_project("/my-project", cx).await;
7298 let (multi_workspace, cx) =
7299 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7300 let sidebar = setup_sidebar(&multi_workspace, cx);
7301
7302 save_thread_metadata(
7303 acp::SessionId::new(Arc::from("visible-thread")),
7304 Some("Visible Thread".into()),
7305 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7306 None,
7307 &project,
7308 cx,
7309 );
7310
7311 let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
7312 save_thread_metadata(
7313 archived_thread_session_id.clone(),
7314 Some("Archived Thread".into()),
7315 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7316 None,
7317 &project,
7318 cx,
7319 );
7320
7321 cx.update(|_, cx| {
7322 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7323 let thread_id = store
7324 .entries()
7325 .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
7326 .map(|e| e.thread_id)
7327 .unwrap();
7328 store.archive(thread_id, None, cx)
7329 })
7330 });
7331 cx.run_until_parked();
7332
7333 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7334 cx.run_until_parked();
7335
7336 let entries = visible_entries_as_strings(&sidebar, cx);
7337 assert!(
7338 entries.iter().any(|e| e.contains("Visible Thread")),
7339 "expected visible thread in sidebar, got: {entries:?}"
7340 );
7341 assert!(
7342 !entries.iter().any(|e| e.contains("Archived Thread")),
7343 "expected archived thread to be hidden from sidebar, got: {entries:?}"
7344 );
7345
7346 cx.update(|_, cx| {
7347 let store = ThreadMetadataStore::global(cx);
7348 let all: Vec<_> = store.read(cx).entries().collect();
7349 assert_eq!(
7350 all.len(),
7351 2,
7352 "expected 2 total entries in the store, got: {}",
7353 all.len()
7354 );
7355
7356 let archived: Vec<_> = store.read(cx).archived_entries().collect();
7357 assert_eq!(archived.len(), 1);
7358 assert_eq!(
7359 archived[0].session_id.as_ref().unwrap().0.as_ref(),
7360 "archived-thread"
7361 );
7362 });
7363}
7364
7365#[gpui::test]
7366async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
7367 cx: &mut TestAppContext,
7368) {
7369 // When a linked worktree has a single thread and that thread is archived,
7370 // the sidebar must NOT create a new thread on the same worktree (which
7371 // would prevent the worktree from being cleaned up on disk). Instead,
7372 // archive_thread switches to a sibling thread on the main workspace (or
7373 // creates a draft there) before archiving the metadata.
7374 agent_ui::test_support::init_test(cx);
7375 cx.update(|cx| {
7376 ThreadStore::init_global(cx);
7377 ThreadMetadataStore::init_global(cx);
7378 language_model::LanguageModelRegistry::test(cx);
7379 prompt_store::init(cx);
7380 });
7381
7382 let fs = FakeFs::new(cx.executor());
7383
7384 fs.insert_tree(
7385 "/project",
7386 serde_json::json!({
7387 ".git": {},
7388 "src": {},
7389 }),
7390 )
7391 .await;
7392
7393 fs.add_linked_worktree_for_repo(
7394 Path::new("/project/.git"),
7395 false,
7396 git::repository::Worktree {
7397 path: std::path::PathBuf::from("/wt-ochre-drift"),
7398 ref_name: Some("refs/heads/ochre-drift".into()),
7399 sha: "aaa".into(),
7400 is_main: false,
7401 is_bare: false,
7402 },
7403 )
7404 .await;
7405
7406 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7407
7408 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7409 let worktree_project =
7410 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7411
7412 main_project
7413 .update(cx, |p, cx| p.git_scans_complete(cx))
7414 .await;
7415 worktree_project
7416 .update(cx, |p, cx| p.git_scans_complete(cx))
7417 .await;
7418
7419 let (multi_workspace, cx) =
7420 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7421
7422 let sidebar = setup_sidebar(&multi_workspace, cx);
7423
7424 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7425 mw.test_add_workspace(worktree_project.clone(), window, cx)
7426 });
7427
7428 // Set up both workspaces with agent panels.
7429 let main_workspace =
7430 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7431 let _main_panel = add_agent_panel(&main_workspace, cx);
7432 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7433
7434 // Activate the linked worktree workspace so the sidebar tracks it.
7435 multi_workspace.update_in(cx, |mw, window, cx| {
7436 mw.activate(worktree_workspace.clone(), window, cx);
7437 });
7438
7439 // Open a thread in the linked worktree panel and send a message
7440 // so it becomes the active thread.
7441 let connection = StubAgentConnection::new();
7442 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7443 send_message(&worktree_panel, cx);
7444
7445 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7446
7447 // Give the thread a response chunk so it has content.
7448 cx.update(|_, cx| {
7449 connection.send_update(
7450 worktree_thread_id.clone(),
7451 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7452 cx,
7453 );
7454 });
7455
7456 // Save the worktree thread's metadata.
7457 save_thread_metadata(
7458 worktree_thread_id.clone(),
7459 Some("Ochre Drift Thread".into()),
7460 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7461 None,
7462 &worktree_project,
7463 cx,
7464 );
7465
7466 // Also save a thread on the main project so there's a sibling in the
7467 // group that can be selected after archiving.
7468 save_thread_metadata(
7469 acp::SessionId::new(Arc::from("main-project-thread")),
7470 Some("Main Project Thread".into()),
7471 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7472 None,
7473 &main_project,
7474 cx,
7475 );
7476
7477 cx.run_until_parked();
7478
7479 // Verify the linked worktree thread appears with its chip.
7480 // The live thread title comes from the message text ("Hello"), not
7481 // the metadata title we saved.
7482 let entries_before = visible_entries_as_strings(&sidebar, cx);
7483 assert!(
7484 entries_before
7485 .iter()
7486 .any(|s| s.contains("{wt-ochre-drift}")),
7487 "expected worktree thread with chip before archiving, got: {entries_before:?}"
7488 );
7489 assert!(
7490 entries_before
7491 .iter()
7492 .any(|s| s.contains("Main Project Thread")),
7493 "expected main project thread before archiving, got: {entries_before:?}"
7494 );
7495
7496 // Confirm the worktree thread is the active entry.
7497 sidebar.read_with(cx, |s, _| {
7498 assert_active_thread(
7499 s,
7500 &worktree_thread_id,
7501 "worktree thread should be active before archiving",
7502 );
7503 });
7504
7505 // Archive the worktree thread — it's the only thread using ochre-drift.
7506 sidebar.update_in(cx, |sidebar, window, cx| {
7507 sidebar.archive_thread(&worktree_thread_id, window, cx);
7508 });
7509
7510 cx.run_until_parked();
7511
7512 // The archived thread should no longer appear in the sidebar.
7513 let entries_after = visible_entries_as_strings(&sidebar, cx);
7514 assert!(
7515 !entries_after
7516 .iter()
7517 .any(|s| s.contains("Ochre Drift Thread")),
7518 "archived thread should be hidden, got: {entries_after:?}"
7519 );
7520
7521 // No "+ New Thread" entry should appear with the ochre-drift worktree
7522 // chip — that would keep the worktree alive and prevent cleanup.
7523 assert!(
7524 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7525 "no entry should reference the archived worktree, got: {entries_after:?}"
7526 );
7527
7528 // The main project thread should still be visible.
7529 assert!(
7530 entries_after
7531 .iter()
7532 .any(|s| s.contains("Main Project Thread")),
7533 "main project thread should still be visible, got: {entries_after:?}"
7534 );
7535}
7536
7537#[gpui::test]
7538async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
7539 cx: &mut TestAppContext,
7540) {
7541 // When a linked worktree thread is the ONLY thread in the project group
7542 // (no threads on the main repo either), archiving it should leave the
7543 // group empty with no active entry.
7544 agent_ui::test_support::init_test(cx);
7545 cx.update(|cx| {
7546 ThreadStore::init_global(cx);
7547 ThreadMetadataStore::init_global(cx);
7548 language_model::LanguageModelRegistry::test(cx);
7549 prompt_store::init(cx);
7550 });
7551
7552 let fs = FakeFs::new(cx.executor());
7553
7554 fs.insert_tree(
7555 "/project",
7556 serde_json::json!({
7557 ".git": {},
7558 "src": {},
7559 }),
7560 )
7561 .await;
7562
7563 fs.add_linked_worktree_for_repo(
7564 Path::new("/project/.git"),
7565 false,
7566 git::repository::Worktree {
7567 path: std::path::PathBuf::from("/wt-ochre-drift"),
7568 ref_name: Some("refs/heads/ochre-drift".into()),
7569 sha: "aaa".into(),
7570 is_main: false,
7571 is_bare: false,
7572 },
7573 )
7574 .await;
7575
7576 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7577
7578 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7579 let worktree_project =
7580 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7581
7582 main_project
7583 .update(cx, |p, cx| p.git_scans_complete(cx))
7584 .await;
7585 worktree_project
7586 .update(cx, |p, cx| p.git_scans_complete(cx))
7587 .await;
7588
7589 let (multi_workspace, cx) =
7590 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7591
7592 let sidebar = setup_sidebar(&multi_workspace, cx);
7593
7594 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7595 mw.test_add_workspace(worktree_project.clone(), window, cx)
7596 });
7597
7598 let main_workspace =
7599 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7600 let _main_panel = add_agent_panel(&main_workspace, cx);
7601 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7602
7603 // Activate the linked worktree workspace.
7604 multi_workspace.update_in(cx, |mw, window, cx| {
7605 mw.activate(worktree_workspace.clone(), window, cx);
7606 });
7607
7608 // Open a thread on the linked worktree — this is the ONLY thread.
7609 let connection = StubAgentConnection::new();
7610 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7611 send_message(&worktree_panel, cx);
7612
7613 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7614
7615 cx.update(|_, cx| {
7616 connection.send_update(
7617 worktree_thread_id.clone(),
7618 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7619 cx,
7620 );
7621 });
7622
7623 save_thread_metadata(
7624 worktree_thread_id.clone(),
7625 Some("Ochre Drift Thread".into()),
7626 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7627 None,
7628 &worktree_project,
7629 cx,
7630 );
7631
7632 cx.run_until_parked();
7633
7634 // Archive it — there are no other threads in the group.
7635 sidebar.update_in(cx, |sidebar, window, cx| {
7636 sidebar.archive_thread(&worktree_thread_id, window, cx);
7637 });
7638
7639 cx.run_until_parked();
7640
7641 let entries_after = visible_entries_as_strings(&sidebar, cx);
7642
7643 // No entry should reference the linked worktree.
7644 assert!(
7645 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7646 "no entry should reference the archived worktree, got: {entries_after:?}"
7647 );
7648
7649 // The active entry should be None — no draft is created.
7650 sidebar.read_with(cx, |s, _| {
7651 assert!(
7652 s.active_entry.is_none(),
7653 "expected no active entry after archiving the last thread, got: {:?}",
7654 s.active_entry,
7655 );
7656 });
7657}
7658
7659#[gpui::test]
7660async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
7661 cx: &mut TestAppContext,
7662) {
7663 // When an archived thread belongs to a linked worktree whose main repo is
7664 // already open, unarchiving should reopen the linked workspace into the
7665 // same project group and show only the restored real thread row.
7666 agent_ui::test_support::init_test(cx);
7667 cx.update(|cx| {
7668 ThreadStore::init_global(cx);
7669 ThreadMetadataStore::init_global(cx);
7670 language_model::LanguageModelRegistry::test(cx);
7671 prompt_store::init(cx);
7672 });
7673
7674 let fs = FakeFs::new(cx.executor());
7675
7676 fs.insert_tree(
7677 "/project",
7678 serde_json::json!({
7679 ".git": {},
7680 "src": {},
7681 }),
7682 )
7683 .await;
7684
7685 fs.insert_tree(
7686 "/wt-ochre-drift",
7687 serde_json::json!({
7688 ".git": "gitdir: /project/.git/worktrees/ochre-drift",
7689 "src": {},
7690 }),
7691 )
7692 .await;
7693
7694 fs.add_linked_worktree_for_repo(
7695 Path::new("/project/.git"),
7696 false,
7697 git::repository::Worktree {
7698 path: std::path::PathBuf::from("/wt-ochre-drift"),
7699 ref_name: Some("refs/heads/ochre-drift".into()),
7700 sha: "aaa".into(),
7701 is_main: false,
7702 is_bare: false,
7703 },
7704 )
7705 .await;
7706
7707 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7708
7709 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7710 let worktree_project =
7711 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7712
7713 main_project
7714 .update(cx, |p, cx| p.git_scans_complete(cx))
7715 .await;
7716 worktree_project
7717 .update(cx, |p, cx| p.git_scans_complete(cx))
7718 .await;
7719
7720 let (multi_workspace, cx) =
7721 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7722
7723 let sidebar = setup_sidebar(&multi_workspace, cx);
7724 let main_workspace =
7725 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7726 let _main_panel = add_agent_panel(&main_workspace, cx);
7727 cx.run_until_parked();
7728
7729 let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
7730 let original_thread_id = ThreadId::new();
7731 let main_paths = PathList::new(&[PathBuf::from("/project")]);
7732 let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
7733
7734 cx.update(|_, cx| {
7735 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7736 store.save(
7737 ThreadMetadata {
7738 thread_id: original_thread_id,
7739 session_id: Some(session_id.clone()),
7740 agent_id: agent::ZED_AGENT_ID.clone(),
7741 title: Some("Unarchived Linked Thread".into()),
7742 updated_at: Utc::now(),
7743 created_at: None,
7744 worktree_paths: WorktreePaths::from_path_lists(
7745 main_paths.clone(),
7746 folder_paths.clone(),
7747 )
7748 .expect("main and folder paths should be well-formed"),
7749 archived: true,
7750 remote_connection: None,
7751 },
7752 cx,
7753 )
7754 });
7755 });
7756 cx.run_until_parked();
7757
7758 let metadata = cx.update(|_, cx| {
7759 ThreadMetadataStore::global(cx)
7760 .read(cx)
7761 .entry(original_thread_id)
7762 .cloned()
7763 .expect("archived linked-worktree metadata should exist before restore")
7764 });
7765
7766 sidebar.update_in(cx, |sidebar, window, cx| {
7767 sidebar.activate_archived_thread(metadata, window, cx);
7768 });
7769 cx.run_until_parked();
7770
7771 assert_eq!(
7772 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7773 2,
7774 "expected unarchive to open the linked worktree workspace into the project group"
7775 );
7776
7777 let session_entries = cx.update(|_, cx| {
7778 ThreadMetadataStore::global(cx)
7779 .read(cx)
7780 .entries()
7781 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7782 .cloned()
7783 .collect::<Vec<_>>()
7784 });
7785 assert_eq!(
7786 session_entries.len(),
7787 1,
7788 "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
7789 );
7790 assert_eq!(
7791 session_entries[0].thread_id, original_thread_id,
7792 "expected unarchive to reuse the original linked worktree thread id"
7793 );
7794 assert!(
7795 !session_entries[0].archived,
7796 "expected restored linked worktree metadata to be unarchived, got: {:?}",
7797 session_entries[0]
7798 );
7799
7800 let assert_no_extra_rows = |entries: &[String]| {
7801 let real_thread_rows = entries
7802 .iter()
7803 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7804 .filter(|entry| !entry.contains("Draft"))
7805 .count();
7806 assert_eq!(
7807 real_thread_rows, 1,
7808 "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
7809 );
7810 assert!(
7811 !entries.iter().any(|entry| entry.contains("Draft")),
7812 "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
7813 );
7814 assert!(
7815 !entries
7816 .iter()
7817 .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
7818 "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
7819 );
7820 assert!(
7821 entries
7822 .iter()
7823 .any(|entry| entry.contains("Unarchived Linked Thread")),
7824 "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
7825 );
7826 };
7827
7828 let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
7829 assert_no_extra_rows(&entries_after_restore);
7830
7831 // The reported bug may only appear after an extra scheduling turn.
7832 cx.run_until_parked();
7833
7834 let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
7835 assert_no_extra_rows(&entries_after_extra_turns);
7836}
7837
7838#[gpui::test]
7839async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
7840 // When a linked worktree thread is archived but the group has other
7841 // threads (e.g. on the main project), archive_thread should select
7842 // the nearest sibling.
7843 agent_ui::test_support::init_test(cx);
7844 cx.update(|cx| {
7845 ThreadStore::init_global(cx);
7846 ThreadMetadataStore::init_global(cx);
7847 language_model::LanguageModelRegistry::test(cx);
7848 prompt_store::init(cx);
7849 });
7850
7851 let fs = FakeFs::new(cx.executor());
7852
7853 fs.insert_tree(
7854 "/project",
7855 serde_json::json!({
7856 ".git": {},
7857 "src": {},
7858 }),
7859 )
7860 .await;
7861
7862 fs.add_linked_worktree_for_repo(
7863 Path::new("/project/.git"),
7864 false,
7865 git::repository::Worktree {
7866 path: std::path::PathBuf::from("/wt-ochre-drift"),
7867 ref_name: Some("refs/heads/ochre-drift".into()),
7868 sha: "aaa".into(),
7869 is_main: false,
7870 is_bare: false,
7871 },
7872 )
7873 .await;
7874
7875 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7876
7877 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7878 let worktree_project =
7879 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7880
7881 main_project
7882 .update(cx, |p, cx| p.git_scans_complete(cx))
7883 .await;
7884 worktree_project
7885 .update(cx, |p, cx| p.git_scans_complete(cx))
7886 .await;
7887
7888 let (multi_workspace, cx) =
7889 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7890
7891 let sidebar = setup_sidebar(&multi_workspace, cx);
7892
7893 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7894 mw.test_add_workspace(worktree_project.clone(), window, cx)
7895 });
7896
7897 let main_workspace =
7898 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7899 let _main_panel = add_agent_panel(&main_workspace, cx);
7900 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7901
7902 // Activate the linked worktree workspace.
7903 multi_workspace.update_in(cx, |mw, window, cx| {
7904 mw.activate(worktree_workspace.clone(), window, cx);
7905 });
7906
7907 // Open a thread on the linked worktree.
7908 let connection = StubAgentConnection::new();
7909 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7910 send_message(&worktree_panel, cx);
7911
7912 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7913
7914 cx.update(|_, cx| {
7915 connection.send_update(
7916 worktree_thread_id.clone(),
7917 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7918 cx,
7919 );
7920 });
7921
7922 save_thread_metadata(
7923 worktree_thread_id.clone(),
7924 Some("Ochre Drift Thread".into()),
7925 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7926 None,
7927 &worktree_project,
7928 cx,
7929 );
7930
7931 // Save a sibling thread on the main project.
7932 let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
7933 save_thread_metadata(
7934 main_thread_id,
7935 Some("Main Project Thread".into()),
7936 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7937 None,
7938 &main_project,
7939 cx,
7940 );
7941
7942 cx.run_until_parked();
7943
7944 // Confirm the worktree thread is active.
7945 sidebar.read_with(cx, |s, _| {
7946 assert_active_thread(
7947 s,
7948 &worktree_thread_id,
7949 "worktree thread should be active before archiving",
7950 );
7951 });
7952
7953 // Archive the worktree thread.
7954 sidebar.update_in(cx, |sidebar, window, cx| {
7955 sidebar.archive_thread(&worktree_thread_id, window, cx);
7956 });
7957
7958 cx.run_until_parked();
7959
7960 // The worktree workspace was removed and a draft was created on the
7961 // main workspace. No entry should reference the linked worktree.
7962 let entries_after = visible_entries_as_strings(&sidebar, cx);
7963 assert!(
7964 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7965 "no entry should reference the archived worktree, got: {entries_after:?}"
7966 );
7967
7968 // The main project thread should still be visible.
7969 assert!(
7970 entries_after
7971 .iter()
7972 .any(|s| s.contains("Main Project Thread")),
7973 "main project thread should still be visible, got: {entries_after:?}"
7974 );
7975}
7976
7977#[gpui::test]
7978async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
7979 // When a linked worktree is opened as its own workspace and the user
7980 // creates a draft thread from it, then switches away, the workspace must
7981 // still be reachable from that DraftThread sidebar entry. Pressing
7982 // RemoveSelectedThread (shift-backspace) on that entry should remove the
7983 // workspace.
7984 init_test(cx);
7985 let fs = FakeFs::new(cx.executor());
7986
7987 fs.insert_tree(
7988 "/project",
7989 serde_json::json!({
7990 ".git": {
7991 "worktrees": {
7992 "feature-a": {
7993 "commondir": "../../",
7994 "HEAD": "ref: refs/heads/feature-a",
7995 },
7996 },
7997 },
7998 "src": {},
7999 }),
8000 )
8001 .await;
8002
8003 fs.insert_tree(
8004 "/wt-feature-a",
8005 serde_json::json!({
8006 ".git": "gitdir: /project/.git/worktrees/feature-a",
8007 "src": {},
8008 }),
8009 )
8010 .await;
8011
8012 fs.add_linked_worktree_for_repo(
8013 Path::new("/project/.git"),
8014 false,
8015 git::repository::Worktree {
8016 path: PathBuf::from("/wt-feature-a"),
8017 ref_name: Some("refs/heads/feature-a".into()),
8018 sha: "aaa".into(),
8019 is_main: false,
8020 is_bare: false,
8021 },
8022 )
8023 .await;
8024
8025 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8026
8027 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
8028 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
8029
8030 main_project
8031 .update(cx, |p, cx| p.git_scans_complete(cx))
8032 .await;
8033 worktree_project
8034 .update(cx, |p, cx| p.git_scans_complete(cx))
8035 .await;
8036
8037 let (multi_workspace, cx) =
8038 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
8039 let sidebar = setup_sidebar(&multi_workspace, cx);
8040
8041 // Open the linked worktree as a separate workspace (simulates cmd-o).
8042 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8043 mw.test_add_workspace(worktree_project.clone(), window, cx)
8044 });
8045 add_agent_panel(&worktree_workspace, cx);
8046 cx.run_until_parked();
8047
8048 // Explicitly create a draft thread from the linked worktree workspace.
8049 // Auto-created drafts use the group's first workspace (the main one),
8050 // so a user-created draft is needed to make the linked worktree reachable.
8051 sidebar.update_in(cx, |sidebar, window, cx| {
8052 sidebar.create_new_thread(&worktree_workspace, window, cx);
8053 });
8054 cx.run_until_parked();
8055
8056 // Switch back to the main workspace.
8057 multi_workspace.update_in(cx, |mw, window, cx| {
8058 let main_ws = mw.workspaces().next().unwrap().clone();
8059 mw.activate(main_ws, window, cx);
8060 });
8061 cx.run_until_parked();
8062
8063 sidebar.update_in(cx, |sidebar, _window, cx| {
8064 sidebar.update_entries(cx);
8065 });
8066 cx.run_until_parked();
8067
8068 // The linked worktree workspace must be reachable from some sidebar entry.
8069 let worktree_ws_id = worktree_workspace.entity_id();
8070 let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
8071 let mw = multi_workspace.read(cx);
8072 sidebar
8073 .contents
8074 .entries
8075 .iter()
8076 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
8077 .map(|ws| ws.entity_id())
8078 .collect()
8079 });
8080 assert!(
8081 reachable.contains(&worktree_ws_id),
8082 "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
8083 );
8084
8085 // Find the draft Thread entry whose workspace is the linked worktree.
8086 let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
8087 sidebar
8088 .contents
8089 .entries
8090 .iter()
8091 .position(|entry| match entry {
8092 ListEntry::Thread(thread) if thread.is_draft => matches!(
8093 &thread.workspace,
8094 ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
8095 ),
8096 _ => false,
8097 })
8098 .expect("expected a draft thread entry for the linked worktree")
8099 });
8100
8101 assert_eq!(
8102 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8103 2
8104 );
8105
8106 sidebar.update_in(cx, |sidebar, window, cx| {
8107 sidebar.selection = Some(new_thread_ix);
8108 sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
8109 });
8110 cx.run_until_parked();
8111
8112 assert_eq!(
8113 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8114 2,
8115 "dismissing a draft no longer removes the linked worktree workspace"
8116 );
8117
8118 let has_draft_for_worktree = sidebar.read_with(cx, |sidebar, _| {
8119 sidebar.contents.entries.iter().any(|entry| match entry {
8120 ListEntry::Thread(thread) if thread.is_draft => matches!(
8121 &thread.workspace,
8122 ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
8123 ),
8124 _ => false,
8125 })
8126 });
8127 assert!(
8128 !has_draft_for_worktree,
8129 "draft thread entry for the linked worktree should be removed after dismiss"
8130 );
8131}
8132
8133#[gpui::test]
8134async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
8135 // When only a linked worktree workspace is open (not the main repo),
8136 // threads saved against the main repo should still appear in the sidebar.
8137 init_test(cx);
8138 let fs = FakeFs::new(cx.executor());
8139
8140 // Create the main repo with a linked worktree.
8141 fs.insert_tree(
8142 "/project",
8143 serde_json::json!({
8144 ".git": {
8145 "worktrees": {
8146 "feature-a": {
8147 "commondir": "../../",
8148 "HEAD": "ref: refs/heads/feature-a",
8149 },
8150 },
8151 },
8152 "src": {},
8153 }),
8154 )
8155 .await;
8156
8157 fs.insert_tree(
8158 "/wt-feature-a",
8159 serde_json::json!({
8160 ".git": "gitdir: /project/.git/worktrees/feature-a",
8161 "src": {},
8162 }),
8163 )
8164 .await;
8165
8166 fs.add_linked_worktree_for_repo(
8167 std::path::Path::new("/project/.git"),
8168 false,
8169 git::repository::Worktree {
8170 path: std::path::PathBuf::from("/wt-feature-a"),
8171 ref_name: Some("refs/heads/feature-a".into()),
8172 sha: "abc".into(),
8173 is_main: false,
8174 is_bare: false,
8175 },
8176 )
8177 .await;
8178
8179 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8180
8181 // Only open the linked worktree as a workspace — NOT the main repo.
8182 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
8183 worktree_project
8184 .update(cx, |p, cx| p.git_scans_complete(cx))
8185 .await;
8186
8187 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
8188 main_project
8189 .update(cx, |p, cx| p.git_scans_complete(cx))
8190 .await;
8191
8192 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
8193 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
8194 });
8195 let sidebar = setup_sidebar(&multi_workspace, cx);
8196
8197 // Save a thread against the MAIN repo path.
8198 save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
8199
8200 // Save a thread against the linked worktree path.
8201 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
8202
8203 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
8204 cx.run_until_parked();
8205
8206 // Both threads should be visible: the worktree thread by direct lookup,
8207 // and the main repo thread because the workspace is a linked worktree
8208 // and we also query the main repo path.
8209 let entries = visible_entries_as_strings(&sidebar, cx);
8210 assert!(
8211 entries.iter().any(|e| e.contains("Main Repo Thread")),
8212 "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
8213 );
8214 assert!(
8215 entries.iter().any(|e| e.contains("Worktree Thread")),
8216 "expected worktree thread to be visible, got: {entries:?}"
8217 );
8218}
8219
8220async fn init_multi_project_test(
8221 paths: &[&str],
8222 cx: &mut TestAppContext,
8223) -> (Arc<FakeFs>, Entity<project::Project>) {
8224 agent_ui::test_support::init_test(cx);
8225 cx.update(|cx| {
8226 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
8227 ThreadStore::init_global(cx);
8228 ThreadMetadataStore::init_global(cx);
8229 language_model::LanguageModelRegistry::test(cx);
8230 prompt_store::init(cx);
8231 });
8232 let fs = FakeFs::new(cx.executor());
8233 for path in paths {
8234 fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
8235 .await;
8236 }
8237 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8238 let project =
8239 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
8240 (fs, project)
8241}
8242
8243async fn add_test_project(
8244 path: &str,
8245 fs: &Arc<FakeFs>,
8246 multi_workspace: &Entity<MultiWorkspace>,
8247 cx: &mut gpui::VisualTestContext,
8248) -> Entity<Workspace> {
8249 let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
8250 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8251 mw.test_add_workspace(project, window, cx)
8252 });
8253 cx.run_until_parked();
8254 workspace
8255}
8256
8257#[gpui::test]
8258async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
8259 let (fs, project_a) =
8260 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
8261 let (multi_workspace, cx) =
8262 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8263 let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
8264
8265 // Sidebar starts closed. Initial workspace A is transient.
8266 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8267 assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
8268 assert_eq!(
8269 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8270 1
8271 );
8272 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
8273
8274 // Add B — replaces A as the transient workspace.
8275 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8276 assert_eq!(
8277 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8278 1
8279 );
8280 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
8281
8282 // Add C — replaces B as the transient workspace.
8283 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8284 assert_eq!(
8285 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8286 1
8287 );
8288 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8289}
8290
8291#[gpui::test]
8292async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
8293 let (fs, project_a) = init_multi_project_test(
8294 &["/project-a", "/project-b", "/project-c", "/project-d"],
8295 cx,
8296 )
8297 .await;
8298 let (multi_workspace, cx) =
8299 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8300 let _sidebar = setup_sidebar(&multi_workspace, cx);
8301 assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
8302
8303 // Add B — retained since sidebar is open.
8304 let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8305 assert_eq!(
8306 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8307 2
8308 );
8309
8310 // Switch to A — B survives. (Switching from one internal workspace, to another)
8311 multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
8312 cx.run_until_parked();
8313 assert_eq!(
8314 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8315 2
8316 );
8317
8318 // Close sidebar — both A and B remain retained.
8319 multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
8320 cx.run_until_parked();
8321 assert_eq!(
8322 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8323 2
8324 );
8325
8326 // Add C — added as new transient workspace. (switching from retained, to transient)
8327 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8328 assert_eq!(
8329 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8330 3
8331 );
8332 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8333
8334 // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
8335 let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
8336 assert_eq!(
8337 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8338 3
8339 );
8340 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
8341}
8342
8343#[gpui::test]
8344async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
8345 let (fs, project_a) =
8346 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
8347 let (multi_workspace, cx) =
8348 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8349 setup_sidebar_closed(&multi_workspace, cx);
8350
8351 // Add B — replaces A as the transient workspace (A is discarded).
8352 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8353 assert_eq!(
8354 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8355 1
8356 );
8357 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
8358
8359 // Open sidebar — promotes the transient B to retained.
8360 multi_workspace.update_in(cx, |mw, window, cx| {
8361 mw.toggle_sidebar(window, cx);
8362 });
8363 cx.run_until_parked();
8364 assert_eq!(
8365 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8366 1
8367 );
8368 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
8369
8370 // Close sidebar — the retained B remains.
8371 multi_workspace.update_in(cx, |mw, window, cx| {
8372 mw.toggle_sidebar(window, cx);
8373 });
8374
8375 // Add C — added as new transient workspace.
8376 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8377 assert_eq!(
8378 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8379 2
8380 );
8381 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8382}
8383
8384#[gpui::test]
8385async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
8386 init_test(cx);
8387 let fs = FakeFs::new(cx.executor());
8388
8389 fs.insert_tree(
8390 "/project",
8391 serde_json::json!({
8392 ".git": {
8393 "worktrees": {
8394 "feature-a": {
8395 "commondir": "../../",
8396 "HEAD": "ref: refs/heads/feature-a",
8397 },
8398 },
8399 },
8400 "src": {},
8401 }),
8402 )
8403 .await;
8404
8405 fs.insert_tree(
8406 "/wt-feature-a",
8407 serde_json::json!({
8408 ".git": "gitdir: /project/.git/worktrees/feature-a",
8409 "src": {},
8410 }),
8411 )
8412 .await;
8413
8414 fs.add_linked_worktree_for_repo(
8415 Path::new("/project/.git"),
8416 false,
8417 git::repository::Worktree {
8418 path: PathBuf::from("/wt-feature-a"),
8419 ref_name: Some("refs/heads/feature-a".into()),
8420 sha: "abc".into(),
8421 is_main: false,
8422 is_bare: false,
8423 },
8424 )
8425 .await;
8426
8427 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8428
8429 // Only a linked worktree workspace is open — no workspace for /project.
8430 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
8431 worktree_project
8432 .update(cx, |p, cx| p.git_scans_complete(cx))
8433 .await;
8434
8435 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
8436 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
8437 });
8438 let sidebar = setup_sidebar(&multi_workspace, cx);
8439
8440 // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
8441 let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
8442 cx.update(|_, cx| {
8443 let metadata = ThreadMetadata {
8444 thread_id: ThreadId::new(),
8445 session_id: Some(legacy_session.clone()),
8446 agent_id: agent::ZED_AGENT_ID.clone(),
8447 title: Some("Legacy Main Thread".into()),
8448 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8449 created_at: None,
8450 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
8451 "/project",
8452 )])),
8453 archived: false,
8454 remote_connection: None,
8455 };
8456 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
8457 });
8458 cx.run_until_parked();
8459
8460 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
8461 cx.run_until_parked();
8462
8463 // The legacy thread should appear in the sidebar under the project group.
8464 let entries = visible_entries_as_strings(&sidebar, cx);
8465 assert!(
8466 entries.iter().any(|e| e.contains("Legacy Main Thread")),
8467 "legacy thread should be visible: {entries:?}",
8468 );
8469
8470 // Verify only 1 workspace before clicking.
8471 assert_eq!(
8472 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8473 1,
8474 );
8475
8476 // Focus and select the legacy thread, then confirm.
8477 focus_sidebar(&sidebar, cx);
8478 let thread_index = sidebar.read_with(cx, |sidebar, _| {
8479 sidebar
8480 .contents
8481 .entries
8482 .iter()
8483 .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
8484 .expect("legacy thread should be in entries")
8485 });
8486 sidebar.update_in(cx, |sidebar, _window, _cx| {
8487 sidebar.selection = Some(thread_index);
8488 });
8489 cx.dispatch_action(Confirm);
8490 cx.run_until_parked();
8491
8492 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8493 let new_path_list =
8494 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
8495 assert_eq!(
8496 new_path_list,
8497 PathList::new(&[PathBuf::from("/project")]),
8498 "the new workspace should be for the main repo, not the linked worktree",
8499 );
8500}
8501
8502#[gpui::test]
8503async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
8504 cx: &mut TestAppContext,
8505) {
8506 // Regression test for a property-test finding:
8507 // AddLinkedWorktree { project_group_index: 0 }
8508 // AddProject { use_worktree: true }
8509 // AddProject { use_worktree: false }
8510 // After these three steps, the linked-worktree workspace was not
8511 // reachable from any sidebar entry.
8512 agent_ui::test_support::init_test(cx);
8513 cx.update(|cx| {
8514 ThreadStore::init_global(cx);
8515 ThreadMetadataStore::init_global(cx);
8516 language_model::LanguageModelRegistry::test(cx);
8517 prompt_store::init(cx);
8518
8519 cx.observe_new(
8520 |workspace: &mut Workspace,
8521 window: Option<&mut Window>,
8522 cx: &mut gpui::Context<Workspace>| {
8523 if let Some(window) = window {
8524 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
8525 workspace.add_panel(panel, window, cx);
8526 }
8527 },
8528 )
8529 .detach();
8530 });
8531
8532 let fs = FakeFs::new(cx.executor());
8533 fs.insert_tree(
8534 "/my-project",
8535 serde_json::json!({
8536 ".git": {},
8537 "src": {},
8538 }),
8539 )
8540 .await;
8541 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8542 let project =
8543 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
8544 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8545
8546 let (multi_workspace, cx) =
8547 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8548 let sidebar = setup_sidebar(&multi_workspace, cx);
8549
8550 // Step 1: Create a linked worktree for the main project.
8551 let worktree_name = "wt-0";
8552 let worktree_path = "/worktrees/wt-0";
8553
8554 fs.insert_tree(
8555 worktree_path,
8556 serde_json::json!({
8557 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8558 "src": {},
8559 }),
8560 )
8561 .await;
8562 fs.insert_tree(
8563 "/my-project/.git/worktrees/wt-0",
8564 serde_json::json!({
8565 "commondir": "../../",
8566 "HEAD": "ref: refs/heads/wt-0",
8567 }),
8568 )
8569 .await;
8570 fs.add_linked_worktree_for_repo(
8571 Path::new("/my-project/.git"),
8572 false,
8573 git::repository::Worktree {
8574 path: PathBuf::from(worktree_path),
8575 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
8576 sha: "aaa".into(),
8577 is_main: false,
8578 is_bare: false,
8579 },
8580 )
8581 .await;
8582
8583 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8584 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
8585 main_project
8586 .update(cx, |p, cx| p.git_scans_complete(cx))
8587 .await;
8588 cx.run_until_parked();
8589
8590 // Step 2: Open the linked worktree as its own workspace.
8591 let worktree_project =
8592 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
8593 worktree_project
8594 .update(cx, |p, cx| p.git_scans_complete(cx))
8595 .await;
8596 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8597 mw.test_add_workspace(worktree_project.clone(), window, cx)
8598 });
8599 cx.run_until_parked();
8600
8601 // Step 3: Add an unrelated project.
8602 fs.insert_tree(
8603 "/other-project",
8604 serde_json::json!({
8605 ".git": {},
8606 "src": {},
8607 }),
8608 )
8609 .await;
8610 let other_project = project::Project::test(
8611 fs.clone() as Arc<dyn fs::Fs>,
8612 ["/other-project".as_ref()],
8613 cx,
8614 )
8615 .await;
8616 other_project
8617 .update(cx, |p, cx| p.git_scans_complete(cx))
8618 .await;
8619 multi_workspace.update_in(cx, |mw, window, cx| {
8620 mw.test_add_workspace(other_project.clone(), window, cx);
8621 });
8622 cx.run_until_parked();
8623
8624 // Force a full sidebar rebuild with all groups expanded.
8625 sidebar.update_in(cx, |sidebar, _window, cx| {
8626 if let Some(mw) = sidebar.multi_workspace.upgrade() {
8627 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
8628 }
8629 sidebar.update_entries(cx);
8630 });
8631 cx.run_until_parked();
8632
8633 // The linked-worktree workspace must be reachable from at least one
8634 // sidebar entry — otherwise the user has no way to navigate to it.
8635 let worktree_ws_id = worktree_workspace.entity_id();
8636 let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
8637 let mw = multi_workspace.read(cx);
8638
8639 let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
8640 let reachable: HashSet<gpui::EntityId> = sidebar
8641 .contents
8642 .entries
8643 .iter()
8644 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
8645 .map(|ws| ws.entity_id())
8646 .collect();
8647 (all, reachable)
8648 });
8649
8650 let unreachable = &all_ids - &reachable_ids;
8651 eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
8652
8653 assert!(
8654 unreachable.is_empty(),
8655 "workspaces not reachable from any sidebar entry: {:?}\n\
8656 (linked-worktree workspace id: {:?})",
8657 unreachable,
8658 worktree_ws_id,
8659 );
8660}
8661
8662#[gpui::test]
8663async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
8664 // Empty project groups no longer auto-create drafts via reconciliation.
8665 // A fresh startup with no restorable thread should show only the header.
8666 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8667 let (multi_workspace, cx) =
8668 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8669 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8670
8671 let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8672
8673 let entries = visible_entries_as_strings(&sidebar, cx);
8674 assert_eq!(
8675 entries,
8676 vec!["v [my-project]"],
8677 "empty group should show only the header, no auto-created draft"
8678 );
8679}
8680
8681#[gpui::test]
8682async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
8683 // Rule 5: When the app starts and the AgentPanel successfully loads
8684 // a thread, no spurious draft should appear.
8685 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8686 let (multi_workspace, cx) =
8687 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8688 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8689
8690 // Create and send a message to make a real thread.
8691 let connection = StubAgentConnection::new();
8692 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8693 acp::ContentChunk::new("Done".into()),
8694 )]);
8695 open_thread_with_connection(&panel, connection, cx);
8696 send_message(&panel, cx);
8697 let session_id = active_session_id(&panel, cx);
8698 save_test_thread_metadata(&session_id, &project, cx).await;
8699 cx.run_until_parked();
8700
8701 // Should show the thread, NOT a spurious draft.
8702 let entries = visible_entries_as_strings(&sidebar, cx);
8703 assert_eq!(entries, vec!["v [my-project]", " Hello *"]);
8704
8705 // active_entry should be Thread, not Draft.
8706 sidebar.read_with(cx, |sidebar, _| {
8707 assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
8708 });
8709}
8710
8711#[gpui::test]
8712async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
8713 // Rule 9: Clicking a project header should restore whatever the
8714 // user was last looking at in that group, not create new drafts
8715 // or jump to the first entry.
8716 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
8717 let (multi_workspace, cx) =
8718 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8719 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8720
8721 // Create two threads in project-a.
8722 let conn1 = StubAgentConnection::new();
8723 conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8724 acp::ContentChunk::new("Done".into()),
8725 )]);
8726 open_thread_with_connection(&panel_a, conn1, cx);
8727 send_message(&panel_a, cx);
8728 let thread_a1 = active_session_id(&panel_a, cx);
8729 save_test_thread_metadata(&thread_a1, &project_a, cx).await;
8730
8731 let conn2 = StubAgentConnection::new();
8732 conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8733 acp::ContentChunk::new("Done".into()),
8734 )]);
8735 open_thread_with_connection(&panel_a, conn2, cx);
8736 send_message(&panel_a, cx);
8737 let thread_a2 = active_session_id(&panel_a, cx);
8738 save_test_thread_metadata(&thread_a2, &project_a, cx).await;
8739 cx.run_until_parked();
8740
8741 // The user is now looking at thread_a2.
8742 sidebar.read_with(cx, |sidebar, _| {
8743 assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
8744 });
8745
8746 // Add project-b and switch to it.
8747 let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
8748 fs.as_fake()
8749 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
8750 .await;
8751 let project_b =
8752 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8753 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8754 mw.test_add_workspace(project_b.clone(), window, cx)
8755 });
8756 let _panel_b = add_agent_panel(&workspace_b, cx);
8757 cx.run_until_parked();
8758
8759 // Now switch BACK to project-a by activating its workspace.
8760 let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
8761 mw.workspaces()
8762 .find(|ws| {
8763 ws.read(cx)
8764 .project()
8765 .read(cx)
8766 .visible_worktrees(cx)
8767 .any(|wt| {
8768 wt.read(cx)
8769 .abs_path()
8770 .to_string_lossy()
8771 .contains("project-a")
8772 })
8773 })
8774 .unwrap()
8775 .clone()
8776 });
8777 multi_workspace.update_in(cx, |mw, window, cx| {
8778 mw.activate(workspace_a.clone(), window, cx);
8779 });
8780 cx.run_until_parked();
8781
8782 // The panel should still show thread_a2 (the last thing the user
8783 // was viewing in project-a), not a draft or thread_a1.
8784 sidebar.read_with(cx, |sidebar, _| {
8785 assert_active_thread(
8786 sidebar,
8787 &thread_a2,
8788 "switching back to project-a should restore thread_a2",
8789 );
8790 });
8791
8792 // No spurious draft entries should have been created in
8793 // project-a's group (project-b may have a placeholder).
8794 let entries = visible_entries_as_strings(&sidebar, cx);
8795 // Find project-a's section and check it has no drafts.
8796 let project_a_start = entries
8797 .iter()
8798 .position(|e| e.contains("project-a"))
8799 .unwrap();
8800 let project_a_end = entries[project_a_start + 1..]
8801 .iter()
8802 .position(|e| e.starts_with("v "))
8803 .map(|i| i + project_a_start + 1)
8804 .unwrap_or(entries.len());
8805 let project_a_drafts = entries[project_a_start..project_a_end]
8806 .iter()
8807 .filter(|e| e.contains("Draft"))
8808 .count();
8809 assert_eq!(
8810 project_a_drafts, 0,
8811 "switching back to project-a should not create drafts in its group"
8812 );
8813}
8814
8815#[gpui::test]
8816async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
8817 // Clicking the + button when an empty draft already exists should
8818 // focus the existing draft rather than creating a new one.
8819 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8820 let (multi_workspace, cx) =
8821 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8822 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8823
8824 // Start: no drafts from reconciliation.
8825 let entries = visible_entries_as_strings(&sidebar, cx);
8826 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
8827 assert_eq!(draft_count, 0, "should start with 0 drafts");
8828
8829 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8830 let simulate_plus_button =
8831 |sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context<Sidebar>| {
8832 sidebar.create_new_thread(&workspace, window, cx);
8833 };
8834
8835 // First + click: should create a draft.
8836 sidebar.update_in(cx, |sidebar, window, cx| {
8837 simulate_plus_button(sidebar, window, cx);
8838 });
8839 cx.run_until_parked();
8840
8841 let entries = visible_entries_as_strings(&sidebar, cx);
8842 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
8843 assert_eq!(draft_count, 1, "first + click should create a draft");
8844
8845 // Second + click with empty draft: should reuse it, not create a new one.
8846 sidebar.update_in(cx, |sidebar, window, cx| {
8847 simulate_plus_button(sidebar, window, cx);
8848 });
8849 cx.run_until_parked();
8850
8851 let entries = visible_entries_as_strings(&sidebar, cx);
8852 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
8853 assert_eq!(
8854 draft_count, 1,
8855 "second + click should reuse the existing empty draft, not create a new one"
8856 );
8857
8858 // The draft should be active.
8859 assert_eq!(entries[1], " [~ Draft] *");
8860}
8861
8862#[gpui::test]
8863async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
8864 // When a workspace has a draft (from the panel's load fallback)
8865 // and the user activates it (e.g. by clicking the placeholder or
8866 // the project header), no extra drafts should be created.
8867 init_test(cx);
8868 let fs = FakeFs::new(cx.executor());
8869 fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
8870 .await;
8871 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8872 .await;
8873 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8874
8875 let project_a =
8876 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
8877 let (multi_workspace, cx) =
8878 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8879 let sidebar = setup_sidebar(&multi_workspace, cx);
8880 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8881 let _panel_a = add_agent_panel(&workspace_a, cx);
8882 cx.run_until_parked();
8883
8884 // Add project-b with its own workspace and agent panel.
8885 let project_b =
8886 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8887 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8888 mw.test_add_workspace(project_b.clone(), window, cx)
8889 });
8890 let _panel_b = add_agent_panel(&workspace_b, cx);
8891 cx.run_until_parked();
8892
8893 // Explicitly create a draft on workspace_b so the sidebar tracks one.
8894 sidebar.update_in(cx, |sidebar, window, cx| {
8895 sidebar.create_new_thread(&workspace_b, window, cx);
8896 });
8897 cx.run_until_parked();
8898
8899 // Count project-b's drafts.
8900 let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
8901 let entries = visible_entries_as_strings(&sidebar, cx);
8902 entries
8903 .iter()
8904 .skip_while(|e| !e.contains("project-b"))
8905 .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
8906 .filter(|e| e.contains("Draft"))
8907 .count()
8908 };
8909 let drafts_before = count_b_drafts(cx);
8910
8911 // Switch away from project-b, then back.
8912 multi_workspace.update_in(cx, |mw, window, cx| {
8913 mw.activate(workspace_a.clone(), window, cx);
8914 });
8915 cx.run_until_parked();
8916 multi_workspace.update_in(cx, |mw, window, cx| {
8917 mw.activate(workspace_b.clone(), window, cx);
8918 });
8919 cx.run_until_parked();
8920
8921 let drafts_after = count_b_drafts(cx);
8922 assert_eq!(
8923 drafts_before, drafts_after,
8924 "activating workspace should not create extra drafts"
8925 );
8926
8927 // The draft should be highlighted as active after switching back.
8928 sidebar.read_with(cx, |sidebar, _| {
8929 assert_active_draft(
8930 sidebar,
8931 &workspace_b,
8932 "draft should be active after switching back to its workspace",
8933 );
8934 });
8935}
8936
8937#[gpui::test]
8938async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
8939 // Historical threads (not open in any agent panel) should have their
8940 // worktree paths updated when a folder is added to or removed from the
8941 // project.
8942 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
8943 let (multi_workspace, cx) =
8944 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8945 let sidebar = setup_sidebar(&multi_workspace, cx);
8946
8947 // Save two threads directly into the metadata store (not via the agent
8948 // panel), so they are purely historical — no open views hold them.
8949 // Use different timestamps so sort order is deterministic.
8950 save_thread_metadata(
8951 acp::SessionId::new(Arc::from("hist-1")),
8952 Some("Historical 1".into()),
8953 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8954 None,
8955 &project,
8956 cx,
8957 );
8958 save_thread_metadata(
8959 acp::SessionId::new(Arc::from("hist-2")),
8960 Some("Historical 2".into()),
8961 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
8962 None,
8963 &project,
8964 cx,
8965 );
8966 cx.run_until_parked();
8967 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8968 cx.run_until_parked();
8969
8970 // Sanity-check: both threads exist under the initial key [/project-a].
8971 let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
8972 cx.update(|_window, cx| {
8973 let store = ThreadMetadataStore::global(cx).read(cx);
8974 assert_eq!(
8975 store
8976 .entries_for_main_worktree_path(&old_key_paths, None)
8977 .count(),
8978 2,
8979 "should have 2 historical threads under old key before worktree add"
8980 );
8981 });
8982
8983 // Add a second worktree to the project.
8984 project
8985 .update(cx, |project, cx| {
8986 project.find_or_create_worktree("/project-b", true, cx)
8987 })
8988 .await
8989 .expect("should add worktree");
8990 cx.run_until_parked();
8991
8992 // The historical threads should now be indexed under the new combined
8993 // key [/project-a, /project-b].
8994 let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
8995 cx.update(|_window, cx| {
8996 let store = ThreadMetadataStore::global(cx).read(cx);
8997 assert_eq!(
8998 store
8999 .entries_for_main_worktree_path(&old_key_paths, None)
9000 .count(),
9001 0,
9002 "should have 0 historical threads under old key after worktree add"
9003 );
9004 assert_eq!(
9005 store
9006 .entries_for_main_worktree_path(&new_key_paths, None)
9007 .count(),
9008 2,
9009 "should have 2 historical threads under new key after worktree add"
9010 );
9011 });
9012
9013 // Sidebar should show threads under the new header.
9014 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9015 cx.run_until_parked();
9016 assert_eq!(
9017 visible_entries_as_strings(&sidebar, cx),
9018 vec![
9019 "v [project-a, project-b]",
9020 " Historical 2",
9021 " Historical 1",
9022 ]
9023 );
9024
9025 // Now remove the second worktree.
9026 let worktree_id = project.read_with(cx, |project, cx| {
9027 project
9028 .visible_worktrees(cx)
9029 .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
9030 .map(|wt| wt.read(cx).id())
9031 .expect("should find project-b worktree")
9032 });
9033 project.update(cx, |project, cx| {
9034 project.remove_worktree(worktree_id, cx);
9035 });
9036 cx.run_until_parked();
9037
9038 // Historical threads should migrate back to the original key.
9039 cx.update(|_window, cx| {
9040 let store = ThreadMetadataStore::global(cx).read(cx);
9041 assert_eq!(
9042 store
9043 .entries_for_main_worktree_path(&new_key_paths, None)
9044 .count(),
9045 0,
9046 "should have 0 historical threads under new key after worktree remove"
9047 );
9048 assert_eq!(
9049 store
9050 .entries_for_main_worktree_path(&old_key_paths, None)
9051 .count(),
9052 2,
9053 "should have 2 historical threads under old key after worktree remove"
9054 );
9055 });
9056
9057 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9058 cx.run_until_parked();
9059 assert_eq!(
9060 visible_entries_as_strings(&sidebar, cx),
9061 vec!["v [project-a]", " Historical 2", " Historical 1",]
9062 );
9063}
9064
9065#[gpui::test]
9066async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
9067 // When two workspaces share the same project group (same main path)
9068 // but have different folder paths (main repo vs linked worktree),
9069 // adding a worktree to the main workspace should regroup only that
9070 // workspace and its threads into the new project group. Threads for the
9071 // linked worktree workspace should remain under the original group.
9072 agent_ui::test_support::init_test(cx);
9073 cx.update(|cx| {
9074 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
9075 ThreadStore::init_global(cx);
9076 ThreadMetadataStore::init_global(cx);
9077 language_model::LanguageModelRegistry::test(cx);
9078 prompt_store::init(cx);
9079 });
9080
9081 let fs = FakeFs::new(cx.executor());
9082 fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
9083 .await;
9084 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
9085 .await;
9086 fs.add_linked_worktree_for_repo(
9087 Path::new("/project/.git"),
9088 false,
9089 git::repository::Worktree {
9090 path: std::path::PathBuf::from("/wt-feature"),
9091 ref_name: Some("refs/heads/feature".into()),
9092 sha: "aaa".into(),
9093 is_main: false,
9094 is_bare: false,
9095 },
9096 )
9097 .await;
9098 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
9099
9100 // Workspace A: main repo at /project.
9101 let main_project =
9102 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
9103 // Workspace B: linked worktree of the same repo (same group, different folder).
9104 let worktree_project =
9105 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
9106
9107 main_project
9108 .update(cx, |p, cx| p.git_scans_complete(cx))
9109 .await;
9110 worktree_project
9111 .update(cx, |p, cx| p.git_scans_complete(cx))
9112 .await;
9113
9114 let (multi_workspace, cx) =
9115 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
9116 let sidebar = setup_sidebar(&multi_workspace, cx);
9117 multi_workspace.update_in(cx, |mw, window, cx| {
9118 mw.test_add_workspace(worktree_project.clone(), window, cx);
9119 });
9120 cx.run_until_parked();
9121
9122 // Save a thread for each workspace's folder paths.
9123 let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
9124 let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
9125 save_thread_metadata(
9126 acp::SessionId::new(Arc::from("thread-main")),
9127 Some("Main Thread".into()),
9128 time_main,
9129 Some(time_main),
9130 &main_project,
9131 cx,
9132 );
9133 save_thread_metadata(
9134 acp::SessionId::new(Arc::from("thread-wt")),
9135 Some("Worktree Thread".into()),
9136 time_wt,
9137 Some(time_wt),
9138 &worktree_project,
9139 cx,
9140 );
9141 cx.run_until_parked();
9142
9143 let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
9144 let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
9145
9146 // Sanity-check: each thread is indexed under its own folder paths, but
9147 // both appear under the shared sidebar group keyed by the main worktree.
9148 cx.update(|_window, cx| {
9149 let store = ThreadMetadataStore::global(cx).read(cx);
9150 assert_eq!(
9151 store.entries_for_path(&folder_paths_main, None).count(),
9152 1,
9153 "one thread under [/project]"
9154 );
9155 assert_eq!(
9156 store.entries_for_path(&folder_paths_wt, None).count(),
9157 1,
9158 "one thread under [/wt-feature]"
9159 );
9160 });
9161 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9162 cx.run_until_parked();
9163 assert_eq!(
9164 visible_entries_as_strings(&sidebar, cx),
9165 vec![
9166 "v [project]",
9167 " Worktree Thread {wt-feature}",
9168 " Main Thread",
9169 ]
9170 );
9171
9172 // Add /project-b to the main project only.
9173 main_project
9174 .update(cx, |project, cx| {
9175 project.find_or_create_worktree("/project-b", true, cx)
9176 })
9177 .await
9178 .expect("should add worktree");
9179 cx.run_until_parked();
9180
9181 // Main Thread (folder paths [/project]) should be regrouped to
9182 // [/project, /project-b]. Worktree Thread should remain under the
9183 // original [/project] group.
9184 let folder_paths_main_b =
9185 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
9186 cx.update(|_window, cx| {
9187 let store = ThreadMetadataStore::global(cx).read(cx);
9188 assert_eq!(
9189 store.entries_for_path(&folder_paths_main, None).count(),
9190 0,
9191 "main thread should no longer be under old folder paths [/project]"
9192 );
9193 assert_eq!(
9194 store.entries_for_path(&folder_paths_main_b, None).count(),
9195 1,
9196 "main thread should now be under [/project, /project-b]"
9197 );
9198 assert_eq!(
9199 store.entries_for_path(&folder_paths_wt, None).count(),
9200 1,
9201 "worktree thread should remain unchanged under [/wt-feature]"
9202 );
9203 });
9204
9205 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9206 cx.run_until_parked();
9207 assert_eq!(
9208 visible_entries_as_strings(&sidebar, cx),
9209 vec![
9210 "v [project]",
9211 " Worktree Thread {wt-feature}",
9212 "v [project, project-b]",
9213 " Main Thread",
9214 ]
9215 );
9216}
9217
9218#[gpui::test]
9219async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
9220 cx: &mut TestAppContext,
9221) {
9222 // When a linked worktree is opened as its own workspace and then a new
9223 // folder is added to the main project group, the linked worktree
9224 // workspace must still be reachable from some sidebar entry.
9225 let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
9226 let fs = _fs.clone();
9227
9228 // Set up git worktree infrastructure.
9229 fs.insert_tree(
9230 "/my-project/.git/worktrees/wt-0",
9231 serde_json::json!({
9232 "commondir": "../../",
9233 "HEAD": "ref: refs/heads/wt-0",
9234 }),
9235 )
9236 .await;
9237 fs.insert_tree(
9238 "/worktrees/wt-0",
9239 serde_json::json!({
9240 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
9241 "src": {},
9242 }),
9243 )
9244 .await;
9245 fs.add_linked_worktree_for_repo(
9246 Path::new("/my-project/.git"),
9247 false,
9248 git::repository::Worktree {
9249 path: PathBuf::from("/worktrees/wt-0"),
9250 ref_name: Some("refs/heads/wt-0".into()),
9251 sha: "aaa".into(),
9252 is_main: false,
9253 is_bare: false,
9254 },
9255 )
9256 .await;
9257
9258 // Re-scan so the main project discovers the linked worktree.
9259 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9260
9261 let (multi_workspace, cx) =
9262 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9263 let sidebar = setup_sidebar(&multi_workspace, cx);
9264
9265 // Open the linked worktree as its own workspace.
9266 let worktree_project = project::Project::test(
9267 fs.clone() as Arc<dyn fs::Fs>,
9268 ["/worktrees/wt-0".as_ref()],
9269 cx,
9270 )
9271 .await;
9272 worktree_project
9273 .update(cx, |p, cx| p.git_scans_complete(cx))
9274 .await;
9275 multi_workspace.update_in(cx, |mw, window, cx| {
9276 mw.test_add_workspace(worktree_project.clone(), window, cx);
9277 });
9278 cx.run_until_parked();
9279
9280 // Both workspaces should be reachable.
9281 let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
9282 assert_eq!(workspace_count, 2, "should have 2 workspaces");
9283
9284 // Add a new folder to the main project, changing the project group key.
9285 fs.insert_tree(
9286 "/other-project",
9287 serde_json::json!({ ".git": {}, "src": {} }),
9288 )
9289 .await;
9290 project
9291 .update(cx, |project, cx| {
9292 project.find_or_create_worktree("/other-project", true, cx)
9293 })
9294 .await
9295 .expect("should add worktree");
9296 cx.run_until_parked();
9297
9298 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9299 cx.run_until_parked();
9300
9301 // The linked worktree workspace must still be reachable.
9302 let entries = visible_entries_as_strings(&sidebar, cx);
9303 let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
9304 mw.workspaces().map(|ws| ws.entity_id()).collect()
9305 });
9306 sidebar.read_with(cx, |sidebar, cx| {
9307 let multi_workspace = multi_workspace.read(cx);
9308 let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
9309 .contents
9310 .entries
9311 .iter()
9312 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
9313 .map(|ws| ws.entity_id())
9314 .collect();
9315 let all: std::collections::HashSet<gpui::EntityId> =
9316 mw_workspaces.iter().copied().collect();
9317 let unreachable = &all - &reachable;
9318 assert!(
9319 unreachable.is_empty(),
9320 "all workspaces should be reachable after adding folder; \
9321 unreachable: {:?}, entries: {:?}",
9322 unreachable,
9323 entries,
9324 );
9325 });
9326}
9327
9328mod property_test {
9329 use super::*;
9330 use gpui::proptest::prelude::*;
9331
9332 struct UnopenedWorktree {
9333 path: String,
9334 main_workspace_path: String,
9335 }
9336
9337 struct TestState {
9338 fs: Arc<FakeFs>,
9339 thread_counter: u32,
9340 workspace_counter: u32,
9341 worktree_counter: u32,
9342 saved_thread_ids: Vec<acp::SessionId>,
9343 unopened_worktrees: Vec<UnopenedWorktree>,
9344 }
9345
9346 impl TestState {
9347 fn new(fs: Arc<FakeFs>) -> Self {
9348 Self {
9349 fs,
9350 thread_counter: 0,
9351 workspace_counter: 1,
9352 worktree_counter: 0,
9353 saved_thread_ids: Vec::new(),
9354 unopened_worktrees: Vec::new(),
9355 }
9356 }
9357
9358 fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
9359 let id = self.thread_counter;
9360 self.thread_counter += 1;
9361 acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
9362 }
9363
9364 fn next_workspace_path(&mut self) -> String {
9365 let id = self.workspace_counter;
9366 self.workspace_counter += 1;
9367 format!("/prop-project-{id}")
9368 }
9369
9370 fn next_worktree_name(&mut self) -> String {
9371 let id = self.worktree_counter;
9372 self.worktree_counter += 1;
9373 format!("wt-{id}")
9374 }
9375 }
9376
9377 #[derive(Debug)]
9378 enum Operation {
9379 SaveThread { project_group_index: usize },
9380 SaveWorktreeThread { worktree_index: usize },
9381 ToggleAgentPanel,
9382 CreateDraftThread,
9383 AddProject { use_worktree: bool },
9384 ArchiveThread { index: usize },
9385 SwitchToThread { index: usize },
9386 SwitchToProjectGroup { index: usize },
9387 AddLinkedWorktree { project_group_index: usize },
9388 AddWorktreeToProject { project_group_index: usize },
9389 RemoveWorktreeFromProject { project_group_index: usize },
9390 }
9391
9392 // Distribution (out of 24 slots):
9393 // SaveThread: 5 slots (~21%)
9394 // SaveWorktreeThread: 2 slots (~8%)
9395 // ToggleAgentPanel: 1 slot (~4%)
9396 // CreateDraftThread: 1 slot (~4%)
9397 // AddProject: 1 slot (~4%)
9398 // ArchiveThread: 2 slots (~8%)
9399 // SwitchToThread: 2 slots (~8%)
9400 // SwitchToProjectGroup: 2 slots (~8%)
9401 // AddLinkedWorktree: 4 slots (~17%)
9402 // AddWorktreeToProject: 2 slots (~8%)
9403 // RemoveWorktreeFromProject: 2 slots (~8%)
9404 const DISTRIBUTION_SLOTS: u32 = 24;
9405
9406 impl TestState {
9407 fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
9408 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
9409
9410 match raw % DISTRIBUTION_SLOTS {
9411 0..=4 => Operation::SaveThread {
9412 project_group_index: extra % project_group_count,
9413 },
9414 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
9415 worktree_index: extra % self.unopened_worktrees.len(),
9416 },
9417 5..=6 => Operation::SaveThread {
9418 project_group_index: extra % project_group_count,
9419 },
9420 7 => Operation::ToggleAgentPanel,
9421 8 => Operation::CreateDraftThread,
9422 9 => Operation::AddProject {
9423 use_worktree: !self.unopened_worktrees.is_empty(),
9424 },
9425 10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
9426 index: extra % self.saved_thread_ids.len(),
9427 },
9428 10..=11 => Operation::AddProject {
9429 use_worktree: !self.unopened_worktrees.is_empty(),
9430 },
9431 12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
9432 index: extra % self.saved_thread_ids.len(),
9433 },
9434 12..=13 => Operation::SwitchToProjectGroup {
9435 index: extra % project_group_count,
9436 },
9437 14..=15 => Operation::SwitchToProjectGroup {
9438 index: extra % project_group_count,
9439 },
9440 16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
9441 project_group_index: extra % project_group_count,
9442 },
9443 16..=19 => Operation::SaveThread {
9444 project_group_index: extra % project_group_count,
9445 },
9446 20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
9447 project_group_index: extra % project_group_count,
9448 },
9449 20..=21 => Operation::SaveThread {
9450 project_group_index: extra % project_group_count,
9451 },
9452 22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
9453 project_group_index: extra % project_group_count,
9454 },
9455 22..=23 => Operation::SaveThread {
9456 project_group_index: extra % project_group_count,
9457 },
9458 _ => unreachable!(),
9459 }
9460 }
9461 }
9462
9463 fn save_thread_to_path_with_main(
9464 state: &mut TestState,
9465 path_list: PathList,
9466 main_worktree_paths: PathList,
9467 cx: &mut gpui::VisualTestContext,
9468 ) {
9469 let session_id = state.next_metadata_only_thread_id();
9470 let title: SharedString = format!("Thread {}", session_id).into();
9471 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9472 .unwrap()
9473 + chrono::Duration::seconds(state.thread_counter as i64);
9474 let metadata = ThreadMetadata {
9475 thread_id: ThreadId::new(),
9476 session_id: Some(session_id),
9477 agent_id: agent::ZED_AGENT_ID.clone(),
9478 title: Some(title),
9479 updated_at,
9480 created_at: None,
9481 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
9482 archived: false,
9483 remote_connection: None,
9484 };
9485 cx.update(|_, cx| {
9486 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
9487 });
9488 cx.run_until_parked();
9489 }
9490
9491 async fn perform_operation(
9492 operation: Operation,
9493 state: &mut TestState,
9494 multi_workspace: &Entity<MultiWorkspace>,
9495 sidebar: &Entity<Sidebar>,
9496 cx: &mut gpui::VisualTestContext,
9497 ) {
9498 match operation {
9499 Operation::SaveThread {
9500 project_group_index,
9501 } => {
9502 // Find a workspace for this project group and create a real
9503 // thread via its agent panel.
9504 let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
9505 let keys = mw.project_group_keys();
9506 let key = &keys[project_group_index];
9507 let ws = mw
9508 .workspaces_for_project_group(key, cx)
9509 .and_then(|ws| ws.first().cloned())
9510 .unwrap_or_else(|| mw.workspace().clone());
9511 let project = ws.read(cx).project().clone();
9512 (ws, project)
9513 });
9514
9515 let panel =
9516 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9517 if let Some(panel) = panel {
9518 let connection = StubAgentConnection::new();
9519 connection.set_next_prompt_updates(vec![
9520 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
9521 "Done".into(),
9522 )),
9523 ]);
9524 open_thread_with_connection(&panel, connection, cx);
9525 send_message(&panel, cx);
9526 let session_id = active_session_id(&panel, cx);
9527 state.saved_thread_ids.push(session_id.clone());
9528
9529 let title: SharedString = format!("Thread {}", state.thread_counter).into();
9530 state.thread_counter += 1;
9531 let updated_at =
9532 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9533 .unwrap()
9534 + chrono::Duration::seconds(state.thread_counter as i64);
9535 save_thread_metadata(session_id, Some(title), updated_at, None, &project, cx);
9536 }
9537 }
9538 Operation::SaveWorktreeThread { worktree_index } => {
9539 let worktree = &state.unopened_worktrees[worktree_index];
9540 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
9541 let main_worktree_paths =
9542 PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
9543 save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
9544 }
9545
9546 Operation::ToggleAgentPanel => {
9547 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9548 let panel_open =
9549 workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
9550 workspace.update_in(cx, |workspace, window, cx| {
9551 if panel_open {
9552 workspace.close_panel::<AgentPanel>(window, cx);
9553 } else {
9554 workspace.open_panel::<AgentPanel>(window, cx);
9555 }
9556 });
9557 }
9558 Operation::CreateDraftThread => {
9559 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9560 let panel =
9561 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9562 if let Some(panel) = panel {
9563 panel.update_in(cx, |panel, window, cx| {
9564 panel.new_thread(&NewThread, window, cx);
9565 });
9566 cx.run_until_parked();
9567 }
9568 workspace.update_in(cx, |workspace, window, cx| {
9569 workspace.focus_panel::<AgentPanel>(window, cx);
9570 });
9571 }
9572 Operation::AddProject { use_worktree } => {
9573 let path = if use_worktree {
9574 // Open an existing linked worktree as a project (simulates Cmd+O
9575 // on a worktree directory).
9576 state.unopened_worktrees.remove(0).path
9577 } else {
9578 // Create a brand new project.
9579 let path = state.next_workspace_path();
9580 state
9581 .fs
9582 .insert_tree(
9583 &path,
9584 serde_json::json!({
9585 ".git": {},
9586 "src": {},
9587 }),
9588 )
9589 .await;
9590 path
9591 };
9592 let project = project::Project::test(
9593 state.fs.clone() as Arc<dyn fs::Fs>,
9594 [path.as_ref()],
9595 cx,
9596 )
9597 .await;
9598 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9599 multi_workspace.update_in(cx, |mw, window, cx| {
9600 mw.test_add_workspace(project.clone(), window, cx)
9601 });
9602 }
9603
9604 Operation::ArchiveThread { index } => {
9605 let session_id = state.saved_thread_ids[index].clone();
9606 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
9607 sidebar.archive_thread(&session_id, window, cx);
9608 });
9609 cx.run_until_parked();
9610 state.saved_thread_ids.remove(index);
9611 }
9612 Operation::SwitchToThread { index } => {
9613 let session_id = state.saved_thread_ids[index].clone();
9614 // Find the thread's position in the sidebar entries and select it.
9615 let thread_index = sidebar.read_with(cx, |sidebar, _| {
9616 sidebar.contents.entries.iter().position(|entry| {
9617 matches!(
9618 entry,
9619 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
9620 )
9621 })
9622 });
9623 if let Some(ix) = thread_index {
9624 sidebar.update_in(cx, |sidebar, window, cx| {
9625 sidebar.selection = Some(ix);
9626 sidebar.confirm(&Confirm, window, cx);
9627 });
9628 cx.run_until_parked();
9629 }
9630 }
9631 Operation::SwitchToProjectGroup { index } => {
9632 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9633 let keys = mw.project_group_keys();
9634 let key = &keys[index];
9635 mw.workspaces_for_project_group(key, cx)
9636 .and_then(|ws| ws.first().cloned())
9637 .unwrap_or_else(|| mw.workspace().clone())
9638 });
9639 multi_workspace.update_in(cx, |mw, window, cx| {
9640 mw.activate(workspace, window, cx);
9641 });
9642 }
9643 Operation::AddLinkedWorktree {
9644 project_group_index,
9645 } => {
9646 // Get the main worktree path from the project group key.
9647 let main_path = multi_workspace.read_with(cx, |mw, _| {
9648 let keys = mw.project_group_keys();
9649 let key = &keys[project_group_index];
9650 key.path_list()
9651 .paths()
9652 .first()
9653 .unwrap()
9654 .to_string_lossy()
9655 .to_string()
9656 });
9657 let dot_git = format!("{}/.git", main_path);
9658 let worktree_name = state.next_worktree_name();
9659 let worktree_path = format!("/worktrees/{}", worktree_name);
9660
9661 state.fs
9662 .insert_tree(
9663 &worktree_path,
9664 serde_json::json!({
9665 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
9666 "src": {},
9667 }),
9668 )
9669 .await;
9670
9671 // Also create the worktree metadata dir inside the main repo's .git
9672 state
9673 .fs
9674 .insert_tree(
9675 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
9676 serde_json::json!({
9677 "commondir": "../../",
9678 "HEAD": format!("ref: refs/heads/{}", worktree_name),
9679 }),
9680 )
9681 .await;
9682
9683 let dot_git_path = std::path::Path::new(&dot_git);
9684 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
9685 state
9686 .fs
9687 .add_linked_worktree_for_repo(
9688 dot_git_path,
9689 false,
9690 git::repository::Worktree {
9691 path: worktree_pathbuf,
9692 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
9693 sha: "aaa".into(),
9694 is_main: false,
9695 is_bare: false,
9696 },
9697 )
9698 .await;
9699
9700 // Re-scan the main workspace's project so it discovers the new worktree.
9701 let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
9702 let keys = mw.project_group_keys();
9703 let key = &keys[project_group_index];
9704 mw.workspaces_for_project_group(key, cx)
9705 .and_then(|ws| ws.first().cloned())
9706 .unwrap()
9707 });
9708 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
9709 main_project
9710 .update(cx, |p, cx| p.git_scans_complete(cx))
9711 .await;
9712
9713 state.unopened_worktrees.push(UnopenedWorktree {
9714 path: worktree_path,
9715 main_workspace_path: main_path.clone(),
9716 });
9717 }
9718 Operation::AddWorktreeToProject {
9719 project_group_index,
9720 } => {
9721 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9722 let keys = mw.project_group_keys();
9723 let key = &keys[project_group_index];
9724 mw.workspaces_for_project_group(key, cx)
9725 .and_then(|ws| ws.first().cloned())
9726 });
9727 let Some(workspace) = workspace else { return };
9728 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9729
9730 let new_path = state.next_workspace_path();
9731 state
9732 .fs
9733 .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
9734 .await;
9735
9736 let result = project
9737 .update(cx, |project, cx| {
9738 project.find_or_create_worktree(&new_path, true, cx)
9739 })
9740 .await;
9741 if result.is_err() {
9742 return;
9743 }
9744 cx.run_until_parked();
9745 }
9746 Operation::RemoveWorktreeFromProject {
9747 project_group_index,
9748 } => {
9749 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9750 let keys = mw.project_group_keys();
9751 let key = &keys[project_group_index];
9752 mw.workspaces_for_project_group(key, cx)
9753 .and_then(|ws| ws.first().cloned())
9754 });
9755 let Some(workspace) = workspace else { return };
9756 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9757
9758 let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
9759 if worktree_count <= 1 {
9760 return;
9761 }
9762
9763 let worktree_id = project.read_with(cx, |p, cx| {
9764 p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
9765 });
9766 if let Some(worktree_id) = worktree_id {
9767 project.update(cx, |project, cx| {
9768 project.remove_worktree(worktree_id, cx);
9769 });
9770 cx.run_until_parked();
9771 }
9772 }
9773 }
9774 }
9775
9776 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
9777 sidebar.update_in(cx, |sidebar, _window, cx| {
9778 if let Some(mw) = sidebar.multi_workspace.upgrade() {
9779 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
9780 }
9781 sidebar.update_entries(cx);
9782 });
9783 }
9784
9785 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9786 verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
9787 verify_no_duplicate_threads(sidebar)?;
9788 verify_all_threads_are_shown(sidebar, cx)?;
9789 verify_active_state_matches_current_workspace(sidebar, cx)?;
9790 verify_all_workspaces_are_reachable(sidebar, cx)?;
9791 verify_workspace_group_key_integrity(sidebar, cx)?;
9792 Ok(())
9793 }
9794
9795 fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
9796 let mut seen: HashSet<acp::SessionId> = HashSet::default();
9797 let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
9798
9799 for entry in &sidebar.contents.entries {
9800 if let Some(session_id) = entry.session_id() {
9801 if !seen.insert(session_id.clone()) {
9802 let title = match entry {
9803 ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
9804 _ => "<unknown>".to_string(),
9805 };
9806 duplicates.push((session_id.clone(), title));
9807 }
9808 }
9809 }
9810
9811 anyhow::ensure!(
9812 duplicates.is_empty(),
9813 "threads appear more than once in sidebar: {:?}",
9814 duplicates,
9815 );
9816 Ok(())
9817 }
9818
9819 fn verify_every_group_in_multiworkspace_is_shown(
9820 sidebar: &Sidebar,
9821 cx: &App,
9822 ) -> anyhow::Result<()> {
9823 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9824 anyhow::bail!("sidebar should still have an associated multi-workspace");
9825 };
9826
9827 let mw = multi_workspace.read(cx);
9828
9829 // Every project group key in the multi-workspace that has a
9830 // non-empty path list should appear as a ProjectHeader in the
9831 // sidebar.
9832 let all_keys = mw.project_group_keys();
9833 let expected_keys: HashSet<&ProjectGroupKey> = all_keys
9834 .iter()
9835 .filter(|k| !k.path_list().paths().is_empty())
9836 .collect();
9837
9838 let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
9839 .contents
9840 .entries
9841 .iter()
9842 .filter_map(|entry| match entry {
9843 ListEntry::ProjectHeader { key, .. } => Some(key),
9844 _ => None,
9845 })
9846 .collect();
9847
9848 let missing = &expected_keys - &sidebar_keys;
9849 let stray = &sidebar_keys - &expected_keys;
9850
9851 anyhow::ensure!(
9852 missing.is_empty() && stray.is_empty(),
9853 "sidebar project groups don't match multi-workspace.\n\
9854 Only in multi-workspace (missing): {:?}\n\
9855 Only in sidebar (stray): {:?}",
9856 missing,
9857 stray,
9858 );
9859
9860 Ok(())
9861 }
9862
9863 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9864 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9865 anyhow::bail!("sidebar should still have an associated multi-workspace");
9866 };
9867 let workspaces = multi_workspace
9868 .read(cx)
9869 .workspaces()
9870 .cloned()
9871 .collect::<Vec<_>>();
9872 let thread_store = ThreadMetadataStore::global(cx);
9873
9874 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
9875 .contents
9876 .entries
9877 .iter()
9878 .filter_map(|entry| entry.session_id().cloned())
9879 .collect();
9880
9881 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
9882
9883 // Query using the same approach as the sidebar: iterate project
9884 // group keys, then do main + legacy queries per group.
9885 let mw = multi_workspace.read(cx);
9886 let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
9887 HashMap::default();
9888 for workspace in &workspaces {
9889 let key = workspace.read(cx).project_group_key(cx);
9890 workspaces_by_group
9891 .entry(key)
9892 .or_default()
9893 .push(workspace.clone());
9894 }
9895
9896 for group_key in mw.project_group_keys() {
9897 let path_list = group_key.path_list().clone();
9898 if path_list.paths().is_empty() {
9899 continue;
9900 }
9901
9902 let group_workspaces = workspaces_by_group
9903 .get(&group_key)
9904 .map(|ws| ws.as_slice())
9905 .unwrap_or_default();
9906
9907 // Main code path queries (run for all groups, even without workspaces).
9908 // Skip drafts (session_id: None) — they are shown via the
9909 // panel's draft_thread_ids, not by session_id matching.
9910 for metadata in thread_store
9911 .read(cx)
9912 .entries_for_main_worktree_path(&path_list, None)
9913 {
9914 if let Some(sid) = metadata.session_id.clone() {
9915 metadata_thread_ids.insert(sid);
9916 }
9917 }
9918 for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
9919 if let Some(sid) = metadata.session_id.clone() {
9920 metadata_thread_ids.insert(sid);
9921 }
9922 }
9923
9924 // Legacy: per-workspace queries for different root paths.
9925 let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
9926 .iter()
9927 .flat_map(|ws| {
9928 ws.read(cx)
9929 .root_paths(cx)
9930 .into_iter()
9931 .map(|p| p.to_path_buf())
9932 })
9933 .collect();
9934
9935 for workspace in group_workspaces {
9936 let ws_path_list = workspace_path_list(workspace, cx);
9937 if ws_path_list != path_list {
9938 for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
9939 if let Some(sid) = metadata.session_id.clone() {
9940 metadata_thread_ids.insert(sid);
9941 }
9942 }
9943 }
9944 }
9945
9946 for workspace in group_workspaces {
9947 for snapshot in root_repository_snapshots(workspace, cx) {
9948 let repo_path_list =
9949 PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
9950 if repo_path_list != path_list {
9951 continue;
9952 }
9953 for linked_worktree in snapshot.linked_worktrees() {
9954 if covered_paths.contains(&*linked_worktree.path) {
9955 continue;
9956 }
9957 let worktree_path_list =
9958 PathList::new(std::slice::from_ref(&linked_worktree.path));
9959 for metadata in thread_store
9960 .read(cx)
9961 .entries_for_path(&worktree_path_list, None)
9962 {
9963 if let Some(sid) = metadata.session_id.clone() {
9964 metadata_thread_ids.insert(sid);
9965 }
9966 }
9967 }
9968 }
9969 }
9970 }
9971
9972 anyhow::ensure!(
9973 sidebar_thread_ids == metadata_thread_ids,
9974 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
9975 sidebar_thread_ids,
9976 metadata_thread_ids,
9977 );
9978 Ok(())
9979 }
9980
9981 fn verify_active_state_matches_current_workspace(
9982 sidebar: &Sidebar,
9983 cx: &App,
9984 ) -> anyhow::Result<()> {
9985 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9986 anyhow::bail!("sidebar should still have an associated multi-workspace");
9987 };
9988
9989 let active_workspace = multi_workspace.read(cx).workspace();
9990
9991 // 1. active_entry should be Some when the panel has content.
9992 // It may be None when the panel is uninitialized (no drafts,
9993 // no threads), which is fine.
9994 // It may also temporarily point at a different workspace
9995 // when the workspace just changed and the new panel has no
9996 // content yet.
9997 let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
9998 let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
9999 || panel.read(cx).active_conversation_view().is_some();
10000
10001 let Some(entry) = sidebar.active_entry.as_ref() else {
10002 if panel_has_content {
10003 anyhow::bail!("active_entry is None but panel has content (draft or thread)");
10004 }
10005 return Ok(());
10006 };
10007
10008 // If the entry workspace doesn't match the active workspace
10009 // and the panel has no content, this is a transient state that
10010 // will resolve when the panel gets content.
10011 if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
10012 return Ok(());
10013 }
10014
10015 // 2. The entry's workspace must agree with the multi-workspace's
10016 // active workspace.
10017 anyhow::ensure!(
10018 entry.workspace().entity_id() == active_workspace.entity_id(),
10019 "active_entry workspace ({:?}) != active workspace ({:?})",
10020 entry.workspace().entity_id(),
10021 active_workspace.entity_id(),
10022 );
10023
10024 // 3. The entry must match the agent panel's current state.
10025 if panel.read(cx).active_thread_id(cx).is_some() {
10026 anyhow::ensure!(
10027 matches!(entry, ActiveEntry { .. }),
10028 "panel shows a tracked draft but active_entry is {:?}",
10029 entry,
10030 );
10031 } else if let Some(thread_id) = panel
10032 .read(cx)
10033 .active_conversation_view()
10034 .map(|cv| cv.read(cx).parent_id())
10035 {
10036 anyhow::ensure!(
10037 matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
10038 "panel has thread {:?} but active_entry is {:?}",
10039 thread_id,
10040 entry,
10041 );
10042 }
10043
10044 // 4. Exactly one entry in sidebar contents must be uniquely
10045 // identified by the active_entry.
10046 let matching_count = sidebar
10047 .contents
10048 .entries
10049 .iter()
10050 .filter(|e| entry.matches_entry(e))
10051 .count();
10052 if matching_count != 1 {
10053 let thread_entries: Vec<_> = sidebar
10054 .contents
10055 .entries
10056 .iter()
10057 .filter_map(|e| match e {
10058 ListEntry::Thread(t) => Some(format!(
10059 "tid={:?} sid={:?} draft={}",
10060 t.metadata.thread_id, t.metadata.session_id, t.is_draft
10061 )),
10062 _ => None,
10063 })
10064 .collect();
10065 let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
10066 let store_entries: Vec<_> = store
10067 .entries()
10068 .map(|m| {
10069 format!(
10070 "tid={:?} sid={:?} archived={} paths={:?}",
10071 m.thread_id,
10072 m.session_id,
10073 m.archived,
10074 m.folder_paths()
10075 )
10076 })
10077 .collect();
10078 anyhow::bail!(
10079 "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
10080 entry,
10081 matching_count,
10082 thread_entries,
10083 store_entries,
10084 );
10085 }
10086
10087 Ok(())
10088 }
10089
10090 /// Every workspace in the multi-workspace should be "reachable" from
10091 /// the sidebar — meaning there is at least one entry (thread, draft,
10092 /// new-thread, or project header) that, when clicked, would activate
10093 /// that workspace.
10094 fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
10095 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
10096 anyhow::bail!("sidebar should still have an associated multi-workspace");
10097 };
10098
10099 let multi_workspace = multi_workspace.read(cx);
10100
10101 let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
10102 .contents
10103 .entries
10104 .iter()
10105 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
10106 .map(|ws| ws.entity_id())
10107 .collect();
10108
10109 let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
10110 .workspaces()
10111 .map(|ws| ws.entity_id())
10112 .collect();
10113
10114 let unreachable = &all_workspace_ids - &reachable_workspaces;
10115
10116 anyhow::ensure!(
10117 unreachable.is_empty(),
10118 "The following workspaces are not reachable from any sidebar entry: {:?}",
10119 unreachable,
10120 );
10121
10122 Ok(())
10123 }
10124
10125 fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
10126 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
10127 anyhow::bail!("sidebar should still have an associated multi-workspace");
10128 };
10129 multi_workspace
10130 .read(cx)
10131 .assert_project_group_key_integrity(cx)
10132 }
10133
10134 #[gpui::property_test(config = ProptestConfig {
10135 cases: 20,
10136 ..Default::default()
10137 })]
10138 async fn test_sidebar_invariants(
10139 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
10140 raw_operations: Vec<u32>,
10141 cx: &mut TestAppContext,
10142 ) {
10143 use std::sync::atomic::{AtomicUsize, Ordering};
10144 static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
10145
10146 agent_ui::test_support::init_test(cx);
10147 cx.update(|cx| {
10148 cx.set_global(db::AppDatabase::test_new());
10149 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
10150 cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
10151 format!(
10152 "PROPTEST_THREAD_METADATA_{}",
10153 NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
10154 ),
10155 ));
10156
10157 ThreadStore::init_global(cx);
10158 ThreadMetadataStore::init_global(cx);
10159 language_model::LanguageModelRegistry::test(cx);
10160 prompt_store::init(cx);
10161
10162 // Auto-add an AgentPanel to every workspace so that implicitly
10163 // created workspaces (e.g. from thread activation) also have one.
10164 cx.observe_new(
10165 |workspace: &mut Workspace,
10166 window: Option<&mut Window>,
10167 cx: &mut gpui::Context<Workspace>| {
10168 if let Some(window) = window {
10169 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
10170 workspace.add_panel(panel, window, cx);
10171 }
10172 },
10173 )
10174 .detach();
10175 });
10176
10177 let fs = FakeFs::new(cx.executor());
10178 fs.insert_tree(
10179 "/my-project",
10180 serde_json::json!({
10181 ".git": {},
10182 "src": {},
10183 }),
10184 )
10185 .await;
10186 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10187 let project =
10188 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
10189 .await;
10190 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10191
10192 let (multi_workspace, cx) =
10193 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10194 let sidebar = setup_sidebar(&multi_workspace, cx);
10195
10196 let mut state = TestState::new(fs);
10197 let mut executed: Vec<String> = Vec::new();
10198
10199 for &raw_op in &raw_operations {
10200 let project_group_count =
10201 multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
10202 let operation = state.generate_operation(raw_op, project_group_count);
10203 executed.push(format!("{:?}", operation));
10204 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
10205 cx.run_until_parked();
10206
10207 update_sidebar(&sidebar, cx);
10208 cx.run_until_parked();
10209
10210 let result =
10211 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
10212 if let Err(err) = result {
10213 let log = executed.join("\n ");
10214 panic!(
10215 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
10216 executed.len(),
10217 );
10218 }
10219 }
10220 }
10221}
10222
10223#[gpui::test]
10224async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
10225 cx: &mut TestAppContext,
10226 server_cx: &mut TestAppContext,
10227) {
10228 init_test(cx);
10229
10230 cx.update(|cx| {
10231 release_channel::init(semver::Version::new(0, 0, 0), cx);
10232 });
10233
10234 let app_state = cx.update(|cx| {
10235 let app_state = workspace::AppState::test(cx);
10236 workspace::init(app_state.clone(), cx);
10237 app_state
10238 });
10239
10240 // Set up the remote server side.
10241 let server_fs = FakeFs::new(server_cx.executor());
10242 server_fs
10243 .insert_tree(
10244 "/project",
10245 serde_json::json!({
10246 ".git": {},
10247 "src": { "main.rs": "fn main() {}" }
10248 }),
10249 )
10250 .await;
10251 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10252
10253 // Create the linked worktree checkout path on the remote server,
10254 // but do not yet register it as a git-linked worktree. The real
10255 // regrouping update in this test should happen only after the
10256 // sidebar opens the closed remote thread.
10257 server_fs
10258 .insert_tree(
10259 "/project-wt-1",
10260 serde_json::json!({
10261 "src": { "main.rs": "fn main() {}" }
10262 }),
10263 )
10264 .await;
10265
10266 server_cx.update(|cx| {
10267 release_channel::init(semver::Version::new(0, 0, 0), cx);
10268 });
10269
10270 let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
10271
10272 server_cx.update(remote_server::HeadlessProject::init);
10273 let server_executor = server_cx.executor();
10274 let _headless = server_cx.new(|cx| {
10275 remote_server::HeadlessProject::new(
10276 remote_server::HeadlessAppState {
10277 session: server_session,
10278 fs: server_fs.clone(),
10279 http_client: Arc::new(http_client::BlockedHttpClient),
10280 node_runtime: node_runtime::NodeRuntime::unavailable(),
10281 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10282 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10283 startup_time: std::time::Instant::now(),
10284 },
10285 false,
10286 cx,
10287 )
10288 });
10289
10290 // Connect the client side and build a remote project.
10291 let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
10292 let project = cx.update(|cx| {
10293 let project_client = client::Client::new(
10294 Arc::new(clock::FakeSystemClock::new()),
10295 http_client::FakeHttpClient::with_404_response(),
10296 cx,
10297 );
10298 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
10299 project::Project::remote(
10300 remote_client,
10301 project_client,
10302 node_runtime::NodeRuntime::unavailable(),
10303 user_store,
10304 app_state.languages.clone(),
10305 app_state.fs.clone(),
10306 false,
10307 cx,
10308 )
10309 });
10310
10311 // Open the remote worktree.
10312 project
10313 .update(cx, |project, cx| {
10314 project.find_or_create_worktree(Path::new("/project"), true, cx)
10315 })
10316 .await
10317 .expect("should open remote worktree");
10318 cx.run_until_parked();
10319
10320 // Verify the project is remote.
10321 project.read_with(cx, |project, cx| {
10322 assert!(!project.is_local(), "project should be remote");
10323 assert!(
10324 project.remote_connection_options(cx).is_some(),
10325 "project should have remote connection options"
10326 );
10327 });
10328
10329 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10330
10331 // Create MultiWorkspace with the remote project.
10332 let (multi_workspace, cx) =
10333 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10334 let sidebar = setup_sidebar(&multi_workspace, cx);
10335
10336 cx.run_until_parked();
10337
10338 // Save a thread for the main remote workspace (folder_paths match
10339 // the open workspace, so it will be classified as Open).
10340 let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
10341 save_thread_metadata(
10342 main_thread_id.clone(),
10343 Some("Main Thread".into()),
10344 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10345 None,
10346 &project,
10347 cx,
10348 );
10349 cx.run_until_parked();
10350
10351 // Save a thread whose folder_paths point to a linked worktree path
10352 // that doesn't have an open workspace ("/project-wt-1"), but whose
10353 // main_worktree_paths match the project group key so it appears
10354 // in the sidebar under the same remote group. This simulates a
10355 // linked worktree workspace that was closed.
10356 let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10357 let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
10358 (
10359 p.project_group_key(cx).path_list().clone(),
10360 p.remote_connection_options(cx),
10361 )
10362 });
10363 cx.update(|_window, cx| {
10364 let metadata = ThreadMetadata {
10365 thread_id: ThreadId::new(),
10366 session_id: Some(remote_thread_id.clone()),
10367 agent_id: agent::ZED_AGENT_ID.clone(),
10368 title: Some("Worktree Thread".into()),
10369 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10370 created_at: None,
10371 worktree_paths: WorktreePaths::from_path_lists(
10372 main_worktree_paths,
10373 PathList::new(&[PathBuf::from("/project-wt-1")]),
10374 )
10375 .unwrap(),
10376 archived: false,
10377 remote_connection,
10378 };
10379 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10380 });
10381 cx.run_until_parked();
10382
10383 focus_sidebar(&sidebar, cx);
10384 sidebar.update_in(cx, |sidebar, _window, _cx| {
10385 sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10386 matches!(
10387 entry,
10388 ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10389 )
10390 });
10391 });
10392
10393 let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10394 let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10395
10396 sidebar
10397 .update(cx, |_, cx| {
10398 cx.observe_self(move |sidebar, _cx| {
10399 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10400 if let ListEntry::ProjectHeader { label, .. } = entry {
10401 Some(label.as_ref())
10402 } else {
10403 None
10404 }
10405 });
10406
10407 let Some(project_header) = project_headers.next() else {
10408 saw_separate_project_header_for_observer
10409 .store(true, std::sync::atomic::Ordering::SeqCst);
10410 return;
10411 };
10412
10413 if project_header != "project" || project_headers.next().is_some() {
10414 saw_separate_project_header_for_observer
10415 .store(true, std::sync::atomic::Ordering::SeqCst);
10416 }
10417 })
10418 })
10419 .detach();
10420
10421 multi_workspace.update(cx, |multi_workspace, cx| {
10422 let workspace = multi_workspace.workspace().clone();
10423 workspace.update(cx, |workspace: &mut Workspace, cx| {
10424 let remote_client = workspace
10425 .project()
10426 .read(cx)
10427 .remote_client()
10428 .expect("main remote project should have a remote client");
10429 remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10430 remote_client.force_server_not_running(cx);
10431 });
10432 });
10433 });
10434 cx.run_until_parked();
10435
10436 let (server_session_2, connect_guard_2) =
10437 remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10438 let _headless_2 = server_cx.new(|cx| {
10439 remote_server::HeadlessProject::new(
10440 remote_server::HeadlessAppState {
10441 session: server_session_2,
10442 fs: server_fs.clone(),
10443 http_client: Arc::new(http_client::BlockedHttpClient),
10444 node_runtime: node_runtime::NodeRuntime::unavailable(),
10445 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10446 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10447 startup_time: std::time::Instant::now(),
10448 },
10449 false,
10450 cx,
10451 )
10452 });
10453 drop(connect_guard_2);
10454
10455 let window = cx.windows()[0];
10456 cx.update_window(window, |_, window, cx| {
10457 window.dispatch_action(Confirm.boxed_clone(), cx);
10458 })
10459 .unwrap();
10460
10461 cx.run_until_parked();
10462
10463 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10464 assert_eq!(
10465 mw.workspaces().count(),
10466 2,
10467 "confirming a closed remote thread should open a second workspace"
10468 );
10469 mw.workspaces()
10470 .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10471 .unwrap()
10472 .clone()
10473 });
10474
10475 server_fs
10476 .add_linked_worktree_for_repo(
10477 Path::new("/project/.git"),
10478 true,
10479 git::repository::Worktree {
10480 path: PathBuf::from("/project-wt-1"),
10481 ref_name: Some("refs/heads/feature-wt".into()),
10482 sha: "abc123".into(),
10483 is_main: false,
10484 is_bare: false,
10485 },
10486 )
10487 .await;
10488
10489 server_cx.run_until_parked();
10490 cx.run_until_parked();
10491 server_cx.run_until_parked();
10492 cx.run_until_parked();
10493
10494 let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10495 let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10496 workspace.project().read(cx).project_group_key(cx)
10497 });
10498
10499 assert_eq!(
10500 group_after_update,
10501 project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10502 "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10503 final sidebar entries: {:?}",
10504 entries_after_update,
10505 );
10506
10507 sidebar.update(cx, |sidebar, _cx| {
10508 assert_remote_project_integration_sidebar_state(
10509 sidebar,
10510 &main_thread_id,
10511 &remote_thread_id,
10512 );
10513 });
10514
10515 assert!(
10516 !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10517 "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10518 final group: {:?}; final sidebar entries: {:?}",
10519 group_after_update,
10520 entries_after_update,
10521 );
10522}
10523
10524#[gpui::test]
10525async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10526 // When the thread's folder_paths don't exactly match any workspace's
10527 // root paths (e.g. because a folder was added to the workspace after
10528 // the thread was created), workspace_to_remove is None. But the linked
10529 // worktree workspace still needs to be removed so that its worktree
10530 // entities are released, allowing git worktree removal to proceed.
10531 //
10532 // With the fix, archive_thread scans roots_to_archive for any linked
10533 // worktree workspaces and includes them in the removal set, even when
10534 // the thread's folder_paths don't match the workspace's root paths.
10535 init_test(cx);
10536 let fs = FakeFs::new(cx.executor());
10537
10538 fs.insert_tree(
10539 "/project",
10540 serde_json::json!({
10541 ".git": {
10542 "worktrees": {
10543 "feature-a": {
10544 "commondir": "../../",
10545 "HEAD": "ref: refs/heads/feature-a",
10546 },
10547 },
10548 },
10549 "src": {},
10550 }),
10551 )
10552 .await;
10553
10554 fs.insert_tree(
10555 "/wt-feature-a",
10556 serde_json::json!({
10557 ".git": "gitdir: /project/.git/worktrees/feature-a",
10558 "src": {
10559 "main.rs": "fn main() {}",
10560 },
10561 }),
10562 )
10563 .await;
10564
10565 fs.add_linked_worktree_for_repo(
10566 Path::new("/project/.git"),
10567 false,
10568 git::repository::Worktree {
10569 path: PathBuf::from("/wt-feature-a"),
10570 ref_name: Some("refs/heads/feature-a".into()),
10571 sha: "abc".into(),
10572 is_main: false,
10573 is_bare: false,
10574 },
10575 )
10576 .await;
10577
10578 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10579
10580 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10581 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
10582
10583 main_project
10584 .update(cx, |p, cx| p.git_scans_complete(cx))
10585 .await;
10586 worktree_project
10587 .update(cx, |p, cx| p.git_scans_complete(cx))
10588 .await;
10589
10590 let (multi_workspace, cx) =
10591 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10592 let sidebar = setup_sidebar(&multi_workspace, cx);
10593
10594 multi_workspace.update_in(cx, |mw, window, cx| {
10595 mw.test_add_workspace(worktree_project.clone(), window, cx)
10596 });
10597
10598 // Save thread metadata using folder_paths that DON'T match the
10599 // workspace's root paths. This simulates the case where the workspace's
10600 // paths diverged (e.g. a folder was added after thread creation).
10601 // This causes workspace_to_remove to be None because
10602 // workspace_for_paths can't find a workspace with these exact paths.
10603 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10604 save_thread_metadata_with_main_paths(
10605 "worktree-thread",
10606 "Worktree Thread",
10607 PathList::new(&[
10608 PathBuf::from("/wt-feature-a"),
10609 PathBuf::from("/nonexistent"),
10610 ]),
10611 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10612 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10613 cx,
10614 );
10615
10616 // Also save a main thread so the sidebar has something to show.
10617 save_thread_metadata(
10618 acp::SessionId::new(Arc::from("main-thread")),
10619 Some("Main Thread".into()),
10620 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10621 None,
10622 &main_project,
10623 cx,
10624 );
10625 cx.run_until_parked();
10626
10627 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10628 cx.run_until_parked();
10629
10630 assert_eq!(
10631 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10632 2,
10633 "should start with 2 workspaces (main + linked worktree)"
10634 );
10635
10636 // Archive the worktree thread.
10637 sidebar.update_in(cx, |sidebar, window, cx| {
10638 sidebar.archive_thread(&wt_thread_id, window, cx);
10639 });
10640
10641 cx.run_until_parked();
10642
10643 // The linked worktree workspace should have been removed, even though
10644 // workspace_to_remove was None (paths didn't match).
10645 assert_eq!(
10646 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10647 1,
10648 "linked worktree workspace should be removed after archiving, \
10649 even when folder_paths don't match workspace root paths"
10650 );
10651
10652 // The thread should still be archived (not unarchived due to an error).
10653 let still_archived = cx.update(|_, cx| {
10654 ThreadMetadataStore::global(cx)
10655 .read(cx)
10656 .entry_by_session(&wt_thread_id)
10657 .map(|t| t.archived)
10658 });
10659 assert_eq!(
10660 still_archived,
10661 Some(true),
10662 "thread should still be archived (not rolled back due to error)"
10663 );
10664
10665 // The linked worktree directory should be removed from disk.
10666 assert!(
10667 !fs.is_dir(Path::new("/wt-feature-a")).await,
10668 "linked worktree directory should be removed from disk"
10669 );
10670}
10671
10672#[gpui::test]
10673async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10674 // When a workspace contains both a worktree being archived and other
10675 // worktrees that should remain, only the editor items referencing the
10676 // archived worktree should be closed — the workspace itself must be
10677 // preserved.
10678 init_test(cx);
10679 let fs = FakeFs::new(cx.executor());
10680
10681 fs.insert_tree(
10682 "/main-repo",
10683 serde_json::json!({
10684 ".git": {
10685 "worktrees": {
10686 "feature-b": {
10687 "commondir": "../../",
10688 "HEAD": "ref: refs/heads/feature-b",
10689 },
10690 },
10691 },
10692 "src": {
10693 "lib.rs": "pub fn hello() {}",
10694 },
10695 }),
10696 )
10697 .await;
10698
10699 fs.insert_tree(
10700 "/wt-feature-b",
10701 serde_json::json!({
10702 ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10703 "src": {
10704 "main.rs": "fn main() { hello(); }",
10705 },
10706 }),
10707 )
10708 .await;
10709
10710 fs.add_linked_worktree_for_repo(
10711 Path::new("/main-repo/.git"),
10712 false,
10713 git::repository::Worktree {
10714 path: PathBuf::from("/wt-feature-b"),
10715 ref_name: Some("refs/heads/feature-b".into()),
10716 sha: "def".into(),
10717 is_main: false,
10718 is_bare: false,
10719 },
10720 )
10721 .await;
10722
10723 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10724
10725 // Create a single project that contains BOTH the main repo and the
10726 // linked worktree — this makes it a "mixed" workspace.
10727 let mixed_project = project::Project::test(
10728 fs.clone(),
10729 ["/main-repo".as_ref(), "/wt-feature-b".as_ref()],
10730 cx,
10731 )
10732 .await;
10733
10734 mixed_project
10735 .update(cx, |p, cx| p.git_scans_complete(cx))
10736 .await;
10737
10738 let (multi_workspace, cx) = cx
10739 .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10740 let sidebar = setup_sidebar(&multi_workspace, cx);
10741
10742 // Open editor items in both worktrees so we can verify which ones
10743 // get closed.
10744 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10745
10746 let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10747 ws.project()
10748 .read(cx)
10749 .visible_worktrees(cx)
10750 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10751 .collect()
10752 });
10753
10754 let main_repo_wt_id = worktree_ids
10755 .iter()
10756 .find(|(_, path)| path.ends_with("main-repo"))
10757 .map(|(id, _)| *id)
10758 .expect("should find main-repo worktree");
10759
10760 let feature_b_wt_id = worktree_ids
10761 .iter()
10762 .find(|(_, path)| path.ends_with("wt-feature-b"))
10763 .map(|(id, _)| *id)
10764 .expect("should find wt-feature-b worktree");
10765
10766 // Open files from both worktrees.
10767 let main_repo_path = project::ProjectPath {
10768 worktree_id: main_repo_wt_id,
10769 path: Arc::from(rel_path("src/lib.rs")),
10770 };
10771 let feature_b_path = project::ProjectPath {
10772 worktree_id: feature_b_wt_id,
10773 path: Arc::from(rel_path("src/main.rs")),
10774 };
10775
10776 workspace
10777 .update_in(cx, |ws, window, cx| {
10778 ws.open_path(main_repo_path.clone(), None, true, window, cx)
10779 })
10780 .await
10781 .expect("should open main-repo file");
10782 workspace
10783 .update_in(cx, |ws, window, cx| {
10784 ws.open_path(feature_b_path.clone(), None, true, window, cx)
10785 })
10786 .await
10787 .expect("should open feature-b file");
10788
10789 cx.run_until_parked();
10790
10791 // Verify both items are open.
10792 let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10793 ws.panes()
10794 .iter()
10795 .flat_map(|pane| {
10796 pane.read(cx)
10797 .items()
10798 .filter_map(|item| item.project_path(cx))
10799 })
10800 .collect()
10801 });
10802 assert!(
10803 open_paths_before
10804 .iter()
10805 .any(|pp| pp.worktree_id == main_repo_wt_id),
10806 "main-repo file should be open"
10807 );
10808 assert!(
10809 open_paths_before
10810 .iter()
10811 .any(|pp| pp.worktree_id == feature_b_wt_id),
10812 "feature-b file should be open"
10813 );
10814
10815 // Save thread metadata for the linked worktree with deliberately
10816 // mismatched folder_paths to trigger the scan-based detection.
10817 save_thread_metadata_with_main_paths(
10818 "feature-b-thread",
10819 "Feature B Thread",
10820 PathList::new(&[
10821 PathBuf::from("/wt-feature-b"),
10822 PathBuf::from("/nonexistent"),
10823 ]),
10824 PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10825 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10826 cx,
10827 );
10828
10829 // Save another thread that references only the main repo (not the
10830 // linked worktree) so archiving the feature-b thread's worktree isn't
10831 // blocked by another unarchived thread referencing the same path.
10832 save_thread_metadata_with_main_paths(
10833 "other-thread",
10834 "Other Thread",
10835 PathList::new(&[PathBuf::from("/main-repo")]),
10836 PathList::new(&[PathBuf::from("/main-repo")]),
10837 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10838 cx,
10839 );
10840 cx.run_until_parked();
10841
10842 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10843 cx.run_until_parked();
10844
10845 // There should still be exactly 1 workspace.
10846 assert_eq!(
10847 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10848 1,
10849 "should have 1 workspace (the mixed workspace)"
10850 );
10851
10852 // Archive the feature-b thread.
10853 let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10854 sidebar.update_in(cx, |sidebar, window, cx| {
10855 sidebar.archive_thread(&fb_session_id, window, cx);
10856 });
10857
10858 cx.run_until_parked();
10859
10860 // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10861 assert_eq!(
10862 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10863 1,
10864 "mixed workspace should be preserved"
10865 );
10866
10867 // Only the feature-b editor item should have been closed.
10868 let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10869 ws.panes()
10870 .iter()
10871 .flat_map(|pane| {
10872 pane.read(cx)
10873 .items()
10874 .filter_map(|item| item.project_path(cx))
10875 })
10876 .collect()
10877 });
10878 assert!(
10879 open_paths_after
10880 .iter()
10881 .any(|pp| pp.worktree_id == main_repo_wt_id),
10882 "main-repo file should still be open"
10883 );
10884 assert!(
10885 !open_paths_after
10886 .iter()
10887 .any(|pp| pp.worktree_id == feature_b_wt_id),
10888 "feature-b file should have been closed"
10889 );
10890}
10891
10892#[test]
10893fn test_worktree_info_branch_names_for_main_worktrees() {
10894 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10895 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10896
10897 let branch_by_path: HashMap<PathBuf, SharedString> =
10898 [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10899 .into_iter()
10900 .collect();
10901
10902 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10903 assert_eq!(infos.len(), 1);
10904 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10905 assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10906 assert_eq!(infos[0].name, SharedString::from("myapp"));
10907}
10908
10909#[test]
10910fn test_worktree_info_branch_names_for_linked_worktrees() {
10911 let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10912 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10913 let worktree_paths =
10914 WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10915
10916 let branch_by_path: HashMap<PathBuf, SharedString> = [(
10917 PathBuf::from("/projects/myapp-feature"),
10918 "feature-branch".into(),
10919 )]
10920 .into_iter()
10921 .collect();
10922
10923 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10924 assert_eq!(infos.len(), 1);
10925 assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10926 assert_eq!(
10927 infos[0].branch_name,
10928 Some(SharedString::from("feature-branch"))
10929 );
10930}
10931
10932#[test]
10933fn test_worktree_info_missing_branch_returns_none() {
10934 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10935 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10936
10937 let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10938
10939 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10940 assert_eq!(infos.len(), 1);
10941 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10942 assert_eq!(infos[0].branch_name, None);
10943 assert_eq!(infos[0].name, SharedString::from("myapp"));
10944}