1use super::*;
2use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
3use agent::ThreadStore;
4use agent_ui::{
5 ThreadId,
6 test_support::{
7 active_session_id, active_thread_id, open_thread_with_connection, send_message,
8 },
9 thread_metadata_store::{ThreadMetadata, WorktreePaths},
10};
11use chrono::DateTime;
12use fs::{FakeFs, Fs};
13use gpui::TestAppContext;
14use pretty_assertions::assert_eq;
15use project::AgentId;
16use settings::SettingsStore;
17use std::{
18 path::{Path, PathBuf},
19 sync::Arc,
20};
21use util::{path_list::PathList, rel_path::rel_path};
22
23fn init_test(cx: &mut TestAppContext) {
24 cx.update(|cx| {
25 let settings_store = SettingsStore::test(cx);
26 cx.set_global(settings_store);
27 theme_settings::init(theme::LoadThemes::JustBase, cx);
28 editor::init(cx);
29 ThreadStore::init_global(cx);
30 ThreadMetadataStore::init_global(cx);
31 language_model::LanguageModelRegistry::test(cx);
32 prompt_store::init(cx);
33 });
34}
35
36#[track_caller]
37fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
38 let active = sidebar.active_entry.as_ref();
39 let matches = active.is_some_and(|entry| {
40 // Match by session_id directly on active_entry.
41 entry.session_id.as_ref() == Some(session_id)
42 // Or match by finding the thread in sidebar entries.
43 || sidebar.contents.entries.iter().any(|list_entry| {
44 matches!(list_entry, ListEntry::Thread(t)
45 if t.metadata.session_id.as_ref() == Some(session_id)
46 && entry.matches_entry(list_entry))
47 })
48 });
49 assert!(
50 matches,
51 "{msg}: expected active_entry for session {session_id:?}, got {:?}",
52 active,
53 );
54}
55
56#[track_caller]
57fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
58 let thread_id = sidebar
59 .contents
60 .entries
61 .iter()
62 .find_map(|entry| match entry {
63 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
64 Some(t.metadata.thread_id)
65 }
66 _ => None,
67 });
68 match thread_id {
69 Some(tid) => {
70 matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid)
71 }
72 // Thread not in sidebar entries — can't confirm it's active.
73 None => false,
74 }
75}
76
77#[track_caller]
78fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
79 assert!(
80 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace),
81 "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
82 workspace.entity_id(),
83 sidebar.active_entry,
84 );
85}
86
87fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
88 sidebar
89 .contents
90 .entries
91 .iter()
92 .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
93}
94
95#[track_caller]
96fn assert_remote_project_integration_sidebar_state(
97 sidebar: &mut Sidebar,
98 main_thread_id: &acp::SessionId,
99 remote_thread_id: &acp::SessionId,
100) {
101 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
102 if let ListEntry::ProjectHeader { label, .. } = entry {
103 Some(label.as_ref())
104 } else {
105 None
106 }
107 });
108
109 let Some(project_header) = project_headers.next() else {
110 panic!("expected exactly one sidebar project header named `project`, found none");
111 };
112 assert_eq!(
113 project_header, "project",
114 "expected the only sidebar project header to be `project`"
115 );
116 if let Some(unexpected_header) = project_headers.next() {
117 panic!(
118 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
119 );
120 }
121
122 let mut saw_main_thread = false;
123 let mut saw_remote_thread = false;
124 for entry in &sidebar.contents.entries {
125 match entry {
126 ListEntry::ProjectHeader { label, .. } => {
127 assert_eq!(
128 label.as_ref(),
129 "project",
130 "expected the only sidebar project header to be `project`"
131 );
132 }
133 ListEntry::Thread(thread)
134 if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
135 {
136 saw_main_thread = true;
137 }
138 ListEntry::Thread(thread)
139 if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
140 {
141 saw_remote_thread = true;
142 }
143 ListEntry::Thread(thread) => {
144 let title = thread.metadata.display_title();
145 panic!(
146 "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
147 title
148 );
149 }
150 }
151 }
152
153 assert!(
154 saw_main_thread,
155 "expected the sidebar to keep showing `Main Thread` under `project`"
156 );
157 assert!(
158 saw_remote_thread,
159 "expected the sidebar to keep showing `Worktree Thread` under `project`"
160 );
161}
162
163async fn init_test_project(
164 worktree_path: &str,
165 cx: &mut TestAppContext,
166) -> Entity<project::Project> {
167 init_test(cx);
168 let fs = FakeFs::new(cx.executor());
169 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
170 .await;
171 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
172 project::Project::test(fs, [worktree_path.as_ref()], cx).await
173}
174
175fn setup_sidebar(
176 multi_workspace: &Entity<MultiWorkspace>,
177 cx: &mut gpui::VisualTestContext,
178) -> Entity<Sidebar> {
179 let sidebar = setup_sidebar_closed(multi_workspace, cx);
180 multi_workspace.update_in(cx, |mw, window, cx| {
181 mw.toggle_sidebar(window, cx);
182 });
183 cx.run_until_parked();
184 sidebar
185}
186
187fn setup_sidebar_closed(
188 multi_workspace: &Entity<MultiWorkspace>,
189 cx: &mut gpui::VisualTestContext,
190) -> Entity<Sidebar> {
191 let multi_workspace = multi_workspace.clone();
192 let sidebar =
193 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
194 multi_workspace.update(cx, |mw, cx| {
195 mw.register_sidebar(sidebar.clone(), cx);
196 });
197 cx.run_until_parked();
198 sidebar
199}
200
201async fn save_n_test_threads(
202 count: u32,
203 project: &Entity<project::Project>,
204 cx: &mut gpui::VisualTestContext,
205) {
206 for i in 0..count {
207 save_thread_metadata(
208 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
209 Some(format!("Thread {}", i + 1).into()),
210 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
211 None,
212 None,
213 project,
214 cx,
215 )
216 }
217 cx.run_until_parked();
218}
219
220async fn save_test_thread_metadata(
221 session_id: &acp::SessionId,
222 project: &Entity<project::Project>,
223 cx: &mut TestAppContext,
224) {
225 save_thread_metadata(
226 session_id.clone(),
227 Some("Test".into()),
228 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
229 None,
230 None,
231 project,
232 cx,
233 )
234}
235
236async fn save_named_thread_metadata(
237 session_id: &str,
238 title: &str,
239 project: &Entity<project::Project>,
240 cx: &mut gpui::VisualTestContext,
241) {
242 save_thread_metadata(
243 acp::SessionId::new(Arc::from(session_id)),
244 Some(SharedString::from(title.to_string())),
245 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
246 None,
247 None,
248 project,
249 cx,
250 );
251 cx.run_until_parked();
252}
253
254/// Spins up a fresh remote project backed by a headless server sharing
255/// `server_fs`, opens the given worktree path on it, and returns the
256/// project together with the headless entity (which the caller must keep
257/// alive for the duration of the test) and the `RemoteConnectionOptions`
258/// used for the fake server. Passing those options back into
259/// `reuse_opts` on a subsequent call makes the new project share the
260/// same `RemoteConnectionIdentity`, matching how Zed treats multiple
261/// projects on the same SSH host.
262async fn start_remote_project(
263 server_fs: &Arc<FakeFs>,
264 worktree_path: &Path,
265 app_state: &Arc<workspace::AppState>,
266 reuse_opts: Option<&remote::RemoteConnectionOptions>,
267 cx: &mut TestAppContext,
268 server_cx: &mut TestAppContext,
269) -> (
270 Entity<project::Project>,
271 Entity<remote_server::HeadlessProject>,
272 remote::RemoteConnectionOptions,
273) {
274 // Bare `_` on the guard so it's dropped immediately; holding onto it
275 // would deadlock `connect_mock` below since the client waits on the
276 // guard before completing the mock handshake.
277 let (opts, server_session) = match reuse_opts {
278 Some(existing) => {
279 let (session, _) = remote::RemoteClient::fake_server_with_opts(existing, cx, server_cx);
280 (existing.clone(), session)
281 }
282 None => {
283 let (opts, session, _) = remote::RemoteClient::fake_server(cx, server_cx);
284 (opts, session)
285 }
286 };
287
288 server_cx.update(remote_server::HeadlessProject::init);
289 let server_executor = server_cx.executor();
290 let fs = server_fs.clone();
291 let headless = server_cx.new(|cx| {
292 remote_server::HeadlessProject::new(
293 remote_server::HeadlessAppState {
294 session: server_session,
295 fs,
296 http_client: Arc::new(http_client::BlockedHttpClient),
297 node_runtime: node_runtime::NodeRuntime::unavailable(),
298 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
299 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
300 startup_time: std::time::Instant::now(),
301 },
302 false,
303 cx,
304 )
305 });
306
307 let remote_client = remote::RemoteClient::connect_mock(opts.clone(), cx).await;
308 let project = cx.update(|cx| {
309 let project_client = client::Client::new(
310 Arc::new(clock::FakeSystemClock::new()),
311 http_client::FakeHttpClient::with_404_response(),
312 cx,
313 );
314 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
315 project::Project::remote(
316 remote_client,
317 project_client,
318 node_runtime::NodeRuntime::unavailable(),
319 user_store,
320 app_state.languages.clone(),
321 app_state.fs.clone(),
322 false,
323 cx,
324 )
325 });
326
327 project
328 .update(cx, |project, cx| {
329 project.find_or_create_worktree(worktree_path, true, cx)
330 })
331 .await
332 .expect("should open remote worktree");
333 cx.run_until_parked();
334
335 (project, headless, opts)
336}
337
338fn save_thread_metadata(
339 session_id: acp::SessionId,
340 title: Option<SharedString>,
341 updated_at: DateTime<Utc>,
342 created_at: Option<DateTime<Utc>>,
343 interacted_at: Option<DateTime<Utc>>,
344 project: &Entity<project::Project>,
345 cx: &mut TestAppContext,
346) {
347 cx.update(|cx| {
348 let worktree_paths = project.read(cx).worktree_paths(cx);
349 let remote_connection = project.read(cx).remote_connection_options(cx);
350 let thread_id = ThreadMetadataStore::global(cx)
351 .read(cx)
352 .entries()
353 .find(|e| e.session_id.as_ref() == Some(&session_id))
354 .map(|e| e.thread_id)
355 .unwrap_or_else(ThreadId::new);
356 let metadata = ThreadMetadata {
357 thread_id,
358 session_id: Some(session_id),
359 agent_id: agent::ZED_AGENT_ID.clone(),
360 title,
361 updated_at,
362 created_at,
363 interacted_at,
364 worktree_paths,
365 archived: false,
366 remote_connection,
367 };
368 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
369 });
370 cx.run_until_parked();
371}
372
373fn save_thread_metadata_with_main_paths(
374 session_id: &str,
375 title: &str,
376 folder_paths: PathList,
377 main_worktree_paths: PathList,
378 updated_at: DateTime<Utc>,
379 cx: &mut TestAppContext,
380) {
381 let session_id = acp::SessionId::new(Arc::from(session_id));
382 let title = SharedString::from(title.to_string());
383 let thread_id = cx.update(|cx| {
384 ThreadMetadataStore::global(cx)
385 .read(cx)
386 .entries()
387 .find(|e| e.session_id.as_ref() == Some(&session_id))
388 .map(|e| e.thread_id)
389 .unwrap_or_else(ThreadId::new)
390 });
391 let metadata = ThreadMetadata {
392 thread_id,
393 session_id: Some(session_id),
394 agent_id: agent::ZED_AGENT_ID.clone(),
395 title: Some(title),
396 updated_at,
397 created_at: None,
398 interacted_at: None,
399 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
400 archived: false,
401 remote_connection: None,
402 };
403 cx.update(|cx| {
404 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
405 });
406 cx.run_until_parked();
407}
408
409fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
410 sidebar.update_in(cx, |_, window, cx| {
411 cx.focus_self(window);
412 });
413 cx.run_until_parked();
414}
415
416fn request_test_tool_authorization(
417 thread: &Entity<AcpThread>,
418 tool_call_id: &str,
419 option_id: &str,
420 cx: &mut gpui::VisualTestContext,
421) {
422 let tool_call_id = acp::ToolCallId::new(tool_call_id);
423 let label = format!("Tool {tool_call_id}");
424 let option_id = acp::PermissionOptionId::new(option_id);
425 let _authorization_task = cx.update(|_, cx| {
426 thread.update(cx, |thread, cx| {
427 thread
428 .request_tool_call_authorization(
429 acp::ToolCall::new(tool_call_id, label)
430 .kind(acp::ToolKind::Edit)
431 .into(),
432 PermissionOptions::Flat(vec![acp::PermissionOption::new(
433 option_id,
434 "Allow",
435 acp::PermissionOptionKind::AllowOnce,
436 )]),
437 cx,
438 )
439 .unwrap()
440 })
441 });
442 cx.run_until_parked();
443}
444
445fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
446 let mut seen = Vec::new();
447 let mut chips = Vec::new();
448 for wt in worktrees {
449 if wt.kind == ui::WorktreeKind::Main {
450 continue;
451 }
452 let Some(name) = wt.worktree_name.as_ref() else {
453 continue;
454 };
455 if !seen.contains(name) {
456 seen.push(name.clone());
457 chips.push(format!("{{{}}}", name));
458 }
459 }
460 if chips.is_empty() {
461 String::new()
462 } else {
463 format!(" {}", chips.join(", "))
464 }
465}
466
467fn visible_entries_as_strings(
468 sidebar: &Entity<Sidebar>,
469 cx: &mut gpui::VisualTestContext,
470) -> Vec<String> {
471 sidebar.read_with(cx, |sidebar, cx| {
472 sidebar
473 .contents
474 .entries
475 .iter()
476 .enumerate()
477 .map(|(ix, entry)| {
478 let selected = if sidebar.selection == Some(ix) {
479 " <== selected"
480 } else {
481 ""
482 };
483 match entry {
484 ListEntry::ProjectHeader {
485 label,
486 key,
487 highlight_positions: _,
488 ..
489 } => {
490 let icon = if sidebar.is_group_collapsed(key, cx) {
491 ">"
492 } else {
493 "v"
494 };
495 format!("{} [{}]{}", icon, label, selected)
496 }
497 ListEntry::Thread(thread) => {
498 let title = thread.metadata.display_title();
499 let worktree = format_linked_worktree_chips(&thread.worktrees);
500
501 {
502 let live = if thread.is_live { " *" } else { "" };
503 let status_str = match thread.status {
504 AgentThreadStatus::Running => " (running)",
505 AgentThreadStatus::Error => " (error)",
506 AgentThreadStatus::WaitingForConfirmation => " (waiting)",
507 _ => "",
508 };
509 let notified = if sidebar
510 .contents
511 .is_thread_notified(&thread.metadata.thread_id)
512 {
513 " (!)"
514 } else {
515 ""
516 };
517 format!(" {title}{worktree}{live}{status_str}{notified}{selected}")
518 }
519 }
520 }
521 })
522 .collect()
523 })
524}
525
526#[gpui::test]
527async fn test_serialization_round_trip(cx: &mut TestAppContext) {
528 let project = init_test_project("/my-project", cx).await;
529 let (multi_workspace, cx) =
530 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
531 let sidebar = setup_sidebar(&multi_workspace, cx);
532
533 save_n_test_threads(3, &project, cx).await;
534
535 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
536
537 // Set a custom width and collapse the group.
538 sidebar.update_in(cx, |sidebar, window, cx| {
539 sidebar.set_width(Some(px(420.0)), cx);
540 sidebar.toggle_collapse(&project_group_key, window, cx);
541 });
542 cx.run_until_parked();
543
544 // Capture the serialized state from the first sidebar.
545 let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
546 let serialized = serialized.expect("serialized_state should return Some");
547
548 // Create a fresh sidebar and restore into it.
549 let sidebar2 =
550 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
551 cx.run_until_parked();
552
553 sidebar2.update_in(cx, |sidebar, window, cx| {
554 sidebar.restore_serialized_state(&serialized, window, cx);
555 });
556 cx.run_until_parked();
557
558 // Assert all serialized fields match.
559 let width1 = sidebar.read_with(cx, |s, _| s.width);
560 let width2 = sidebar2.read_with(cx, |s, _| s.width);
561
562 assert_eq!(width1, width2);
563 assert_eq!(width1, px(420.0));
564}
565
566#[gpui::test]
567async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
568 // A regression test to ensure that restoring a serialized archive view does not panic.
569 let project = init_test_project_with_agent_panel("/my-project", cx).await;
570 let (multi_workspace, cx) =
571 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
572 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
573 cx.update(|_window, cx| {
574 AgentRegistryStore::init_test_global(cx, vec![]);
575 });
576
577 let serialized = serde_json::to_string(&SerializedSidebar {
578 width: Some(400.0),
579 active_view: SerializedSidebarView::History,
580 })
581 .expect("serialization should succeed");
582
583 multi_workspace.update_in(cx, |multi_workspace, window, cx| {
584 if let Some(sidebar) = multi_workspace.sidebar() {
585 sidebar.restore_serialized_state(&serialized, window, cx);
586 }
587 });
588 cx.run_until_parked();
589
590 // After the deferred `show_archive` runs, the view should be Archive.
591 sidebar.read_with(cx, |sidebar, _cx| {
592 assert!(
593 matches!(sidebar.view, SidebarView::Archive(_)),
594 "expected sidebar view to be Archive after restore, got ThreadList"
595 );
596 });
597}
598
599#[gpui::test]
600async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
601 let project = init_test_project("/my-project", cx).await;
602 let (multi_workspace, cx) =
603 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
604 let sidebar = setup_sidebar(&multi_workspace, cx);
605
606 let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
607 let weak_sidebar = sidebar.downgrade();
608 let weak_multi_workspace = multi_workspace.downgrade();
609
610 drop(sidebar);
611 drop(multi_workspace);
612 cx.update(|window, _cx| window.remove_window());
613 cx.run_until_parked();
614
615 weak_multi_workspace.assert_released();
616 weak_sidebar.assert_released();
617 weak_workspace.assert_released();
618}
619
620#[gpui::test]
621async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
622 let project = init_test_project_with_agent_panel("/my-project", cx).await;
623 let (multi_workspace, cx) =
624 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
625 let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
626
627 assert_eq!(
628 visible_entries_as_strings(&_sidebar, cx),
629 vec!["v [my-project]"]
630 );
631}
632
633#[gpui::test]
634async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
635 let project = init_test_project("/my-project", cx).await;
636 let (multi_workspace, cx) =
637 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
638 let sidebar = setup_sidebar(&multi_workspace, cx);
639
640 save_thread_metadata(
641 acp::SessionId::new(Arc::from("thread-1")),
642 Some("Fix crash in project panel".into()),
643 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
644 None,
645 None,
646 &project,
647 cx,
648 );
649
650 save_thread_metadata(
651 acp::SessionId::new(Arc::from("thread-2")),
652 Some("Add inline diff view".into()),
653 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
654 None,
655 None,
656 &project,
657 cx,
658 );
659 cx.run_until_parked();
660
661 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
662 cx.run_until_parked();
663
664 assert_eq!(
665 visible_entries_as_strings(&sidebar, cx),
666 vec![
667 //
668 "v [my-project]",
669 " Fix crash in project panel",
670 " Add inline diff view",
671 ]
672 );
673}
674
675#[gpui::test]
676async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
677 let project = init_test_project("/project-a", cx).await;
678 let (multi_workspace, cx) =
679 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
680 let sidebar = setup_sidebar(&multi_workspace, cx);
681
682 // Single workspace with a thread
683 save_thread_metadata(
684 acp::SessionId::new(Arc::from("thread-a1")),
685 Some("Thread A1".into()),
686 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
687 None,
688 None,
689 &project,
690 cx,
691 );
692 cx.run_until_parked();
693
694 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
695 cx.run_until_parked();
696
697 assert_eq!(
698 visible_entries_as_strings(&sidebar, cx),
699 vec![
700 //
701 "v [project-a]",
702 " Thread A1",
703 ]
704 );
705
706 // Add a second workspace
707 multi_workspace.update_in(cx, |mw, window, cx| {
708 mw.create_test_workspace(window, cx).detach();
709 });
710 cx.run_until_parked();
711
712 assert_eq!(
713 visible_entries_as_strings(&sidebar, cx),
714 vec![
715 //
716 "v [project-a]",
717 " Thread A1",
718 ]
719 );
720}
721
722#[gpui::test]
723async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
724 let project = init_test_project("/my-project", cx).await;
725 let (multi_workspace, cx) =
726 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
727 let sidebar = setup_sidebar(&multi_workspace, cx);
728
729 save_n_test_threads(1, &project, cx).await;
730
731 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
732
733 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
734 cx.run_until_parked();
735
736 assert_eq!(
737 visible_entries_as_strings(&sidebar, cx),
738 vec![
739 //
740 "v [my-project]",
741 " Thread 1",
742 ]
743 );
744
745 // Collapse
746 sidebar.update_in(cx, |s, window, cx| {
747 s.toggle_collapse(&project_group_key, window, cx);
748 });
749 cx.run_until_parked();
750
751 assert_eq!(
752 visible_entries_as_strings(&sidebar, cx),
753 vec![
754 //
755 "> [my-project]",
756 ]
757 );
758
759 // Expand
760 sidebar.update_in(cx, |s, window, cx| {
761 s.toggle_collapse(&project_group_key, window, cx);
762 });
763 cx.run_until_parked();
764
765 assert_eq!(
766 visible_entries_as_strings(&sidebar, cx),
767 vec![
768 //
769 "v [my-project]",
770 " Thread 1",
771 ]
772 );
773}
774
775#[gpui::test]
776async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
777 // When a worktree is added to a project, the project group key changes.
778 // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
779 // UI state must survive the key change.
780 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
781 let (multi_workspace, cx) =
782 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
783 let sidebar = setup_sidebar(&multi_workspace, cx);
784
785 save_n_test_threads(2, &project, cx).await;
786 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
787 cx.run_until_parked();
788
789 assert_eq!(
790 visible_entries_as_strings(&sidebar, cx),
791 vec!["v [project-a]", " Thread 2", " Thread 1",]
792 );
793
794 // Collapse the group.
795 let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
796 sidebar.update_in(cx, |sidebar, window, cx| {
797 sidebar.toggle_collapse(&old_key, window, cx);
798 });
799 cx.run_until_parked();
800
801 assert_eq!(
802 visible_entries_as_strings(&sidebar, cx),
803 vec!["> [project-a]"]
804 );
805
806 // Add a second worktree — the key changes from [/project-a] to
807 // [/project-a, /project-b].
808 project
809 .update(cx, |project, cx| {
810 project.find_or_create_worktree("/project-b", true, cx)
811 })
812 .await
813 .expect("should add worktree");
814 cx.run_until_parked();
815
816 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
817 cx.run_until_parked();
818
819 // The group should still be collapsed under the new key.
820 assert_eq!(
821 visible_entries_as_strings(&sidebar, cx),
822 vec!["> [project-a, project-b]"]
823 );
824}
825
826#[gpui::test]
827async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
828 use workspace::ProjectGroup;
829
830 let project = init_test_project("/my-project", cx).await;
831 let (multi_workspace, cx) =
832 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
833 let sidebar = setup_sidebar(&multi_workspace, cx);
834
835 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
836 let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
837 let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
838
839 // Set the collapsed group state through multi_workspace
840 multi_workspace.update(cx, |mw, _cx| {
841 mw.test_add_project_group(ProjectGroup {
842 key: ProjectGroupKey::new(None, collapsed_path.clone()),
843 workspaces: Vec::new(),
844 expanded: false,
845 });
846 });
847
848 sidebar.update_in(cx, |s, _window, _cx| {
849 let notified_thread_id = ThreadId::new();
850 s.contents.notified_threads.insert(notified_thread_id);
851 s.contents.entries = vec![
852 // Expanded project header
853 ListEntry::ProjectHeader {
854 key: ProjectGroupKey::new(None, expanded_path.clone()),
855 label: "expanded-project".into(),
856 highlight_positions: Vec::new(),
857 has_running_threads: false,
858 waiting_thread_count: 0,
859 is_active: true,
860 has_threads: true,
861 },
862 ListEntry::Thread(ThreadEntry {
863 metadata: ThreadMetadata {
864 thread_id: ThreadId::new(),
865 session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
866 agent_id: AgentId::new("zed-agent"),
867 worktree_paths: WorktreePaths::default(),
868 title: Some("Completed thread".into()),
869 updated_at: Utc::now(),
870 created_at: Some(Utc::now()),
871 interacted_at: None,
872 archived: false,
873 remote_connection: None,
874 },
875 icon: IconName::ZedAgent,
876 icon_from_external_svg: None,
877 status: AgentThreadStatus::Completed,
878 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
879 is_live: false,
880 is_background: false,
881 is_title_generating: false,
882 highlight_positions: Vec::new(),
883 worktrees: Vec::new(),
884 diff_stats: DiffStats::default(),
885 }),
886 // Active thread with Running status
887 ListEntry::Thread(ThreadEntry {
888 metadata: ThreadMetadata {
889 thread_id: ThreadId::new(),
890 session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
891 agent_id: AgentId::new("zed-agent"),
892 worktree_paths: WorktreePaths::default(),
893 title: Some("Running thread".into()),
894 updated_at: Utc::now(),
895 created_at: Some(Utc::now()),
896 interacted_at: None,
897 archived: false,
898 remote_connection: None,
899 },
900 icon: IconName::ZedAgent,
901 icon_from_external_svg: None,
902 status: AgentThreadStatus::Running,
903 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
904 is_live: true,
905 is_background: false,
906 is_title_generating: false,
907 highlight_positions: Vec::new(),
908 worktrees: Vec::new(),
909 diff_stats: DiffStats::default(),
910 }),
911 // Active thread with Error status
912 ListEntry::Thread(ThreadEntry {
913 metadata: ThreadMetadata {
914 thread_id: ThreadId::new(),
915 session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
916 agent_id: AgentId::new("zed-agent"),
917 worktree_paths: WorktreePaths::default(),
918 title: Some("Error thread".into()),
919 updated_at: Utc::now(),
920 created_at: Some(Utc::now()),
921 interacted_at: None,
922 archived: false,
923 remote_connection: None,
924 },
925 icon: IconName::ZedAgent,
926 icon_from_external_svg: None,
927 status: AgentThreadStatus::Error,
928 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
929 is_live: true,
930 is_background: false,
931 is_title_generating: false,
932 highlight_positions: Vec::new(),
933 worktrees: Vec::new(),
934 diff_stats: DiffStats::default(),
935 }),
936 // Thread with WaitingForConfirmation status, not active
937 // remote_connection: None,
938 ListEntry::Thread(ThreadEntry {
939 metadata: ThreadMetadata {
940 thread_id: ThreadId::new(),
941 session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
942 agent_id: AgentId::new("zed-agent"),
943 worktree_paths: WorktreePaths::default(),
944 title: Some("Waiting thread".into()),
945 updated_at: Utc::now(),
946 created_at: Some(Utc::now()),
947 interacted_at: None,
948 archived: false,
949 remote_connection: None,
950 },
951 icon: IconName::ZedAgent,
952 icon_from_external_svg: None,
953 status: AgentThreadStatus::WaitingForConfirmation,
954 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
955 is_live: false,
956 is_background: false,
957 is_title_generating: false,
958 highlight_positions: Vec::new(),
959 worktrees: Vec::new(),
960 diff_stats: DiffStats::default(),
961 }),
962 // Background thread that completed (should show notification)
963 // remote_connection: None,
964 ListEntry::Thread(ThreadEntry {
965 metadata: ThreadMetadata {
966 thread_id: notified_thread_id,
967 session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
968 agent_id: AgentId::new("zed-agent"),
969 worktree_paths: WorktreePaths::default(),
970 title: Some("Notified thread".into()),
971 updated_at: Utc::now(),
972 created_at: Some(Utc::now()),
973 interacted_at: None,
974 archived: false,
975 remote_connection: None,
976 },
977 icon: IconName::ZedAgent,
978 icon_from_external_svg: None,
979 status: AgentThreadStatus::Completed,
980 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
981 is_live: true,
982 is_background: true,
983 is_title_generating: false,
984 highlight_positions: Vec::new(),
985 worktrees: Vec::new(),
986 diff_stats: DiffStats::default(),
987 }),
988 // Collapsed project header
989 ListEntry::ProjectHeader {
990 key: ProjectGroupKey::new(None, collapsed_path.clone()),
991 label: "collapsed-project".into(),
992 highlight_positions: Vec::new(),
993 has_running_threads: false,
994 waiting_thread_count: 0,
995 is_active: false,
996 has_threads: false,
997 },
998 ];
999
1000 // Select the Running thread (index 2)
1001 s.selection = Some(2);
1002 });
1003
1004 assert_eq!(
1005 visible_entries_as_strings(&sidebar, cx),
1006 vec![
1007 //
1008 "v [expanded-project]",
1009 " Completed thread",
1010 " Running thread * (running) <== selected",
1011 " Error thread * (error)",
1012 " Waiting thread (waiting)",
1013 " Notified thread * (!)",
1014 "> [collapsed-project]",
1015 ]
1016 );
1017
1018 // Move selection to the collapsed header
1019 sidebar.update_in(cx, |s, _window, _cx| {
1020 s.selection = Some(6);
1021 });
1022
1023 assert_eq!(
1024 visible_entries_as_strings(&sidebar, cx).last().cloned(),
1025 Some("> [collapsed-project] <== selected".to_string()),
1026 );
1027
1028 // Clear selection
1029 sidebar.update_in(cx, |s, _window, _cx| {
1030 s.selection = None;
1031 });
1032
1033 // No entry should have the selected marker
1034 let entries = visible_entries_as_strings(&sidebar, cx);
1035 for entry in &entries {
1036 assert!(
1037 !entry.contains("<== selected"),
1038 "unexpected selection marker in: {}",
1039 entry
1040 );
1041 }
1042}
1043
1044#[gpui::test]
1045async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
1046 let project = init_test_project("/my-project", cx).await;
1047 let (multi_workspace, cx) =
1048 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1049 let sidebar = setup_sidebar(&multi_workspace, cx);
1050
1051 save_n_test_threads(3, &project, cx).await;
1052
1053 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1054 cx.run_until_parked();
1055
1056 // Entries: [header, thread3, thread2, thread1]
1057 // Focusing the sidebar does not set a selection; select_next/select_previous
1058 // handle None gracefully by starting from the first or last entry.
1059 focus_sidebar(&sidebar, cx);
1060 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1061
1062 // First SelectNext from None starts at index 0
1063 cx.dispatch_action(SelectNext);
1064 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1065
1066 // Move down through remaining entries
1067 cx.dispatch_action(SelectNext);
1068 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1069
1070 cx.dispatch_action(SelectNext);
1071 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1072
1073 cx.dispatch_action(SelectNext);
1074 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1075
1076 // At the end, wraps back to first entry
1077 cx.dispatch_action(SelectNext);
1078 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1079
1080 // Navigate back to the end
1081 cx.dispatch_action(SelectNext);
1082 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1083 cx.dispatch_action(SelectNext);
1084 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1085 cx.dispatch_action(SelectNext);
1086 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1087
1088 // Move back up
1089 cx.dispatch_action(SelectPrevious);
1090 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1091
1092 cx.dispatch_action(SelectPrevious);
1093 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1094
1095 cx.dispatch_action(SelectPrevious);
1096 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1097
1098 // At the top, selection clears (focus returns to editor)
1099 cx.dispatch_action(SelectPrevious);
1100 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1101}
1102
1103#[gpui::test]
1104async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
1105 let project = init_test_project("/my-project", cx).await;
1106 let (multi_workspace, cx) =
1107 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1108 let sidebar = setup_sidebar(&multi_workspace, cx);
1109
1110 save_n_test_threads(3, &project, cx).await;
1111 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1112 cx.run_until_parked();
1113
1114 focus_sidebar(&sidebar, cx);
1115
1116 // SelectLast jumps to the end
1117 cx.dispatch_action(SelectLast);
1118 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1119
1120 // SelectFirst jumps to the beginning
1121 cx.dispatch_action(SelectFirst);
1122 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1123}
1124
1125#[gpui::test]
1126async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
1127 let project = init_test_project("/my-project", cx).await;
1128 let (multi_workspace, cx) =
1129 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1130 let sidebar = setup_sidebar(&multi_workspace, cx);
1131
1132 // Initially no selection
1133 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1134
1135 // Open the sidebar so it's rendered, then focus it to trigger focus_in.
1136 // focus_in no longer sets a default selection.
1137 focus_sidebar(&sidebar, cx);
1138 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1139
1140 // Manually set a selection, blur, then refocus — selection should be preserved
1141 sidebar.update_in(cx, |sidebar, _window, _cx| {
1142 sidebar.selection = Some(0);
1143 });
1144
1145 cx.update(|window, _cx| {
1146 window.blur();
1147 });
1148 cx.run_until_parked();
1149
1150 sidebar.update_in(cx, |_, window, cx| {
1151 cx.focus_self(window);
1152 });
1153 cx.run_until_parked();
1154 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1155}
1156
1157#[gpui::test]
1158async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
1159 let project = init_test_project("/my-project", cx).await;
1160 let (multi_workspace, cx) =
1161 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1162 let sidebar = setup_sidebar(&multi_workspace, cx);
1163
1164 save_n_test_threads(1, &project, cx).await;
1165 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1166 cx.run_until_parked();
1167
1168 assert_eq!(
1169 visible_entries_as_strings(&sidebar, cx),
1170 vec![
1171 //
1172 "v [my-project]",
1173 " Thread 1",
1174 ]
1175 );
1176
1177 // Focus the sidebar and select the header
1178 focus_sidebar(&sidebar, cx);
1179 sidebar.update_in(cx, |sidebar, _window, _cx| {
1180 sidebar.selection = Some(0);
1181 });
1182
1183 // Confirm on project header collapses the group
1184 cx.dispatch_action(Confirm);
1185 cx.run_until_parked();
1186
1187 assert_eq!(
1188 visible_entries_as_strings(&sidebar, cx),
1189 vec![
1190 //
1191 "> [my-project] <== selected",
1192 ]
1193 );
1194
1195 // Confirm again expands the group
1196 cx.dispatch_action(Confirm);
1197 cx.run_until_parked();
1198
1199 assert_eq!(
1200 visible_entries_as_strings(&sidebar, cx),
1201 vec![
1202 //
1203 "v [my-project] <== selected",
1204 " Thread 1",
1205 ]
1206 );
1207}
1208
1209#[gpui::test]
1210async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
1211 let project = init_test_project("/my-project", cx).await;
1212 let (multi_workspace, cx) =
1213 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1214 let sidebar = setup_sidebar(&multi_workspace, cx);
1215
1216 save_n_test_threads(1, &project, cx).await;
1217 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1218 cx.run_until_parked();
1219
1220 assert_eq!(
1221 visible_entries_as_strings(&sidebar, cx),
1222 vec![
1223 //
1224 "v [my-project]",
1225 " Thread 1",
1226 ]
1227 );
1228
1229 // Focus sidebar and manually select the header (index 0). Press left to collapse.
1230 focus_sidebar(&sidebar, cx);
1231 sidebar.update_in(cx, |sidebar, _window, _cx| {
1232 sidebar.selection = Some(0);
1233 });
1234
1235 cx.dispatch_action(SelectParent);
1236 cx.run_until_parked();
1237
1238 assert_eq!(
1239 visible_entries_as_strings(&sidebar, cx),
1240 vec![
1241 //
1242 "> [my-project] <== selected",
1243 ]
1244 );
1245
1246 // Press right to expand
1247 cx.dispatch_action(SelectChild);
1248 cx.run_until_parked();
1249
1250 assert_eq!(
1251 visible_entries_as_strings(&sidebar, cx),
1252 vec![
1253 //
1254 "v [my-project] <== selected",
1255 " Thread 1",
1256 ]
1257 );
1258
1259 // Press right again on already-expanded header moves selection down
1260 cx.dispatch_action(SelectChild);
1261 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1262}
1263
1264#[gpui::test]
1265async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
1266 let project = init_test_project("/my-project", cx).await;
1267 let (multi_workspace, cx) =
1268 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1269 let sidebar = setup_sidebar(&multi_workspace, cx);
1270
1271 save_n_test_threads(1, &project, cx).await;
1272 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1273 cx.run_until_parked();
1274
1275 // Focus sidebar (selection starts at None), then navigate down to the thread (child)
1276 focus_sidebar(&sidebar, cx);
1277 cx.dispatch_action(SelectNext);
1278 cx.dispatch_action(SelectNext);
1279 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1280
1281 assert_eq!(
1282 visible_entries_as_strings(&sidebar, cx),
1283 vec![
1284 //
1285 "v [my-project]",
1286 " Thread 1 <== selected",
1287 ]
1288 );
1289
1290 // Pressing left on a child collapses the parent group and selects it
1291 cx.dispatch_action(SelectParent);
1292 cx.run_until_parked();
1293
1294 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1295 assert_eq!(
1296 visible_entries_as_strings(&sidebar, cx),
1297 vec![
1298 //
1299 "> [my-project] <== selected",
1300 ]
1301 );
1302}
1303
1304#[gpui::test]
1305async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
1306 let project = init_test_project_with_agent_panel("/empty-project", cx).await;
1307 let (multi_workspace, cx) =
1308 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1309 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1310
1311 // An empty project has only the header (no auto-created draft).
1312 assert_eq!(
1313 visible_entries_as_strings(&sidebar, cx),
1314 vec!["v [empty-project]"]
1315 );
1316
1317 // Focus sidebar — focus_in does not set a selection
1318 focus_sidebar(&sidebar, cx);
1319 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1320
1321 // First SelectNext from None starts at index 0 (header)
1322 cx.dispatch_action(SelectNext);
1323 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1324
1325 // SelectNext with only one entry stays at index 0
1326 cx.dispatch_action(SelectNext);
1327 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1328
1329 // SelectPrevious from first entry clears selection (returns to editor)
1330 cx.dispatch_action(SelectPrevious);
1331 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1332
1333 // SelectPrevious from None selects the last entry
1334 cx.dispatch_action(SelectPrevious);
1335 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1336}
1337
1338#[gpui::test]
1339async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
1340 let project = init_test_project("/my-project", cx).await;
1341 let (multi_workspace, cx) =
1342 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1343 let sidebar = setup_sidebar(&multi_workspace, cx);
1344
1345 save_n_test_threads(1, &project, cx).await;
1346 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1347 cx.run_until_parked();
1348
1349 // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
1350 focus_sidebar(&sidebar, cx);
1351 cx.dispatch_action(SelectNext);
1352 cx.dispatch_action(SelectNext);
1353 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1354
1355 // Collapse the group, which removes the thread from the list
1356 cx.dispatch_action(SelectParent);
1357 cx.run_until_parked();
1358
1359 // Selection should be clamped to the last valid index (0 = header)
1360 let selection = sidebar.read_with(cx, |s, _| s.selection);
1361 let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
1362 assert!(
1363 selection.unwrap_or(0) < entry_count,
1364 "selection {} should be within bounds (entries: {})",
1365 selection.unwrap_or(0),
1366 entry_count,
1367 );
1368}
1369
1370async fn init_test_project_with_agent_panel(
1371 worktree_path: &str,
1372 cx: &mut TestAppContext,
1373) -> Entity<project::Project> {
1374 agent_ui::test_support::init_test(cx);
1375 cx.update(|cx| {
1376 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
1377 ThreadStore::init_global(cx);
1378 ThreadMetadataStore::init_global(cx);
1379 language_model::LanguageModelRegistry::test(cx);
1380 prompt_store::init(cx);
1381 });
1382
1383 let fs = FakeFs::new(cx.executor());
1384 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1385 .await;
1386 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1387 project::Project::test(fs, [worktree_path.as_ref()], cx).await
1388}
1389
1390fn add_agent_panel(
1391 workspace: &Entity<Workspace>,
1392 cx: &mut gpui::VisualTestContext,
1393) -> Entity<AgentPanel> {
1394 workspace.update_in(cx, |workspace, window, cx| {
1395 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
1396 workspace.add_panel(panel.clone(), window, cx);
1397 panel
1398 })
1399}
1400
1401fn setup_sidebar_with_agent_panel(
1402 multi_workspace: &Entity<MultiWorkspace>,
1403 cx: &mut gpui::VisualTestContext,
1404) -> (Entity<Sidebar>, Entity<AgentPanel>) {
1405 let sidebar = setup_sidebar(multi_workspace, cx);
1406 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
1407 let panel = add_agent_panel(&workspace, cx);
1408 (sidebar, panel)
1409}
1410
1411#[gpui::test]
1412async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
1413 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1414 let (multi_workspace, cx) =
1415 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1416 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1417
1418 // Open thread A and keep it generating.
1419 let connection = StubAgentConnection::new();
1420 open_thread_with_connection(&panel, connection.clone(), cx);
1421 send_message(&panel, cx);
1422
1423 let session_id_a = active_session_id(&panel, cx);
1424 save_test_thread_metadata(&session_id_a, &project, cx).await;
1425
1426 cx.update(|_, cx| {
1427 connection.send_update(
1428 session_id_a.clone(),
1429 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
1430 cx,
1431 );
1432 });
1433 cx.run_until_parked();
1434
1435 // Open thread B (idle, default response) — thread A goes to background.
1436 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1437 acp::ContentChunk::new("Done".into()),
1438 )]);
1439 open_thread_with_connection(&panel, connection, cx);
1440 send_message(&panel, cx);
1441
1442 let session_id_b = active_session_id(&panel, cx);
1443 save_test_thread_metadata(&session_id_b, &project, cx).await;
1444
1445 cx.run_until_parked();
1446
1447 let mut entries = visible_entries_as_strings(&sidebar, cx);
1448 entries[1..].sort();
1449 assert_eq!(
1450 entries,
1451 vec![
1452 //
1453 "v [my-project]",
1454 " Hello *",
1455 " Hello * (running)",
1456 ]
1457 );
1458}
1459
1460#[gpui::test]
1461async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
1462 cx: &mut TestAppContext,
1463) {
1464 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1465 let (multi_workspace, cx) =
1466 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1467 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1468
1469 let connection = StubAgentConnection::new().with_supports_load_session(true);
1470 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1471 acp::ContentChunk::new("Done".into()),
1472 )]);
1473 open_thread_with_connection(&panel, connection, cx);
1474 send_message(&panel, cx);
1475
1476 let parent_session_id = active_session_id(&panel, cx);
1477 save_test_thread_metadata(&parent_session_id, &project, cx).await;
1478
1479 let subagent_session_id = acp::SessionId::new("subagent-session");
1480 cx.update(|_, cx| {
1481 let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
1482 parent_thread.update(cx, |thread: &mut AcpThread, cx| {
1483 thread.subagent_spawned(subagent_session_id.clone(), cx);
1484 });
1485 });
1486 cx.run_until_parked();
1487
1488 let subagent_thread = panel.read_with(cx, |panel, cx| {
1489 panel
1490 .active_conversation_view()
1491 .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id))
1492 .map(|thread_view| thread_view.read(cx).thread.clone())
1493 .expect("Expected subagent thread to be loaded into the conversation")
1494 });
1495 request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
1496
1497 let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
1498 sidebar
1499 .contents
1500 .entries
1501 .iter()
1502 .find_map(|entry| match entry {
1503 ListEntry::Thread(thread)
1504 if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
1505 {
1506 Some(thread.status)
1507 }
1508 _ => None,
1509 })
1510 .expect("Expected parent thread entry in sidebar")
1511 });
1512
1513 assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
1514}
1515
1516#[gpui::test]
1517async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
1518 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
1519 let (multi_workspace, cx) =
1520 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1521 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1522
1523 // Open thread on workspace A and keep it generating.
1524 let connection_a = StubAgentConnection::new();
1525 open_thread_with_connection(&panel_a, connection_a.clone(), cx);
1526 send_message(&panel_a, cx);
1527
1528 let session_id_a = active_session_id(&panel_a, cx);
1529 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
1530
1531 cx.update(|_, cx| {
1532 connection_a.send_update(
1533 session_id_a.clone(),
1534 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
1535 cx,
1536 );
1537 });
1538 cx.run_until_parked();
1539
1540 // Add a second workspace and activate it (making workspace A the background).
1541 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
1542 let project_b = project::Project::test(fs, [], cx).await;
1543 multi_workspace.update_in(cx, |mw, window, cx| {
1544 mw.test_add_workspace(project_b, window, cx);
1545 });
1546 cx.run_until_parked();
1547
1548 // Thread A is still running; no notification yet.
1549 assert_eq!(
1550 visible_entries_as_strings(&sidebar, cx),
1551 vec![
1552 //
1553 "v [project-a]",
1554 " Hello * (running)",
1555 ]
1556 );
1557
1558 // Complete thread A's turn (transition Running → Completed).
1559 connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
1560 cx.run_until_parked();
1561
1562 // The completed background thread shows a notification indicator.
1563 assert_eq!(
1564 visible_entries_as_strings(&sidebar, cx),
1565 vec![
1566 //
1567 "v [project-a]",
1568 " Hello * (!)",
1569 ]
1570 );
1571}
1572
1573fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
1574 sidebar.update_in(cx, |sidebar, window, cx| {
1575 window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
1576 sidebar.filter_editor.update(cx, |editor, cx| {
1577 editor.set_text(query, window, cx);
1578 });
1579 });
1580 cx.run_until_parked();
1581}
1582
1583#[gpui::test]
1584async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
1585 let project = init_test_project("/my-project", cx).await;
1586 let (multi_workspace, cx) =
1587 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1588 let sidebar = setup_sidebar(&multi_workspace, cx);
1589
1590 for (id, title, hour) in [
1591 ("t-1", "Fix crash in project panel", 3),
1592 ("t-2", "Add inline diff view", 2),
1593 ("t-3", "Refactor settings module", 1),
1594 ] {
1595 save_thread_metadata(
1596 acp::SessionId::new(Arc::from(id)),
1597 Some(title.into()),
1598 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1599 None,
1600 None,
1601 &project,
1602 cx,
1603 );
1604 }
1605 cx.run_until_parked();
1606
1607 assert_eq!(
1608 visible_entries_as_strings(&sidebar, cx),
1609 vec![
1610 //
1611 "v [my-project]",
1612 " Fix crash in project panel",
1613 " Add inline diff view",
1614 " Refactor settings module",
1615 ]
1616 );
1617
1618 // User types "diff" in the search box — only the matching thread remains,
1619 // with its workspace header preserved for context.
1620 type_in_search(&sidebar, "diff", cx);
1621 assert_eq!(
1622 visible_entries_as_strings(&sidebar, cx),
1623 vec![
1624 //
1625 "v [my-project]",
1626 " Add inline diff view <== selected",
1627 ]
1628 );
1629
1630 // User changes query to something with no matches — list is empty.
1631 type_in_search(&sidebar, "nonexistent", cx);
1632 assert_eq!(
1633 visible_entries_as_strings(&sidebar, cx),
1634 Vec::<String>::new()
1635 );
1636}
1637
1638#[gpui::test]
1639async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
1640 // Scenario: A user remembers a thread title but not the exact casing.
1641 // Search should match case-insensitively so they can still find it.
1642 let project = init_test_project("/my-project", cx).await;
1643 let (multi_workspace, cx) =
1644 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1645 let sidebar = setup_sidebar(&multi_workspace, cx);
1646
1647 save_thread_metadata(
1648 acp::SessionId::new(Arc::from("thread-1")),
1649 Some("Fix Crash In Project Panel".into()),
1650 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1651 None,
1652 None,
1653 &project,
1654 cx,
1655 );
1656 cx.run_until_parked();
1657
1658 // Lowercase query matches mixed-case title.
1659 type_in_search(&sidebar, "fix crash", cx);
1660 assert_eq!(
1661 visible_entries_as_strings(&sidebar, cx),
1662 vec![
1663 //
1664 "v [my-project]",
1665 " Fix Crash In Project Panel <== selected",
1666 ]
1667 );
1668
1669 // Uppercase query also matches the same title.
1670 type_in_search(&sidebar, "FIX CRASH", cx);
1671 assert_eq!(
1672 visible_entries_as_strings(&sidebar, cx),
1673 vec![
1674 //
1675 "v [my-project]",
1676 " Fix Crash In Project Panel <== selected",
1677 ]
1678 );
1679}
1680
1681#[gpui::test]
1682async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
1683 // Scenario: A user searches, finds what they need, then presses Escape
1684 // to dismiss the filter and see the full list again.
1685 let project = init_test_project("/my-project", cx).await;
1686 let (multi_workspace, cx) =
1687 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1688 let sidebar = setup_sidebar(&multi_workspace, cx);
1689
1690 for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
1691 save_thread_metadata(
1692 acp::SessionId::new(Arc::from(id)),
1693 Some(title.into()),
1694 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1695 None,
1696 None,
1697 &project,
1698 cx,
1699 )
1700 }
1701 cx.run_until_parked();
1702
1703 // Confirm the full list is showing.
1704 assert_eq!(
1705 visible_entries_as_strings(&sidebar, cx),
1706 vec![
1707 //
1708 "v [my-project]",
1709 " Alpha thread",
1710 " Beta thread",
1711 ]
1712 );
1713
1714 // User types a search query to filter down.
1715 focus_sidebar(&sidebar, cx);
1716 type_in_search(&sidebar, "alpha", cx);
1717 assert_eq!(
1718 visible_entries_as_strings(&sidebar, cx),
1719 vec![
1720 //
1721 "v [my-project]",
1722 " Alpha thread <== selected",
1723 ]
1724 );
1725
1726 // User presses Escape — filter clears, full list is restored.
1727 // The selection index (1) now points at the first thread entry.
1728 cx.dispatch_action(Cancel);
1729 cx.run_until_parked();
1730 assert_eq!(
1731 visible_entries_as_strings(&sidebar, cx),
1732 vec![
1733 //
1734 "v [my-project]",
1735 " Alpha thread <== selected",
1736 " Beta thread",
1737 ]
1738 );
1739}
1740
1741#[gpui::test]
1742async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
1743 let project_a = init_test_project("/project-a", cx).await;
1744 let (multi_workspace, cx) =
1745 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1746 let sidebar = setup_sidebar(&multi_workspace, cx);
1747
1748 for (id, title, hour) in [
1749 ("a1", "Fix bug in sidebar", 2),
1750 ("a2", "Add tests for editor", 1),
1751 ] {
1752 save_thread_metadata(
1753 acp::SessionId::new(Arc::from(id)),
1754 Some(title.into()),
1755 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1756 None,
1757 None,
1758 &project_a,
1759 cx,
1760 )
1761 }
1762
1763 // Add a second workspace.
1764 multi_workspace.update_in(cx, |mw, window, cx| {
1765 mw.create_test_workspace(window, cx).detach();
1766 });
1767 cx.run_until_parked();
1768
1769 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1770 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1771 });
1772
1773 for (id, title, hour) in [
1774 ("b1", "Refactor sidebar layout", 3),
1775 ("b2", "Fix typo in README", 1),
1776 ] {
1777 save_thread_metadata(
1778 acp::SessionId::new(Arc::from(id)),
1779 Some(title.into()),
1780 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1781 None,
1782 None,
1783 &project_b,
1784 cx,
1785 )
1786 }
1787 cx.run_until_parked();
1788
1789 assert_eq!(
1790 visible_entries_as_strings(&sidebar, cx),
1791 vec![
1792 //
1793 "v [project-a]",
1794 " Fix bug in sidebar",
1795 " Add tests for editor",
1796 ]
1797 );
1798
1799 // "sidebar" matches a thread in each workspace — both headers stay visible.
1800 type_in_search(&sidebar, "sidebar", cx);
1801 assert_eq!(
1802 visible_entries_as_strings(&sidebar, cx),
1803 vec![
1804 //
1805 "v [project-a]",
1806 " Fix bug in sidebar <== selected",
1807 ]
1808 );
1809
1810 // "typo" only matches in the second workspace — the first header disappears.
1811 type_in_search(&sidebar, "typo", cx);
1812 assert_eq!(
1813 visible_entries_as_strings(&sidebar, cx),
1814 Vec::<String>::new()
1815 );
1816
1817 // "project-a" matches the first workspace name — the header appears
1818 // with all child threads included.
1819 type_in_search(&sidebar, "project-a", cx);
1820 assert_eq!(
1821 visible_entries_as_strings(&sidebar, cx),
1822 vec![
1823 //
1824 "v [project-a]",
1825 " Fix bug in sidebar <== selected",
1826 " Add tests for editor",
1827 ]
1828 );
1829}
1830
1831#[gpui::test]
1832async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
1833 let project_a = init_test_project("/alpha-project", cx).await;
1834 let (multi_workspace, cx) =
1835 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1836 let sidebar = setup_sidebar(&multi_workspace, cx);
1837
1838 for (id, title, hour) in [
1839 ("a1", "Fix bug in sidebar", 2),
1840 ("a2", "Add tests for editor", 1),
1841 ] {
1842 save_thread_metadata(
1843 acp::SessionId::new(Arc::from(id)),
1844 Some(title.into()),
1845 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1846 None,
1847 None,
1848 &project_a,
1849 cx,
1850 )
1851 }
1852
1853 // Add a second workspace.
1854 multi_workspace.update_in(cx, |mw, window, cx| {
1855 mw.create_test_workspace(window, cx).detach();
1856 });
1857 cx.run_until_parked();
1858
1859 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1860 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1861 });
1862
1863 for (id, title, hour) in [
1864 ("b1", "Refactor sidebar layout", 3),
1865 ("b2", "Fix typo in README", 1),
1866 ] {
1867 save_thread_metadata(
1868 acp::SessionId::new(Arc::from(id)),
1869 Some(title.into()),
1870 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1871 None,
1872 None,
1873 &project_b,
1874 cx,
1875 )
1876 }
1877 cx.run_until_parked();
1878
1879 // "alpha" matches the workspace name "alpha-project" but no thread titles.
1880 // The workspace header should appear with all child threads included.
1881 type_in_search(&sidebar, "alpha", cx);
1882 assert_eq!(
1883 visible_entries_as_strings(&sidebar, cx),
1884 vec![
1885 //
1886 "v [alpha-project]",
1887 " Fix bug in sidebar <== selected",
1888 " Add tests for editor",
1889 ]
1890 );
1891
1892 // "sidebar" matches thread titles in both workspaces but not workspace names.
1893 // Both headers appear with their matching threads.
1894 type_in_search(&sidebar, "sidebar", cx);
1895 assert_eq!(
1896 visible_entries_as_strings(&sidebar, cx),
1897 vec![
1898 //
1899 "v [alpha-project]",
1900 " Fix bug in sidebar <== selected",
1901 ]
1902 );
1903
1904 // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
1905 // doesn't match) — but does not match either workspace name or any thread.
1906 // Actually let's test something simpler: a query that matches both a workspace
1907 // name AND some threads in that workspace. Matching threads should still appear.
1908 type_in_search(&sidebar, "fix", cx);
1909 assert_eq!(
1910 visible_entries_as_strings(&sidebar, cx),
1911 vec![
1912 //
1913 "v [alpha-project]",
1914 " Fix bug in sidebar <== selected",
1915 ]
1916 );
1917
1918 // A query that matches a workspace name AND a thread in that same workspace.
1919 // Both the header (highlighted) and all child threads should appear.
1920 type_in_search(&sidebar, "alpha", cx);
1921 assert_eq!(
1922 visible_entries_as_strings(&sidebar, cx),
1923 vec![
1924 //
1925 "v [alpha-project]",
1926 " Fix bug in sidebar <== selected",
1927 " Add tests for editor",
1928 ]
1929 );
1930
1931 // Now search for something that matches only a workspace name when there
1932 // are also threads with matching titles — the non-matching workspace's
1933 // threads should still appear if their titles match.
1934 type_in_search(&sidebar, "alp", cx);
1935 assert_eq!(
1936 visible_entries_as_strings(&sidebar, cx),
1937 vec![
1938 //
1939 "v [alpha-project]",
1940 " Fix bug in sidebar <== selected",
1941 " Add tests for editor",
1942 ]
1943 );
1944}
1945
1946#[gpui::test]
1947async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
1948 let project = init_test_project("/my-project", cx).await;
1949 let (multi_workspace, cx) =
1950 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1951 let sidebar = setup_sidebar(&multi_workspace, cx);
1952
1953 save_thread_metadata(
1954 acp::SessionId::new(Arc::from("thread-1")),
1955 Some("Important thread".into()),
1956 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1957 None,
1958 None,
1959 &project,
1960 cx,
1961 );
1962 cx.run_until_parked();
1963
1964 // User focuses the sidebar and collapses the group using keyboard:
1965 // manually select the header, then press SelectParent to collapse.
1966 focus_sidebar(&sidebar, cx);
1967 sidebar.update_in(cx, |sidebar, _window, _cx| {
1968 sidebar.selection = Some(0);
1969 });
1970 cx.dispatch_action(SelectParent);
1971 cx.run_until_parked();
1972
1973 assert_eq!(
1974 visible_entries_as_strings(&sidebar, cx),
1975 vec![
1976 //
1977 "> [my-project] <== selected",
1978 ]
1979 );
1980
1981 // User types a search — the thread appears even though its group is collapsed.
1982 type_in_search(&sidebar, "important", cx);
1983 assert_eq!(
1984 visible_entries_as_strings(&sidebar, cx),
1985 vec![
1986 //
1987 "> [my-project]",
1988 " Important thread <== selected",
1989 ]
1990 );
1991}
1992
1993#[gpui::test]
1994async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
1995 let project = init_test_project("/my-project", cx).await;
1996 let (multi_workspace, cx) =
1997 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1998 let sidebar = setup_sidebar(&multi_workspace, cx);
1999
2000 for (id, title, hour) in [
2001 ("t-1", "Fix crash in panel", 3),
2002 ("t-2", "Fix lint warnings", 2),
2003 ("t-3", "Add new feature", 1),
2004 ] {
2005 save_thread_metadata(
2006 acp::SessionId::new(Arc::from(id)),
2007 Some(title.into()),
2008 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2009 None,
2010 None,
2011 &project,
2012 cx,
2013 )
2014 }
2015 cx.run_until_parked();
2016
2017 focus_sidebar(&sidebar, cx);
2018
2019 // User types "fix" — two threads match.
2020 type_in_search(&sidebar, "fix", cx);
2021 assert_eq!(
2022 visible_entries_as_strings(&sidebar, cx),
2023 vec![
2024 //
2025 "v [my-project]",
2026 " Fix crash in panel <== selected",
2027 " Fix lint warnings",
2028 ]
2029 );
2030
2031 // Selection starts on the first matching thread. User presses
2032 // SelectNext to move to the second match.
2033 cx.dispatch_action(SelectNext);
2034 assert_eq!(
2035 visible_entries_as_strings(&sidebar, cx),
2036 vec![
2037 //
2038 "v [my-project]",
2039 " Fix crash in panel",
2040 " Fix lint warnings <== selected",
2041 ]
2042 );
2043
2044 // User can also jump back with SelectPrevious.
2045 cx.dispatch_action(SelectPrevious);
2046 assert_eq!(
2047 visible_entries_as_strings(&sidebar, cx),
2048 vec![
2049 //
2050 "v [my-project]",
2051 " Fix crash in panel <== selected",
2052 " Fix lint warnings",
2053 ]
2054 );
2055}
2056
2057#[gpui::test]
2058async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
2059 let project = init_test_project("/my-project", cx).await;
2060 let (multi_workspace, cx) =
2061 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2062 let sidebar = setup_sidebar(&multi_workspace, cx);
2063
2064 multi_workspace.update_in(cx, |mw, window, cx| {
2065 mw.create_test_workspace(window, cx).detach();
2066 });
2067 cx.run_until_parked();
2068
2069 let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
2070 (
2071 mw.workspaces().next().unwrap().clone(),
2072 mw.workspaces().nth(1).unwrap().clone(),
2073 )
2074 });
2075
2076 save_thread_metadata(
2077 acp::SessionId::new(Arc::from("hist-1")),
2078 Some("Historical Thread".into()),
2079 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2080 None,
2081 None,
2082 &project,
2083 cx,
2084 );
2085 cx.run_until_parked();
2086 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2087 cx.run_until_parked();
2088
2089 assert_eq!(
2090 visible_entries_as_strings(&sidebar, cx),
2091 vec![
2092 //
2093 "v [my-project]",
2094 " Historical Thread",
2095 ]
2096 );
2097
2098 // Switch to workspace 1 so we can verify the confirm switches back.
2099 multi_workspace.update_in(cx, |mw, window, cx| {
2100 let workspace = mw.workspaces().nth(1).unwrap().clone();
2101 mw.activate(workspace, None, window, cx);
2102 });
2103 cx.run_until_parked();
2104 assert_eq!(
2105 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2106 workspace_1
2107 );
2108
2109 // Confirm on the historical (non-live) thread at index 1.
2110 // Before a previous fix, the workspace field was Option<usize> and
2111 // historical threads had None, so activate_thread early-returned
2112 // without switching the workspace.
2113 sidebar.update_in(cx, |sidebar, window, cx| {
2114 sidebar.selection = Some(1);
2115 sidebar.confirm(&Confirm, window, cx);
2116 });
2117 cx.run_until_parked();
2118
2119 assert_eq!(
2120 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2121 workspace_0
2122 );
2123}
2124
2125#[gpui::test]
2126async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
2127 cx: &mut TestAppContext,
2128) {
2129 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2130 let (multi_workspace, cx) =
2131 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2132 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2133
2134 let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
2135 let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
2136 save_thread_metadata(
2137 newer_session_id,
2138 Some("Newer Historical Thread".into()),
2139 newer_timestamp,
2140 Some(newer_timestamp),
2141 None,
2142 &project,
2143 cx,
2144 );
2145
2146 let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
2147 let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
2148 save_thread_metadata(
2149 older_session_id.clone(),
2150 Some("Older Historical Thread".into()),
2151 older_timestamp,
2152 Some(older_timestamp),
2153 None,
2154 &project,
2155 cx,
2156 );
2157
2158 cx.run_until_parked();
2159 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2160 cx.run_until_parked();
2161
2162 let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2163 .into_iter()
2164 .filter(|entry| entry.contains("Historical Thread"))
2165 .collect();
2166 assert_eq!(
2167 historical_entries_before,
2168 vec![
2169 " Newer Historical Thread".to_string(),
2170 " Older Historical Thread".to_string(),
2171 ],
2172 "expected the sidebar to sort historical threads by their saved timestamp before activation"
2173 );
2174
2175 let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
2176 sidebar
2177 .contents
2178 .entries
2179 .iter()
2180 .position(|entry| {
2181 matches!(entry, ListEntry::Thread(thread)
2182 if thread.metadata.session_id.as_ref() == Some(&older_session_id))
2183 })
2184 .expect("expected Older Historical Thread to appear in the sidebar")
2185 });
2186
2187 sidebar.update_in(cx, |sidebar, window, cx| {
2188 sidebar.selection = Some(older_entry_index);
2189 sidebar.confirm(&Confirm, window, cx);
2190 });
2191 cx.run_until_parked();
2192
2193 let older_metadata = cx.update(|_, cx| {
2194 ThreadMetadataStore::global(cx)
2195 .read(cx)
2196 .entry_by_session(&older_session_id)
2197 .cloned()
2198 .expect("expected metadata for Older Historical Thread after activation")
2199 });
2200 assert_eq!(
2201 older_metadata.created_at,
2202 Some(older_timestamp),
2203 "activating a historical thread should not rewrite its saved created_at timestamp"
2204 );
2205
2206 let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2207 .into_iter()
2208 .filter(|entry| entry.contains("Historical Thread"))
2209 .collect();
2210 assert_eq!(
2211 historical_entries_after,
2212 vec![
2213 " Newer Historical Thread".to_string(),
2214 " Older Historical Thread <== selected".to_string(),
2215 ],
2216 "activating an older historical thread should not reorder it ahead of a newer historical thread"
2217 );
2218}
2219
2220#[gpui::test]
2221async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
2222 cx: &mut TestAppContext,
2223) {
2224 use workspace::ProjectGroup;
2225
2226 agent_ui::test_support::init_test(cx);
2227 cx.update(|cx| {
2228 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
2229 ThreadStore::init_global(cx);
2230 ThreadMetadataStore::init_global(cx);
2231 language_model::LanguageModelRegistry::test(cx);
2232 prompt_store::init(cx);
2233 });
2234
2235 let fs = FakeFs::new(cx.executor());
2236 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
2237 .await;
2238 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
2239 .await;
2240 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2241
2242 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
2243 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
2244
2245 let (multi_workspace, cx) =
2246 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2247 let sidebar = setup_sidebar(&multi_workspace, cx);
2248
2249 let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
2250 multi_workspace.update(cx, |mw, _cx| {
2251 mw.test_add_project_group(ProjectGroup {
2252 key: project_b_key.clone(),
2253 workspaces: Vec::new(),
2254 expanded: true,
2255 });
2256 });
2257
2258 let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
2259 save_thread_metadata(
2260 session_id.clone(),
2261 Some("Historical Thread in New Group".into()),
2262 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2263 None,
2264 None,
2265 &project_b,
2266 cx,
2267 );
2268 cx.run_until_parked();
2269
2270 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2271 cx.run_until_parked();
2272
2273 let entries_before = visible_entries_as_strings(&sidebar, cx);
2274 assert_eq!(
2275 entries_before,
2276 vec![
2277 "v [project-a]",
2278 "v [project-b]",
2279 " Historical Thread in New Group",
2280 ],
2281 "expected the closed project group to show the historical thread before first open"
2282 );
2283
2284 assert_eq!(
2285 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2286 1,
2287 "should start without an open workspace for the new project group"
2288 );
2289
2290 sidebar.update_in(cx, |sidebar, window, cx| {
2291 sidebar.selection = Some(2);
2292 sidebar.confirm(&Confirm, window, cx);
2293 });
2294
2295 cx.run_until_parked();
2296
2297 assert_eq!(
2298 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2299 2,
2300 "confirming the historical thread should open a workspace for the new project group"
2301 );
2302
2303 let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
2304 mw.workspaces()
2305 .find(|workspace| {
2306 PathList::new(&workspace.read(cx).root_paths(cx))
2307 == project_b_key.path_list().clone()
2308 })
2309 .cloned()
2310 .expect("expected workspace for project-b after opening the historical thread")
2311 });
2312
2313 assert_eq!(
2314 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2315 workspace_b,
2316 "opening the historical thread should activate the new project's workspace"
2317 );
2318
2319 let panel = workspace_b.read_with(cx, |workspace, cx| {
2320 workspace
2321 .panel::<AgentPanel>(cx)
2322 .expect("expected first-open activation to bootstrap the agent panel")
2323 });
2324
2325 let expected_thread_id = cx.update(|_, cx| {
2326 ThreadMetadataStore::global(cx)
2327 .read(cx)
2328 .entries()
2329 .find(|e| e.session_id.as_ref() == Some(&session_id))
2330 .map(|e| e.thread_id)
2331 .expect("metadata should still map session id to thread id")
2332 });
2333
2334 assert_eq!(
2335 panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
2336 Some(expected_thread_id),
2337 "expected the agent panel to activate the real historical thread rather than a draft"
2338 );
2339
2340 let entries_after = visible_entries_as_strings(&sidebar, cx);
2341 let matching_rows: Vec<_> = entries_after
2342 .iter()
2343 .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
2344 .cloned()
2345 .collect();
2346 assert_eq!(
2347 matching_rows.len(),
2348 1,
2349 "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
2350 );
2351 assert!(
2352 matching_rows[0].contains("Historical Thread in New Group"),
2353 "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
2354 );
2355 assert!(
2356 !matching_rows[0].contains("Draft"),
2357 "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
2358 );
2359}
2360
2361#[gpui::test]
2362async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
2363 let project = init_test_project("/my-project", cx).await;
2364 let (multi_workspace, cx) =
2365 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2366 let sidebar = setup_sidebar(&multi_workspace, cx);
2367
2368 save_thread_metadata(
2369 acp::SessionId::new(Arc::from("t-1")),
2370 Some("Thread A".into()),
2371 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2372 None,
2373 None,
2374 &project,
2375 cx,
2376 );
2377
2378 save_thread_metadata(
2379 acp::SessionId::new(Arc::from("t-2")),
2380 Some("Thread B".into()),
2381 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2382 None,
2383 None,
2384 &project,
2385 cx,
2386 );
2387
2388 cx.run_until_parked();
2389 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2390 cx.run_until_parked();
2391
2392 assert_eq!(
2393 visible_entries_as_strings(&sidebar, cx),
2394 vec![
2395 //
2396 "v [my-project]",
2397 " Thread A",
2398 " Thread B",
2399 ]
2400 );
2401
2402 // Keyboard confirm preserves selection.
2403 sidebar.update_in(cx, |sidebar, window, cx| {
2404 sidebar.selection = Some(1);
2405 sidebar.confirm(&Confirm, window, cx);
2406 });
2407 assert_eq!(
2408 sidebar.read_with(cx, |sidebar, _| sidebar.selection),
2409 Some(1)
2410 );
2411
2412 // Click handlers clear selection to None so no highlight lingers
2413 // after a click regardless of focus state. The hover style provides
2414 // visual feedback during mouse interaction instead.
2415 sidebar.update_in(cx, |sidebar, window, cx| {
2416 sidebar.selection = None;
2417 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2418 let project_group_key = ProjectGroupKey::new(None, path_list);
2419 sidebar.toggle_collapse(&project_group_key, window, cx);
2420 });
2421 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2422
2423 // When the user tabs back into the sidebar, focus_in no longer
2424 // restores selection — it stays None.
2425 sidebar.update_in(cx, |sidebar, window, cx| {
2426 sidebar.focus_in(window, cx);
2427 });
2428 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2429}
2430
2431#[gpui::test]
2432async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
2433 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2434 let (multi_workspace, cx) =
2435 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2436 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2437
2438 let connection = StubAgentConnection::new();
2439 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2440 acp::ContentChunk::new("Hi there!".into()),
2441 )]);
2442 open_thread_with_connection(&panel, connection, cx);
2443 send_message(&panel, cx);
2444
2445 let session_id = active_session_id(&panel, cx);
2446 save_test_thread_metadata(&session_id, &project, cx).await;
2447 cx.run_until_parked();
2448
2449 assert_eq!(
2450 visible_entries_as_strings(&sidebar, cx),
2451 vec![
2452 //
2453 "v [my-project]",
2454 " Hello *",
2455 ]
2456 );
2457
2458 // Simulate the agent generating a title. The notification chain is:
2459 // AcpThread::set_title emits TitleUpdated →
2460 // ConnectionView::handle_thread_event calls cx.notify() →
2461 // AgentPanel observer fires and emits AgentPanelEvent →
2462 // Sidebar subscription calls update_entries / rebuild_contents.
2463 //
2464 // Before the fix, handle_thread_event did NOT call cx.notify() for
2465 // TitleUpdated, so the AgentPanel observer never fired and the
2466 // sidebar kept showing the old title.
2467 let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2468 thread.update(cx, |thread, cx| {
2469 thread
2470 .set_title("Friendly Greeting with AI".into(), cx)
2471 .detach();
2472 });
2473 cx.run_until_parked();
2474
2475 assert_eq!(
2476 visible_entries_as_strings(&sidebar, cx),
2477 vec![
2478 //
2479 "v [my-project]",
2480 " Friendly Greeting with AI *",
2481 ]
2482 );
2483}
2484
2485#[gpui::test]
2486async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
2487 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2488 let (multi_workspace, cx) =
2489 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2490 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2491
2492 // Save a thread so it appears in the list.
2493 let connection_a = StubAgentConnection::new();
2494 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2495 acp::ContentChunk::new("Done".into()),
2496 )]);
2497 open_thread_with_connection(&panel_a, connection_a, cx);
2498 send_message(&panel_a, cx);
2499 let session_id_a = active_session_id(&panel_a, cx);
2500 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
2501
2502 // Add a second workspace with its own agent panel.
2503 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2504 fs.as_fake()
2505 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2506 .await;
2507 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2508 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2509 mw.test_add_workspace(project_b.clone(), window, cx)
2510 });
2511 let panel_b = add_agent_panel(&workspace_b, cx);
2512 cx.run_until_parked();
2513
2514 let workspace_a =
2515 multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2516
2517 // ── 1. Initial state: focused thread derived from active panel ─────
2518 sidebar.read_with(cx, |sidebar, _cx| {
2519 assert_active_thread(
2520 sidebar,
2521 &session_id_a,
2522 "The active panel's thread should be focused on startup",
2523 );
2524 });
2525
2526 let thread_metadata_a = cx.update(|_window, cx| {
2527 ThreadMetadataStore::global(cx)
2528 .read(cx)
2529 .entry_by_session(&session_id_a)
2530 .cloned()
2531 .expect("session_id_a should exist in metadata store")
2532 });
2533 sidebar.update_in(cx, |sidebar, window, cx| {
2534 sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
2535 });
2536 cx.run_until_parked();
2537
2538 sidebar.read_with(cx, |sidebar, _cx| {
2539 assert_active_thread(
2540 sidebar,
2541 &session_id_a,
2542 "After clicking a thread, it should be the focused thread",
2543 );
2544 assert!(
2545 has_thread_entry(sidebar, &session_id_a),
2546 "The clicked thread should be present in the entries"
2547 );
2548 });
2549
2550 workspace_a.read_with(cx, |workspace, cx| {
2551 assert!(
2552 workspace.panel::<AgentPanel>(cx).is_some(),
2553 "Agent panel should exist"
2554 );
2555 let dock = workspace.left_dock().read(cx);
2556 assert!(
2557 dock.is_open(),
2558 "Clicking a thread should open the agent panel dock"
2559 );
2560 });
2561
2562 let connection_b = StubAgentConnection::new();
2563 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2564 acp::ContentChunk::new("Thread B".into()),
2565 )]);
2566 open_thread_with_connection(&panel_b, connection_b, cx);
2567 send_message(&panel_b, cx);
2568 let session_id_b = active_session_id(&panel_b, cx);
2569 save_test_thread_metadata(&session_id_b, &project_b, cx).await;
2570 cx.run_until_parked();
2571
2572 // Workspace A is currently active. Click a thread in workspace B,
2573 // which also triggers a workspace switch.
2574 let thread_metadata_b = cx.update(|_window, cx| {
2575 ThreadMetadataStore::global(cx)
2576 .read(cx)
2577 .entry_by_session(&session_id_b)
2578 .cloned()
2579 .expect("session_id_b should exist in metadata store")
2580 });
2581 sidebar.update_in(cx, |sidebar, window, cx| {
2582 sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
2583 });
2584 cx.run_until_parked();
2585
2586 sidebar.read_with(cx, |sidebar, _cx| {
2587 assert_active_thread(
2588 sidebar,
2589 &session_id_b,
2590 "Clicking a thread in another workspace should focus that thread",
2591 );
2592 assert!(
2593 has_thread_entry(sidebar, &session_id_b),
2594 "The cross-workspace thread should be present in the entries"
2595 );
2596 });
2597
2598 multi_workspace.update_in(cx, |mw, window, cx| {
2599 let workspace = mw.workspaces().next().unwrap().clone();
2600 mw.activate(workspace, None, window, cx);
2601 });
2602 cx.run_until_parked();
2603
2604 sidebar.read_with(cx, |sidebar, _cx| {
2605 assert_active_thread(
2606 sidebar,
2607 &session_id_a,
2608 "Switching workspace should seed focused_thread from the new active panel",
2609 );
2610 assert!(
2611 has_thread_entry(sidebar, &session_id_a),
2612 "The seeded thread should be present in the entries"
2613 );
2614 });
2615
2616 let connection_b2 = StubAgentConnection::new();
2617 connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2618 acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
2619 )]);
2620 open_thread_with_connection(&panel_b, connection_b2, cx);
2621 send_message(&panel_b, cx);
2622 let session_id_b2 = active_session_id(&panel_b, cx);
2623 save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
2624 cx.run_until_parked();
2625
2626 // Panel B is not the active workspace's panel (workspace A is
2627 // active), so opening a thread there should not change focused_thread.
2628 // This prevents running threads in background workspaces from causing
2629 // the selection highlight to jump around.
2630 sidebar.read_with(cx, |sidebar, _cx| {
2631 assert_active_thread(
2632 sidebar,
2633 &session_id_a,
2634 "Opening a thread in a non-active panel should not change focused_thread",
2635 );
2636 });
2637
2638 workspace_b.update_in(cx, |workspace, window, cx| {
2639 workspace.focus_handle(cx).focus(window, cx);
2640 });
2641 cx.run_until_parked();
2642
2643 sidebar.read_with(cx, |sidebar, _cx| {
2644 assert_active_thread(
2645 sidebar,
2646 &session_id_a,
2647 "Defocusing the sidebar should not change focused_thread",
2648 );
2649 });
2650
2651 // Switching workspaces via the multi_workspace (simulates clicking
2652 // a workspace header) should clear focused_thread.
2653 multi_workspace.update_in(cx, |mw, window, cx| {
2654 let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
2655 if let Some(workspace) = workspace {
2656 mw.activate(workspace, None, window, cx);
2657 }
2658 });
2659 cx.run_until_parked();
2660
2661 sidebar.read_with(cx, |sidebar, _cx| {
2662 assert_active_thread(
2663 sidebar,
2664 &session_id_b2,
2665 "Switching workspace should seed focused_thread from the new active panel",
2666 );
2667 assert!(
2668 has_thread_entry(sidebar, &session_id_b2),
2669 "The seeded thread should be present in the entries"
2670 );
2671 });
2672
2673 // ── 8. Focusing the agent panel thread keeps focused_thread ────
2674 // Workspace B still has session_id_b2 loaded in the agent panel.
2675 // Clicking into the thread (simulated by focusing its view) should
2676 // keep focused_thread since it was already seeded on workspace switch.
2677 panel_b.update_in(cx, |panel, window, cx| {
2678 if let Some(thread_view) = panel.active_conversation_view() {
2679 thread_view.read(cx).focus_handle(cx).focus(window, cx);
2680 }
2681 });
2682 cx.run_until_parked();
2683
2684 sidebar.read_with(cx, |sidebar, _cx| {
2685 assert_active_thread(
2686 sidebar,
2687 &session_id_b2,
2688 "Focusing the agent panel thread should set focused_thread",
2689 );
2690 assert!(
2691 has_thread_entry(sidebar, &session_id_b2),
2692 "The focused thread should be present in the entries"
2693 );
2694 });
2695}
2696
2697#[gpui::test]
2698async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
2699 let project = init_test_project_with_agent_panel("/project-a", cx).await;
2700 let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
2701 let (multi_workspace, cx) =
2702 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2703 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2704
2705 // Start a thread and send a message so it has history.
2706 let connection = StubAgentConnection::new();
2707 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2708 acp::ContentChunk::new("Done".into()),
2709 )]);
2710 open_thread_with_connection(&panel, connection, cx);
2711 send_message(&panel, cx);
2712 let session_id = active_session_id(&panel, cx);
2713 save_test_thread_metadata(&session_id, &project, cx).await;
2714 cx.run_until_parked();
2715
2716 // Verify the thread appears in the sidebar.
2717 assert_eq!(
2718 visible_entries_as_strings(&sidebar, cx),
2719 vec![
2720 //
2721 "v [project-a]",
2722 " Hello *",
2723 ]
2724 );
2725
2726 // The "New Thread" button should NOT be in "active/draft" state
2727 // because the panel has a thread with messages.
2728 sidebar.read_with(cx, |sidebar, _cx| {
2729 assert!(
2730 matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
2731 "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
2732 sidebar.active_entry,
2733 );
2734 });
2735
2736 // Now add a second folder to the workspace, changing the path_list.
2737 fs.as_fake()
2738 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2739 .await;
2740 project
2741 .update(cx, |project, cx| {
2742 project.find_or_create_worktree("/project-b", true, cx)
2743 })
2744 .await
2745 .expect("should add worktree");
2746 cx.run_until_parked();
2747
2748 // The workspace path_list is now [project-a, project-b]. The active
2749 // thread's metadata was re-saved with the new paths by the agent panel's
2750 // project subscription. The old [project-a] key is replaced by the new
2751 // key since no other workspace claims it.
2752 let entries = visible_entries_as_strings(&sidebar, cx);
2753 // After adding a worktree, the thread migrates to the new group key.
2754 // A reconciliation draft may appear during the transition.
2755 assert!(
2756 entries.contains(&" Hello *".to_string()),
2757 "thread should still be present after adding folder: {entries:?}"
2758 );
2759 assert_eq!(entries[0], "v [project-a, project-b]");
2760
2761 // The "New Thread" button must still be clickable (not stuck in
2762 // "active/draft" state). Verify that `active_thread_is_draft` is
2763 // false — the panel still has the old thread with messages.
2764 sidebar.read_with(cx, |sidebar, _cx| {
2765 assert!(
2766 matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
2767 "After adding a folder the panel still has a thread with messages, \
2768 so active_entry should be Thread, got {:?}",
2769 sidebar.active_entry,
2770 );
2771 });
2772
2773 // Actually click "New Thread" by calling create_new_thread and
2774 // verify a new draft is created.
2775 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2776 sidebar.update_in(cx, |sidebar, window, cx| {
2777 sidebar.create_new_thread(&workspace, window, cx);
2778 });
2779 cx.run_until_parked();
2780
2781 // After creating a new thread, the panel should now be in draft
2782 // state (no messages on the new thread).
2783 sidebar.read_with(cx, |sidebar, _cx| {
2784 assert_active_draft(
2785 sidebar,
2786 &workspace,
2787 "After creating a new thread active_entry should be Draft",
2788 );
2789 });
2790}
2791#[gpui::test]
2792async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
2793 // When the user presses Cmd-N (NewThread action) while viewing a
2794 // non-empty thread, the panel should switch to the draft thread.
2795 // Drafts are not shown as sidebar rows.
2796 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2797 let (multi_workspace, cx) =
2798 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2799 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2800
2801 // Create a non-empty thread (has messages).
2802 let connection = StubAgentConnection::new();
2803 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2804 acp::ContentChunk::new("Done".into()),
2805 )]);
2806 open_thread_with_connection(&panel, connection, cx);
2807 send_message(&panel, cx);
2808
2809 let session_id = active_session_id(&panel, cx);
2810 save_test_thread_metadata(&session_id, &project, cx).await;
2811 cx.run_until_parked();
2812
2813 assert_eq!(
2814 visible_entries_as_strings(&sidebar, cx),
2815 vec![
2816 //
2817 "v [my-project]",
2818 " Hello *",
2819 ]
2820 );
2821
2822 // Simulate cmd-n
2823 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2824 panel.update_in(cx, |panel, window, cx| {
2825 panel.new_thread(&NewThread, window, cx);
2826 });
2827 workspace.update_in(cx, |workspace, window, cx| {
2828 workspace.focus_panel::<AgentPanel>(window, cx);
2829 });
2830 cx.run_until_parked();
2831
2832 // Drafts are not shown as sidebar rows, so entries stay the same.
2833 assert_eq!(
2834 visible_entries_as_strings(&sidebar, cx),
2835 vec!["v [my-project]", " Hello *"],
2836 "After Cmd-N the sidebar should not show a Draft entry"
2837 );
2838
2839 // The panel should be on the draft and active_entry should track it.
2840 panel.read_with(cx, |panel, cx| {
2841 assert!(
2842 panel.active_thread_is_draft(cx),
2843 "panel should be showing the draft after Cmd-N",
2844 );
2845 });
2846 sidebar.read_with(cx, |sidebar, _cx| {
2847 assert_active_draft(
2848 sidebar,
2849 &workspace,
2850 "active_entry should be Draft after Cmd-N",
2851 );
2852 });
2853}
2854
2855#[gpui::test]
2856async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
2857 // When the active workspace is an absorbed git worktree, cmd-n
2858 // should activate the draft thread in the panel. Drafts are not
2859 // shown as sidebar rows.
2860 agent_ui::test_support::init_test(cx);
2861 cx.update(|cx| {
2862 ThreadStore::init_global(cx);
2863 ThreadMetadataStore::init_global(cx);
2864 language_model::LanguageModelRegistry::test(cx);
2865 prompt_store::init(cx);
2866 });
2867
2868 let fs = FakeFs::new(cx.executor());
2869
2870 // Main repo with a linked worktree.
2871 fs.insert_tree(
2872 "/project",
2873 serde_json::json!({
2874 ".git": {},
2875 "src": {},
2876 }),
2877 )
2878 .await;
2879
2880 // Worktree checkout pointing back to the main repo.
2881 fs.add_linked_worktree_for_repo(
2882 Path::new("/project/.git"),
2883 false,
2884 git::repository::Worktree {
2885 path: std::path::PathBuf::from("/wt-feature-a"),
2886 ref_name: Some("refs/heads/feature-a".into()),
2887 sha: "aaa".into(),
2888 is_main: false,
2889 is_bare: false,
2890 },
2891 )
2892 .await;
2893
2894 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2895
2896 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2897 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2898
2899 main_project
2900 .update(cx, |p, cx| p.git_scans_complete(cx))
2901 .await;
2902 worktree_project
2903 .update(cx, |p, cx| p.git_scans_complete(cx))
2904 .await;
2905
2906 let (multi_workspace, cx) =
2907 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
2908
2909 let sidebar = setup_sidebar(&multi_workspace, cx);
2910
2911 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
2912 mw.test_add_workspace(worktree_project.clone(), window, cx)
2913 });
2914
2915 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
2916
2917 // Switch to the worktree workspace.
2918 multi_workspace.update_in(cx, |mw, window, cx| {
2919 let workspace = mw.workspaces().nth(1).unwrap().clone();
2920 mw.activate(workspace, None, window, cx);
2921 });
2922
2923 // Create a non-empty thread in the worktree workspace.
2924 let connection = StubAgentConnection::new();
2925 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2926 acp::ContentChunk::new("Done".into()),
2927 )]);
2928 open_thread_with_connection(&worktree_panel, connection, cx);
2929 send_message(&worktree_panel, cx);
2930
2931 let session_id = active_session_id(&worktree_panel, cx);
2932 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
2933 cx.run_until_parked();
2934
2935 assert_eq!(
2936 visible_entries_as_strings(&sidebar, cx),
2937 vec![
2938 //
2939 "v [project]",
2940 " Hello {wt-feature-a} *",
2941 ]
2942 );
2943
2944 // Simulate Cmd-N in the worktree workspace.
2945 worktree_panel.update_in(cx, |panel, window, cx| {
2946 panel.new_thread(&NewThread, window, cx);
2947 });
2948 worktree_workspace.update_in(cx, |workspace, window, cx| {
2949 workspace.focus_panel::<AgentPanel>(window, cx);
2950 });
2951 cx.run_until_parked();
2952
2953 // Drafts are not shown as sidebar rows, so entries stay the same.
2954 assert_eq!(
2955 visible_entries_as_strings(&sidebar, cx),
2956 vec![
2957 //
2958 "v [project]",
2959 " Hello {wt-feature-a} *"
2960 ],
2961 "After Cmd-N the sidebar should not show a Draft entry"
2962 );
2963
2964 // The panel should be on the draft and active_entry should track it.
2965 worktree_panel.read_with(cx, |panel, cx| {
2966 assert!(
2967 panel.active_thread_is_draft(cx),
2968 "panel should be showing the draft after Cmd-N",
2969 );
2970 });
2971 sidebar.read_with(cx, |sidebar, _cx| {
2972 assert_active_draft(
2973 sidebar,
2974 &worktree_workspace,
2975 "active_entry should be Draft after Cmd-N",
2976 );
2977 });
2978}
2979
2980async fn init_test_project_with_git(
2981 worktree_path: &str,
2982 cx: &mut TestAppContext,
2983) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
2984 init_test(cx);
2985 let fs = FakeFs::new(cx.executor());
2986 fs.insert_tree(
2987 worktree_path,
2988 serde_json::json!({
2989 ".git": {},
2990 "src": {},
2991 }),
2992 )
2993 .await;
2994 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2995 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
2996 (project, fs)
2997}
2998
2999#[gpui::test]
3000async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
3001 let (project, fs) = init_test_project_with_git("/project", cx).await;
3002
3003 fs.as_fake()
3004 .add_linked_worktree_for_repo(
3005 Path::new("/project/.git"),
3006 false,
3007 git::repository::Worktree {
3008 path: std::path::PathBuf::from("/wt/rosewood"),
3009 ref_name: Some("refs/heads/rosewood".into()),
3010 sha: "abc".into(),
3011 is_main: false,
3012 is_bare: false,
3013 },
3014 )
3015 .await;
3016
3017 project
3018 .update(cx, |project, cx| project.git_scans_complete(cx))
3019 .await;
3020
3021 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3022 worktree_project
3023 .update(cx, |p, cx| p.git_scans_complete(cx))
3024 .await;
3025
3026 let (multi_workspace, cx) =
3027 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3028 let sidebar = setup_sidebar(&multi_workspace, cx);
3029
3030 save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
3031 save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
3032
3033 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3034 cx.run_until_parked();
3035
3036 // Search for "rosewood" — should match the worktree name, not the title.
3037 type_in_search(&sidebar, "rosewood", cx);
3038
3039 assert_eq!(
3040 visible_entries_as_strings(&sidebar, cx),
3041 vec![
3042 //
3043 "v [project]",
3044 " Fix Bug {rosewood} <== selected",
3045 ],
3046 );
3047}
3048
3049#[gpui::test]
3050async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
3051 let (project, fs) = init_test_project_with_git("/project", cx).await;
3052
3053 project
3054 .update(cx, |project, cx| project.git_scans_complete(cx))
3055 .await;
3056
3057 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3058 worktree_project
3059 .update(cx, |p, cx| p.git_scans_complete(cx))
3060 .await;
3061
3062 let (multi_workspace, cx) =
3063 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3064 let sidebar = setup_sidebar(&multi_workspace, cx);
3065
3066 // Save a thread against a worktree path with the correct main
3067 // worktree association (as if the git state had been resolved).
3068 save_thread_metadata_with_main_paths(
3069 "wt-thread",
3070 "Worktree Thread",
3071 PathList::new(&[PathBuf::from("/wt/rosewood")]),
3072 PathList::new(&[PathBuf::from("/project")]),
3073 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3074 cx,
3075 );
3076
3077 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3078 cx.run_until_parked();
3079
3080 // Thread is visible because its main_worktree_paths match the group.
3081 // The chip name is derived from the path even before git discovery.
3082 assert_eq!(
3083 visible_entries_as_strings(&sidebar, cx),
3084 vec!["v [project]", " Worktree Thread {rosewood}"]
3085 );
3086
3087 // Now add the worktree to the git state and trigger a rescan.
3088 fs.as_fake()
3089 .add_linked_worktree_for_repo(
3090 Path::new("/project/.git"),
3091 true,
3092 git::repository::Worktree {
3093 path: std::path::PathBuf::from("/wt/rosewood"),
3094 ref_name: Some("refs/heads/rosewood".into()),
3095 sha: "abc".into(),
3096 is_main: false,
3097 is_bare: false,
3098 },
3099 )
3100 .await;
3101
3102 cx.run_until_parked();
3103
3104 assert_eq!(
3105 visible_entries_as_strings(&sidebar, cx),
3106 vec![
3107 //
3108 "v [project]",
3109 " Worktree Thread {rosewood}",
3110 ]
3111 );
3112}
3113
3114#[gpui::test]
3115async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
3116 init_test(cx);
3117 let fs = FakeFs::new(cx.executor());
3118
3119 // Create the main repo directory (not opened as a workspace yet).
3120 fs.insert_tree(
3121 "/project",
3122 serde_json::json!({
3123 ".git": {
3124 },
3125 "src": {},
3126 }),
3127 )
3128 .await;
3129
3130 // Two worktree checkouts whose .git files point back to the main repo.
3131 fs.add_linked_worktree_for_repo(
3132 Path::new("/project/.git"),
3133 false,
3134 git::repository::Worktree {
3135 path: std::path::PathBuf::from("/wt-feature-a"),
3136 ref_name: Some("refs/heads/feature-a".into()),
3137 sha: "aaa".into(),
3138 is_main: false,
3139 is_bare: false,
3140 },
3141 )
3142 .await;
3143 fs.add_linked_worktree_for_repo(
3144 Path::new("/project/.git"),
3145 false,
3146 git::repository::Worktree {
3147 path: std::path::PathBuf::from("/wt-feature-b"),
3148 ref_name: Some("refs/heads/feature-b".into()),
3149 sha: "bbb".into(),
3150 is_main: false,
3151 is_bare: false,
3152 },
3153 )
3154 .await;
3155
3156 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3157
3158 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3159 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3160
3161 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3162 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3163
3164 // Open both worktrees as workspaces — no main repo yet.
3165 let (multi_workspace, cx) =
3166 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3167 multi_workspace.update_in(cx, |mw, window, cx| {
3168 mw.test_add_workspace(project_b.clone(), window, cx);
3169 });
3170 let sidebar = setup_sidebar(&multi_workspace, cx);
3171
3172 save_thread_metadata(
3173 acp::SessionId::new(Arc::from("thread-a")),
3174 Some("Thread A".into()),
3175 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3176 None,
3177 None,
3178 &project_a,
3179 cx,
3180 );
3181 save_thread_metadata(
3182 acp::SessionId::new(Arc::from("thread-b")),
3183 Some("Thread B".into()),
3184 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
3185 None,
3186 None,
3187 &project_b,
3188 cx,
3189 );
3190
3191 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3192 cx.run_until_parked();
3193
3194 // Without the main repo, each worktree has its own header.
3195 assert_eq!(
3196 visible_entries_as_strings(&sidebar, cx),
3197 vec![
3198 //
3199 "v [project]",
3200 " Thread B {wt-feature-b}",
3201 " Thread A {wt-feature-a}",
3202 ]
3203 );
3204
3205 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3206 main_project
3207 .update(cx, |p, cx| p.git_scans_complete(cx))
3208 .await;
3209
3210 multi_workspace.update_in(cx, |mw, window, cx| {
3211 mw.test_add_workspace(main_project.clone(), window, cx);
3212 });
3213 cx.run_until_parked();
3214
3215 // Both worktree workspaces should now be absorbed under the main
3216 // repo header, with worktree chips.
3217 assert_eq!(
3218 visible_entries_as_strings(&sidebar, cx),
3219 vec![
3220 //
3221 "v [project]",
3222 " Thread B {wt-feature-b}",
3223 " Thread A {wt-feature-a}",
3224 ]
3225 );
3226}
3227
3228#[gpui::test]
3229async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
3230 // When a group has two workspaces — one with threads and one
3231 // without — the threadless workspace should appear as a
3232 // "New Thread" button with its worktree chip.
3233 init_test(cx);
3234 let fs = FakeFs::new(cx.executor());
3235
3236 // Main repo with two linked worktrees.
3237 fs.insert_tree(
3238 "/project",
3239 serde_json::json!({
3240 ".git": {},
3241 "src": {},
3242 }),
3243 )
3244 .await;
3245 fs.add_linked_worktree_for_repo(
3246 Path::new("/project/.git"),
3247 false,
3248 git::repository::Worktree {
3249 path: std::path::PathBuf::from("/wt-feature-a"),
3250 ref_name: Some("refs/heads/feature-a".into()),
3251 sha: "aaa".into(),
3252 is_main: false,
3253 is_bare: false,
3254 },
3255 )
3256 .await;
3257 fs.add_linked_worktree_for_repo(
3258 Path::new("/project/.git"),
3259 false,
3260 git::repository::Worktree {
3261 path: std::path::PathBuf::from("/wt-feature-b"),
3262 ref_name: Some("refs/heads/feature-b".into()),
3263 sha: "bbb".into(),
3264 is_main: false,
3265 is_bare: false,
3266 },
3267 )
3268 .await;
3269
3270 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3271
3272 // Workspace A: worktree feature-a (has threads).
3273 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3274 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3275
3276 // Workspace B: worktree feature-b (no threads).
3277 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3278 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3279
3280 let (multi_workspace, cx) =
3281 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3282 multi_workspace.update_in(cx, |mw, window, cx| {
3283 mw.test_add_workspace(project_b.clone(), window, cx);
3284 });
3285 let sidebar = setup_sidebar(&multi_workspace, cx);
3286
3287 // Only save a thread for workspace A.
3288 save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
3289
3290 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3291 cx.run_until_parked();
3292
3293 // Workspace A's thread appears normally. Workspace B (threadless)
3294 // appears as a "New Thread" button with its worktree chip.
3295 assert_eq!(
3296 visible_entries_as_strings(&sidebar, cx),
3297 vec!["v [project]", " Thread A {wt-feature-a}",]
3298 );
3299}
3300
3301#[gpui::test]
3302async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
3303 // A thread created in a workspace with roots from different git
3304 // worktrees should show a chip for each distinct worktree name.
3305 init_test(cx);
3306 let fs = FakeFs::new(cx.executor());
3307
3308 // Two main repos.
3309 fs.insert_tree(
3310 "/project_a",
3311 serde_json::json!({
3312 ".git": {},
3313 "src": {},
3314 }),
3315 )
3316 .await;
3317 fs.insert_tree(
3318 "/project_b",
3319 serde_json::json!({
3320 ".git": {},
3321 "src": {},
3322 }),
3323 )
3324 .await;
3325
3326 // Worktree checkouts.
3327 for repo in &["project_a", "project_b"] {
3328 let git_path = format!("/{repo}/.git");
3329 for branch in &["olivetti", "selectric"] {
3330 fs.add_linked_worktree_for_repo(
3331 Path::new(&git_path),
3332 false,
3333 git::repository::Worktree {
3334 path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
3335 ref_name: Some(format!("refs/heads/{branch}").into()),
3336 sha: "aaa".into(),
3337 is_main: false,
3338 is_bare: false,
3339 },
3340 )
3341 .await;
3342 }
3343 }
3344
3345 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3346
3347 // Open a workspace with the worktree checkout paths as roots
3348 // (this is the workspace the thread was created in).
3349 let project = project::Project::test(
3350 fs.clone(),
3351 [
3352 "/worktrees/project_a/olivetti/project_a".as_ref(),
3353 "/worktrees/project_b/selectric/project_b".as_ref(),
3354 ],
3355 cx,
3356 )
3357 .await;
3358 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3359
3360 let (multi_workspace, cx) =
3361 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3362 let sidebar = setup_sidebar(&multi_workspace, cx);
3363
3364 // Save a thread under the same paths as the workspace roots.
3365 save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
3366
3367 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3368 cx.run_until_parked();
3369
3370 // Should show two distinct worktree chips.
3371 assert_eq!(
3372 visible_entries_as_strings(&sidebar, cx),
3373 vec![
3374 //
3375 "v [project_a, project_b]",
3376 " Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
3377 ]
3378 );
3379}
3380
3381#[gpui::test]
3382async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
3383 // When a thread's roots span multiple repos but share the same
3384 // worktree name (e.g. both in "olivetti"), only one chip should
3385 // appear.
3386 init_test(cx);
3387 let fs = FakeFs::new(cx.executor());
3388
3389 fs.insert_tree(
3390 "/project_a",
3391 serde_json::json!({
3392 ".git": {},
3393 "src": {},
3394 }),
3395 )
3396 .await;
3397 fs.insert_tree(
3398 "/project_b",
3399 serde_json::json!({
3400 ".git": {},
3401 "src": {},
3402 }),
3403 )
3404 .await;
3405
3406 for repo in &["project_a", "project_b"] {
3407 let git_path = format!("/{repo}/.git");
3408 fs.add_linked_worktree_for_repo(
3409 Path::new(&git_path),
3410 false,
3411 git::repository::Worktree {
3412 path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
3413 ref_name: Some("refs/heads/olivetti".into()),
3414 sha: "aaa".into(),
3415 is_main: false,
3416 is_bare: false,
3417 },
3418 )
3419 .await;
3420 }
3421
3422 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3423
3424 let project = project::Project::test(
3425 fs.clone(),
3426 [
3427 "/worktrees/project_a/olivetti/project_a".as_ref(),
3428 "/worktrees/project_b/olivetti/project_b".as_ref(),
3429 ],
3430 cx,
3431 )
3432 .await;
3433 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3434
3435 let (multi_workspace, cx) =
3436 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3437 let sidebar = setup_sidebar(&multi_workspace, cx);
3438
3439 // Thread with roots in both repos' "olivetti" worktrees.
3440 save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
3441
3442 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3443 cx.run_until_parked();
3444
3445 // Both worktree paths have the name "olivetti", so only one chip.
3446 assert_eq!(
3447 visible_entries_as_strings(&sidebar, cx),
3448 vec![
3449 //
3450 "v [project_a, project_b]",
3451 " Same Branch Thread {olivetti}",
3452 ]
3453 );
3454}
3455
3456#[gpui::test]
3457async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
3458 // When a worktree workspace is absorbed under the main repo, a
3459 // running thread in the worktree's agent panel should still show
3460 // live status (spinner + "(running)") in the sidebar.
3461 agent_ui::test_support::init_test(cx);
3462 cx.update(|cx| {
3463 ThreadStore::init_global(cx);
3464 ThreadMetadataStore::init_global(cx);
3465 language_model::LanguageModelRegistry::test(cx);
3466 prompt_store::init(cx);
3467 });
3468
3469 let fs = FakeFs::new(cx.executor());
3470
3471 // Main repo with a linked worktree.
3472 fs.insert_tree(
3473 "/project",
3474 serde_json::json!({
3475 ".git": {},
3476 "src": {},
3477 }),
3478 )
3479 .await;
3480
3481 // Worktree checkout pointing back to the main repo.
3482 fs.add_linked_worktree_for_repo(
3483 Path::new("/project/.git"),
3484 false,
3485 git::repository::Worktree {
3486 path: std::path::PathBuf::from("/wt-feature-a"),
3487 ref_name: Some("refs/heads/feature-a".into()),
3488 sha: "aaa".into(),
3489 is_main: false,
3490 is_bare: false,
3491 },
3492 )
3493 .await;
3494
3495 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3496
3497 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3498 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3499
3500 main_project
3501 .update(cx, |p, cx| p.git_scans_complete(cx))
3502 .await;
3503 worktree_project
3504 .update(cx, |p, cx| p.git_scans_complete(cx))
3505 .await;
3506
3507 // Create the MultiWorkspace with both projects.
3508 let (multi_workspace, cx) =
3509 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3510
3511 let sidebar = setup_sidebar(&multi_workspace, cx);
3512
3513 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3514 mw.test_add_workspace(worktree_project.clone(), window, cx)
3515 });
3516
3517 // Add an agent panel to the worktree workspace so we can run a
3518 // thread inside it.
3519 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3520
3521 // Switch back to the main workspace before setting up the sidebar.
3522 multi_workspace.update_in(cx, |mw, window, cx| {
3523 let workspace = mw.workspaces().next().unwrap().clone();
3524 mw.activate(workspace, None, window, cx);
3525 });
3526
3527 // Start a thread in the worktree workspace's panel and keep it
3528 // generating (don't resolve it).
3529 let connection = StubAgentConnection::new();
3530 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3531 send_message(&worktree_panel, cx);
3532
3533 let session_id = active_session_id(&worktree_panel, cx);
3534
3535 // Save metadata so the sidebar knows about this thread.
3536 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3537
3538 // Keep the thread generating by sending a chunk without ending
3539 // the turn.
3540 cx.update(|_, cx| {
3541 connection.send_update(
3542 session_id.clone(),
3543 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3544 cx,
3545 );
3546 });
3547 cx.run_until_parked();
3548
3549 // The worktree thread should be absorbed under the main project
3550 // and show live running status.
3551 let entries = visible_entries_as_strings(&sidebar, cx);
3552 assert_eq!(
3553 entries,
3554 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
3555 );
3556}
3557
3558#[gpui::test]
3559async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
3560 agent_ui::test_support::init_test(cx);
3561 cx.update(|cx| {
3562 ThreadStore::init_global(cx);
3563 ThreadMetadataStore::init_global(cx);
3564 language_model::LanguageModelRegistry::test(cx);
3565 prompt_store::init(cx);
3566 });
3567
3568 let fs = FakeFs::new(cx.executor());
3569
3570 fs.insert_tree(
3571 "/project",
3572 serde_json::json!({
3573 ".git": {},
3574 "src": {},
3575 }),
3576 )
3577 .await;
3578
3579 fs.add_linked_worktree_for_repo(
3580 Path::new("/project/.git"),
3581 false,
3582 git::repository::Worktree {
3583 path: std::path::PathBuf::from("/wt-feature-a"),
3584 ref_name: Some("refs/heads/feature-a".into()),
3585 sha: "aaa".into(),
3586 is_main: false,
3587 is_bare: false,
3588 },
3589 )
3590 .await;
3591
3592 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3593
3594 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3595 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3596
3597 main_project
3598 .update(cx, |p, cx| p.git_scans_complete(cx))
3599 .await;
3600 worktree_project
3601 .update(cx, |p, cx| p.git_scans_complete(cx))
3602 .await;
3603
3604 let (multi_workspace, cx) =
3605 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3606
3607 let sidebar = setup_sidebar(&multi_workspace, cx);
3608
3609 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3610 mw.test_add_workspace(worktree_project.clone(), window, cx)
3611 });
3612
3613 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3614
3615 multi_workspace.update_in(cx, |mw, window, cx| {
3616 let workspace = mw.workspaces().next().unwrap().clone();
3617 mw.activate(workspace, None, window, cx);
3618 });
3619
3620 let connection = StubAgentConnection::new();
3621 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3622 send_message(&worktree_panel, cx);
3623
3624 let session_id = active_session_id(&worktree_panel, cx);
3625 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3626
3627 cx.update(|_, cx| {
3628 connection.send_update(
3629 session_id.clone(),
3630 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3631 cx,
3632 );
3633 });
3634 cx.run_until_parked();
3635
3636 assert_eq!(
3637 visible_entries_as_strings(&sidebar, cx),
3638 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
3639 );
3640
3641 connection.end_turn(session_id, acp::StopReason::EndTurn);
3642 cx.run_until_parked();
3643
3644 assert_eq!(
3645 visible_entries_as_strings(&sidebar, cx),
3646 vec!["v [project]", " Hello {wt-feature-a} * (!)",]
3647 );
3648}
3649
3650#[gpui::test]
3651async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
3652 init_test(cx);
3653 let fs = FakeFs::new(cx.executor());
3654
3655 fs.insert_tree(
3656 "/project",
3657 serde_json::json!({
3658 ".git": {},
3659 "src": {},
3660 }),
3661 )
3662 .await;
3663
3664 fs.add_linked_worktree_for_repo(
3665 Path::new("/project/.git"),
3666 false,
3667 git::repository::Worktree {
3668 path: std::path::PathBuf::from("/wt-feature-a"),
3669 ref_name: Some("refs/heads/feature-a".into()),
3670 sha: "aaa".into(),
3671 is_main: false,
3672 is_bare: false,
3673 },
3674 )
3675 .await;
3676
3677 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3678
3679 // Only open the main repo — no workspace for the worktree.
3680 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3681 main_project
3682 .update(cx, |p, cx| p.git_scans_complete(cx))
3683 .await;
3684
3685 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3686 worktree_project
3687 .update(cx, |p, cx| p.git_scans_complete(cx))
3688 .await;
3689
3690 let (multi_workspace, cx) =
3691 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3692 let sidebar = setup_sidebar(&multi_workspace, cx);
3693
3694 // Save a thread for the worktree path (no workspace for it).
3695 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3696
3697 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3698 cx.run_until_parked();
3699
3700 // Thread should appear under the main repo with a worktree chip.
3701 assert_eq!(
3702 visible_entries_as_strings(&sidebar, cx),
3703 vec![
3704 //
3705 "v [project]",
3706 " WT Thread {wt-feature-a}",
3707 ],
3708 );
3709
3710 // Only 1 workspace should exist.
3711 assert_eq!(
3712 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3713 1,
3714 );
3715
3716 // Focus the sidebar and select the worktree thread.
3717 focus_sidebar(&sidebar, cx);
3718 sidebar.update_in(cx, |sidebar, _window, _cx| {
3719 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3720 });
3721
3722 // Confirm to open the worktree thread.
3723 cx.dispatch_action(Confirm);
3724 cx.run_until_parked();
3725
3726 // A new workspace should have been created for the worktree path.
3727 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
3728 assert_eq!(
3729 mw.workspaces().count(),
3730 2,
3731 "confirming a worktree thread without a workspace should open one",
3732 );
3733 mw.workspaces().nth(1).unwrap().clone()
3734 });
3735
3736 let new_path_list =
3737 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
3738 assert_eq!(
3739 new_path_list,
3740 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
3741 "the new workspace should have been opened for the worktree path",
3742 );
3743}
3744
3745#[gpui::test]
3746async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
3747 cx: &mut TestAppContext,
3748) {
3749 init_test(cx);
3750 let fs = FakeFs::new(cx.executor());
3751
3752 fs.insert_tree(
3753 "/project",
3754 serde_json::json!({
3755 ".git": {},
3756 "src": {},
3757 }),
3758 )
3759 .await;
3760
3761 fs.add_linked_worktree_for_repo(
3762 Path::new("/project/.git"),
3763 false,
3764 git::repository::Worktree {
3765 path: std::path::PathBuf::from("/wt-feature-a"),
3766 ref_name: Some("refs/heads/feature-a".into()),
3767 sha: "aaa".into(),
3768 is_main: false,
3769 is_bare: false,
3770 },
3771 )
3772 .await;
3773
3774 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3775
3776 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3777 main_project
3778 .update(cx, |p, cx| p.git_scans_complete(cx))
3779 .await;
3780
3781 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3782 worktree_project
3783 .update(cx, |p, cx| p.git_scans_complete(cx))
3784 .await;
3785
3786 let (multi_workspace, cx) =
3787 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3788 let sidebar = setup_sidebar(&multi_workspace, cx);
3789
3790 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3791
3792 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3793 cx.run_until_parked();
3794
3795 assert_eq!(
3796 visible_entries_as_strings(&sidebar, cx),
3797 vec![
3798 //
3799 "v [project]",
3800 " WT Thread {wt-feature-a}",
3801 ],
3802 );
3803
3804 focus_sidebar(&sidebar, cx);
3805 sidebar.update_in(cx, |sidebar, _window, _cx| {
3806 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3807 });
3808
3809 let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
3810 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
3811 if let ListEntry::ProjectHeader { label, .. } = entry {
3812 Some(label.as_ref())
3813 } else {
3814 None
3815 }
3816 });
3817
3818 let Some(project_header) = project_headers.next() else {
3819 panic!("expected exactly one sidebar project header named `project`, found none");
3820 };
3821 assert_eq!(
3822 project_header, "project",
3823 "expected the only sidebar project header to be `project`"
3824 );
3825 if let Some(unexpected_header) = project_headers.next() {
3826 panic!(
3827 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
3828 );
3829 }
3830
3831 let mut saw_expected_thread = false;
3832 for entry in &sidebar.contents.entries {
3833 match entry {
3834 ListEntry::ProjectHeader { label, .. } => {
3835 assert_eq!(
3836 label.as_ref(),
3837 "project",
3838 "expected the only sidebar project header to be `project`"
3839 );
3840 }
3841 ListEntry::Thread(thread)
3842 if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
3843 && thread
3844 .worktrees
3845 .first()
3846 .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
3847 == Some("wt-feature-a") =>
3848 {
3849 saw_expected_thread = true;
3850 }
3851 ListEntry::Thread(thread) => {
3852 let title = thread.metadata.display_title();
3853 let worktree_name = thread
3854 .worktrees
3855 .first()
3856 .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
3857 .unwrap_or("<none>");
3858 panic!(
3859 "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
3860 title, worktree_name
3861 );
3862 }
3863 }
3864 }
3865
3866 assert!(
3867 saw_expected_thread,
3868 "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
3869 );
3870 };
3871
3872 sidebar
3873 .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
3874 .detach();
3875
3876 let window = cx.windows()[0];
3877 cx.update_window(window, |_, window, cx| {
3878 window.dispatch_action(Confirm.boxed_clone(), cx);
3879 })
3880 .unwrap();
3881
3882 cx.run_until_parked();
3883
3884 sidebar.update(cx, assert_sidebar_state);
3885}
3886
3887#[gpui::test]
3888async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
3889 cx: &mut TestAppContext,
3890) {
3891 init_test(cx);
3892 let fs = FakeFs::new(cx.executor());
3893
3894 fs.insert_tree(
3895 "/project",
3896 serde_json::json!({
3897 ".git": {},
3898 "src": {},
3899 }),
3900 )
3901 .await;
3902
3903 fs.add_linked_worktree_for_repo(
3904 Path::new("/project/.git"),
3905 false,
3906 git::repository::Worktree {
3907 path: std::path::PathBuf::from("/wt-feature-a"),
3908 ref_name: Some("refs/heads/feature-a".into()),
3909 sha: "aaa".into(),
3910 is_main: false,
3911 is_bare: false,
3912 },
3913 )
3914 .await;
3915
3916 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3917
3918 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3919 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3920
3921 main_project
3922 .update(cx, |p, cx| p.git_scans_complete(cx))
3923 .await;
3924 worktree_project
3925 .update(cx, |p, cx| p.git_scans_complete(cx))
3926 .await;
3927
3928 let (multi_workspace, cx) =
3929 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3930
3931 let sidebar = setup_sidebar(&multi_workspace, cx);
3932
3933 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3934 mw.test_add_workspace(worktree_project.clone(), window, cx)
3935 });
3936
3937 // Activate the main workspace before setting up the sidebar.
3938 let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3939 let workspace = mw.workspaces().next().unwrap().clone();
3940 mw.activate(workspace.clone(), None, window, cx);
3941 workspace
3942 });
3943
3944 save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
3945 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3946
3947 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3948 cx.run_until_parked();
3949
3950 // The worktree workspace should be absorbed under the main repo.
3951 let entries = visible_entries_as_strings(&sidebar, cx);
3952 assert_eq!(entries.len(), 3);
3953 assert_eq!(entries[0], "v [project]");
3954 assert!(entries.contains(&" Main Thread".to_string()));
3955 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
3956
3957 let wt_thread_index = entries
3958 .iter()
3959 .position(|e| e.contains("WT Thread"))
3960 .expect("should find the worktree thread entry");
3961
3962 assert_eq!(
3963 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3964 main_workspace,
3965 "main workspace should be active initially"
3966 );
3967
3968 // Focus the sidebar and select the absorbed worktree thread.
3969 focus_sidebar(&sidebar, cx);
3970 sidebar.update_in(cx, |sidebar, _window, _cx| {
3971 sidebar.selection = Some(wt_thread_index);
3972 });
3973
3974 // Confirm to activate the worktree thread.
3975 cx.dispatch_action(Confirm);
3976 cx.run_until_parked();
3977
3978 // The worktree workspace should now be active, not the main one.
3979 let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3980 assert_eq!(
3981 active_workspace, worktree_workspace,
3982 "clicking an absorbed worktree thread should activate the worktree workspace"
3983 );
3984}
3985
3986#[gpui::test]
3987async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
3988 cx: &mut TestAppContext,
3989) {
3990 // Thread has saved metadata in ThreadStore. A matching workspace is
3991 // already open. Expected: activates the matching workspace.
3992 init_test(cx);
3993 let fs = FakeFs::new(cx.executor());
3994 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3995 .await;
3996 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3997 .await;
3998 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3999
4000 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4001 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4002
4003 let (multi_workspace, cx) =
4004 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4005
4006 let sidebar = setup_sidebar(&multi_workspace, cx);
4007
4008 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4009 mw.test_add_workspace(project_b.clone(), window, cx)
4010 });
4011 let workspace_a =
4012 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4013
4014 // Save a thread with path_list pointing to project-b.
4015 let session_id = acp::SessionId::new(Arc::from("archived-1"));
4016 save_test_thread_metadata(&session_id, &project_b, cx).await;
4017
4018 // Ensure workspace A is active.
4019 multi_workspace.update_in(cx, |mw, window, cx| {
4020 let workspace = mw.workspaces().next().unwrap().clone();
4021 mw.activate(workspace, None, window, cx);
4022 });
4023 cx.run_until_parked();
4024 assert_eq!(
4025 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4026 workspace_a
4027 );
4028
4029 // Call activate_archived_thread – should resolve saved paths and
4030 // switch to the workspace for project-b.
4031 sidebar.update_in(cx, |sidebar, window, cx| {
4032 sidebar.open_thread_from_archive(
4033 ThreadMetadata {
4034 thread_id: ThreadId::new(),
4035 session_id: Some(session_id.clone()),
4036 agent_id: agent::ZED_AGENT_ID.clone(),
4037 title: Some("Archived Thread".into()),
4038 updated_at: Utc::now(),
4039 created_at: None,
4040 interacted_at: None,
4041 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4042 "/project-b",
4043 )])),
4044 archived: false,
4045 remote_connection: None,
4046 },
4047 window,
4048 cx,
4049 );
4050 });
4051 cx.run_until_parked();
4052
4053 assert_eq!(
4054 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4055 workspace_b,
4056 "should have switched to the workspace matching the saved paths"
4057 );
4058}
4059
4060#[gpui::test]
4061async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
4062 cx: &mut TestAppContext,
4063) {
4064 // Thread has no saved metadata but session_info has cwd. A matching
4065 // workspace is open. Expected: uses cwd to find and activate it.
4066 init_test(cx);
4067 let fs = FakeFs::new(cx.executor());
4068 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4069 .await;
4070 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4071 .await;
4072 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4073
4074 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4075 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4076
4077 let (multi_workspace, cx) =
4078 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4079
4080 let sidebar = setup_sidebar(&multi_workspace, cx);
4081
4082 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4083 mw.test_add_workspace(project_b, window, cx)
4084 });
4085 let workspace_a =
4086 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4087
4088 // Start with workspace A active.
4089 multi_workspace.update_in(cx, |mw, window, cx| {
4090 let workspace = mw.workspaces().next().unwrap().clone();
4091 mw.activate(workspace, None, window, cx);
4092 });
4093 cx.run_until_parked();
4094 assert_eq!(
4095 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4096 workspace_a
4097 );
4098
4099 // No thread saved to the store – cwd is the only path hint.
4100 sidebar.update_in(cx, |sidebar, window, cx| {
4101 sidebar.open_thread_from_archive(
4102 ThreadMetadata {
4103 thread_id: ThreadId::new(),
4104 session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
4105 agent_id: agent::ZED_AGENT_ID.clone(),
4106 title: Some("CWD Thread".into()),
4107 updated_at: Utc::now(),
4108 created_at: None,
4109 interacted_at: None,
4110 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
4111 std::path::PathBuf::from("/project-b"),
4112 ])),
4113 archived: false,
4114 remote_connection: None,
4115 },
4116 window,
4117 cx,
4118 );
4119 });
4120 cx.run_until_parked();
4121
4122 assert_eq!(
4123 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4124 workspace_b,
4125 "should have activated the workspace matching the cwd"
4126 );
4127}
4128
4129#[gpui::test]
4130async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4131 cx: &mut TestAppContext,
4132) {
4133 // Thread has no saved metadata and no cwd. Expected: falls back to
4134 // the currently active workspace.
4135 init_test(cx);
4136 let fs = FakeFs::new(cx.executor());
4137 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4138 .await;
4139 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4140 .await;
4141 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4142
4143 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4144 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4145
4146 let (multi_workspace, cx) =
4147 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4148
4149 let sidebar = setup_sidebar(&multi_workspace, cx);
4150
4151 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4152 mw.test_add_workspace(project_b, window, cx)
4153 });
4154
4155 // Activate workspace B (index 1) to make it the active one.
4156 multi_workspace.update_in(cx, |mw, window, cx| {
4157 let workspace = mw.workspaces().nth(1).unwrap().clone();
4158 mw.activate(workspace, None, window, cx);
4159 });
4160 cx.run_until_parked();
4161 assert_eq!(
4162 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4163 workspace_b
4164 );
4165
4166 // No saved thread, no cwd – should fall back to the active workspace.
4167 sidebar.update_in(cx, |sidebar, window, cx| {
4168 sidebar.open_thread_from_archive(
4169 ThreadMetadata {
4170 thread_id: ThreadId::new(),
4171 session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
4172 agent_id: agent::ZED_AGENT_ID.clone(),
4173 title: Some("Contextless Thread".into()),
4174 updated_at: Utc::now(),
4175 created_at: None,
4176 interacted_at: None,
4177 worktree_paths: WorktreePaths::default(),
4178 archived: false,
4179 remote_connection: None,
4180 },
4181 window,
4182 cx,
4183 );
4184 });
4185 cx.run_until_parked();
4186
4187 assert_eq!(
4188 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4189 workspace_b,
4190 "should have stayed on the active workspace when no path info is available"
4191 );
4192}
4193
4194#[gpui::test]
4195async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
4196 // Thread has saved metadata pointing to a path with no open workspace.
4197 // Expected: opens a new workspace for that path.
4198 init_test(cx);
4199 let fs = FakeFs::new(cx.executor());
4200 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4201 .await;
4202 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4203 .await;
4204 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4205
4206 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4207
4208 let (multi_workspace, cx) =
4209 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4210
4211 let sidebar = setup_sidebar(&multi_workspace, cx);
4212
4213 // Save a thread with path_list pointing to project-b – which has no
4214 // open workspace.
4215 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4216 let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4217
4218 assert_eq!(
4219 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4220 1,
4221 "should start with one workspace"
4222 );
4223
4224 sidebar.update_in(cx, |sidebar, window, cx| {
4225 sidebar.open_thread_from_archive(
4226 ThreadMetadata {
4227 thread_id: ThreadId::new(),
4228 session_id: Some(session_id.clone()),
4229 agent_id: agent::ZED_AGENT_ID.clone(),
4230 title: Some("New WS Thread".into()),
4231 updated_at: Utc::now(),
4232 created_at: None,
4233 interacted_at: None,
4234 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
4235 archived: false,
4236 remote_connection: None,
4237 },
4238 window,
4239 cx,
4240 );
4241 });
4242 cx.run_until_parked();
4243
4244 assert_eq!(
4245 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4246 2,
4247 "should have opened a second workspace for the archived thread's saved paths"
4248 );
4249}
4250
4251#[gpui::test]
4252async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
4253 init_test(cx);
4254 let fs = FakeFs::new(cx.executor());
4255 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4256 .await;
4257 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4258 .await;
4259 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4260
4261 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4262 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4263
4264 let multi_workspace_a =
4265 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4266 let multi_workspace_b =
4267 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4268
4269 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4270 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4271
4272 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4273 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4274
4275 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4276 let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
4277
4278 let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
4279
4280 sidebar.update_in(cx_a, |sidebar, window, cx| {
4281 sidebar.open_thread_from_archive(
4282 ThreadMetadata {
4283 thread_id: ThreadId::new(),
4284 session_id: Some(session_id.clone()),
4285 agent_id: agent::ZED_AGENT_ID.clone(),
4286 title: Some("Cross Window Thread".into()),
4287 updated_at: Utc::now(),
4288 created_at: None,
4289 interacted_at: None,
4290 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4291 "/project-b",
4292 )])),
4293 archived: false,
4294 remote_connection: None,
4295 },
4296 window,
4297 cx,
4298 );
4299 });
4300 cx_a.run_until_parked();
4301
4302 assert_eq!(
4303 multi_workspace_a
4304 .read_with(cx_a, |mw, _| mw.workspaces().count())
4305 .unwrap(),
4306 1,
4307 "should not add the other window's workspace into the current window"
4308 );
4309 assert_eq!(
4310 multi_workspace_b
4311 .read_with(cx_a, |mw, _| mw.workspaces().count())
4312 .unwrap(),
4313 1,
4314 "should reuse the existing workspace in the other window"
4315 );
4316 assert!(
4317 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4318 "should activate the window that already owns the matching workspace"
4319 );
4320 sidebar.read_with(cx_a, |sidebar, _| {
4321 assert!(
4322 !is_active_session(&sidebar, &session_id),
4323 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4324 );
4325 });
4326}
4327
4328#[gpui::test]
4329async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
4330 cx: &mut TestAppContext,
4331) {
4332 init_test(cx);
4333 let fs = FakeFs::new(cx.executor());
4334 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4335 .await;
4336 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4337 .await;
4338 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4339
4340 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4341 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4342
4343 let multi_workspace_a =
4344 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4345 let multi_workspace_b =
4346 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
4347
4348 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4349 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4350
4351 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4352 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4353
4354 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4355 let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4356 let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
4357 let _panel_b = add_agent_panel(&workspace_b, cx_b);
4358
4359 let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
4360
4361 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4362 sidebar.open_thread_from_archive(
4363 ThreadMetadata {
4364 thread_id: ThreadId::new(),
4365 session_id: Some(session_id.clone()),
4366 agent_id: agent::ZED_AGENT_ID.clone(),
4367 title: Some("Cross Window Thread".into()),
4368 updated_at: Utc::now(),
4369 created_at: None,
4370 interacted_at: None,
4371 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4372 "/project-b",
4373 )])),
4374 archived: false,
4375 remote_connection: None,
4376 },
4377 window,
4378 cx,
4379 );
4380 });
4381 cx_a.run_until_parked();
4382
4383 assert_eq!(
4384 multi_workspace_a
4385 .read_with(cx_a, |mw, _| mw.workspaces().count())
4386 .unwrap(),
4387 1,
4388 "should not add the other window's workspace into the current window"
4389 );
4390 assert_eq!(
4391 multi_workspace_b
4392 .read_with(cx_a, |mw, _| mw.workspaces().count())
4393 .unwrap(),
4394 1,
4395 "should reuse the existing workspace in the other window"
4396 );
4397 assert!(
4398 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4399 "should activate the window that already owns the matching workspace"
4400 );
4401 sidebar_a.read_with(cx_a, |sidebar, _| {
4402 assert!(
4403 !is_active_session(&sidebar, &session_id),
4404 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4405 );
4406 });
4407 sidebar_b.read_with(cx_b, |sidebar, _| {
4408 assert_active_thread(
4409 sidebar,
4410 &session_id,
4411 "target window's sidebar should eagerly focus the activated archived thread",
4412 );
4413 });
4414}
4415
4416#[gpui::test]
4417async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
4418 cx: &mut TestAppContext,
4419) {
4420 init_test(cx);
4421 let fs = FakeFs::new(cx.executor());
4422 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4423 .await;
4424 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4425
4426 let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4427 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4428
4429 let multi_workspace_b =
4430 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4431 let multi_workspace_a =
4432 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4433
4434 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4435 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4436
4437 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4438 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4439
4440 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4441 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4442
4443 let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
4444
4445 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4446 sidebar.open_thread_from_archive(
4447 ThreadMetadata {
4448 thread_id: ThreadId::new(),
4449 session_id: Some(session_id.clone()),
4450 agent_id: agent::ZED_AGENT_ID.clone(),
4451 title: Some("Current Window Thread".into()),
4452 updated_at: Utc::now(),
4453 created_at: None,
4454 interacted_at: None,
4455 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4456 "/project-a",
4457 )])),
4458 archived: false,
4459 remote_connection: None,
4460 },
4461 window,
4462 cx,
4463 );
4464 });
4465 cx_a.run_until_parked();
4466
4467 assert!(
4468 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
4469 "should keep activation in the current window when it already has a matching workspace"
4470 );
4471 sidebar_a.read_with(cx_a, |sidebar, _| {
4472 assert_active_thread(
4473 sidebar,
4474 &session_id,
4475 "current window's sidebar should eagerly focus the activated archived thread",
4476 );
4477 });
4478 assert_eq!(
4479 multi_workspace_a
4480 .read_with(cx_a, |mw, _| mw.workspaces().count())
4481 .unwrap(),
4482 1,
4483 "current window should continue reusing its existing workspace"
4484 );
4485 assert_eq!(
4486 multi_workspace_b
4487 .read_with(cx_a, |mw, _| mw.workspaces().count())
4488 .unwrap(),
4489 1,
4490 "other windows should not be activated just because they also match the saved paths"
4491 );
4492}
4493
4494#[gpui::test]
4495async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
4496 // Regression test: archive_thread previously always loaded the next thread
4497 // through group_workspace (the main workspace's ProjectHeader), even when
4498 // the next thread belonged to an absorbed linked-worktree workspace. That
4499 // caused the worktree thread to be loaded in the main panel, which bound it
4500 // to the main project and corrupted its stored folder_paths.
4501 //
4502 // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
4503 // falling back to group_workspace only for Closed workspaces.
4504 agent_ui::test_support::init_test(cx);
4505 cx.update(|cx| {
4506 ThreadStore::init_global(cx);
4507 ThreadMetadataStore::init_global(cx);
4508 language_model::LanguageModelRegistry::test(cx);
4509 prompt_store::init(cx);
4510 });
4511
4512 let fs = FakeFs::new(cx.executor());
4513
4514 fs.insert_tree(
4515 "/project",
4516 serde_json::json!({
4517 ".git": {},
4518 "src": {},
4519 }),
4520 )
4521 .await;
4522
4523 fs.add_linked_worktree_for_repo(
4524 Path::new("/project/.git"),
4525 false,
4526 git::repository::Worktree {
4527 path: std::path::PathBuf::from("/wt-feature-a"),
4528 ref_name: Some("refs/heads/feature-a".into()),
4529 sha: "aaa".into(),
4530 is_main: false,
4531 is_bare: false,
4532 },
4533 )
4534 .await;
4535
4536 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4537
4538 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4539 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4540
4541 main_project
4542 .update(cx, |p, cx| p.git_scans_complete(cx))
4543 .await;
4544 worktree_project
4545 .update(cx, |p, cx| p.git_scans_complete(cx))
4546 .await;
4547
4548 let (multi_workspace, cx) =
4549 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4550
4551 let sidebar = setup_sidebar(&multi_workspace, cx);
4552
4553 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4554 mw.test_add_workspace(worktree_project.clone(), window, cx)
4555 });
4556
4557 // Activate main workspace so the sidebar tracks the main panel.
4558 multi_workspace.update_in(cx, |mw, window, cx| {
4559 let workspace = mw.workspaces().next().unwrap().clone();
4560 mw.activate(workspace, None, window, cx);
4561 });
4562
4563 let main_workspace =
4564 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4565 let main_panel = add_agent_panel(&main_workspace, cx);
4566 let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
4567
4568 // Open Thread 2 in the main panel and keep it running.
4569 let connection = StubAgentConnection::new();
4570 open_thread_with_connection(&main_panel, connection.clone(), cx);
4571 send_message(&main_panel, cx);
4572
4573 let thread2_session_id = active_session_id(&main_panel, cx);
4574
4575 cx.update(|_, cx| {
4576 connection.send_update(
4577 thread2_session_id.clone(),
4578 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4579 cx,
4580 );
4581 });
4582
4583 // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
4584 save_thread_metadata(
4585 thread2_session_id.clone(),
4586 Some("Thread 2".into()),
4587 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4588 None,
4589 None,
4590 &main_project,
4591 cx,
4592 );
4593
4594 // Save thread 1's metadata with the worktree path and an older timestamp so
4595 // it sorts below thread 2. archive_thread will find it as the "next" candidate.
4596 let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
4597 save_thread_metadata(
4598 thread1_session_id,
4599 Some("Thread 1".into()),
4600 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4601 None,
4602 None,
4603 &worktree_project,
4604 cx,
4605 );
4606
4607 cx.run_until_parked();
4608
4609 // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
4610 let entries_before = visible_entries_as_strings(&sidebar, cx);
4611 assert!(
4612 entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
4613 "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
4614 entries_before
4615 );
4616
4617 // The sidebar should track T2 as the focused thread (derived from the
4618 // main panel's active view).
4619 sidebar.read_with(cx, |s, _| {
4620 assert_active_thread(
4621 s,
4622 &thread2_session_id,
4623 "focused thread should be Thread 2 before archiving",
4624 );
4625 });
4626
4627 // Archive thread 2.
4628 sidebar.update_in(cx, |sidebar, window, cx| {
4629 sidebar.archive_thread(&thread2_session_id, window, cx);
4630 });
4631
4632 cx.run_until_parked();
4633
4634 // The main panel's active thread must still be thread 2.
4635 let main_active = main_panel.read_with(cx, |panel, cx| {
4636 panel
4637 .active_agent_thread(cx)
4638 .map(|t| t.read(cx).session_id().clone())
4639 });
4640 assert_eq!(
4641 main_active,
4642 Some(thread2_session_id.clone()),
4643 "main panel should not have been taken over by loading the linked-worktree thread T1; \
4644 before the fix, archive_thread used group_workspace instead of next.workspace, \
4645 causing T1 to be loaded in the wrong panel"
4646 );
4647
4648 // Thread 1 should still appear in the sidebar with its worktree chip
4649 // (Thread 2 was archived so it is gone from the list).
4650 let entries_after = visible_entries_as_strings(&sidebar, cx);
4651 assert!(
4652 entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
4653 "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
4654 entries_after
4655 );
4656}
4657
4658#[gpui::test]
4659async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
4660 // When the last non-archived thread for a linked worktree is archived,
4661 // the linked worktree workspace should be removed from the multi-workspace.
4662 // The main worktree workspace should remain (it's always reachable via
4663 // the project header).
4664 init_test(cx);
4665 let fs = FakeFs::new(cx.executor());
4666
4667 fs.insert_tree(
4668 "/project",
4669 serde_json::json!({
4670 ".git": {
4671 "worktrees": {
4672 "feature-a": {
4673 "commondir": "../../",
4674 "HEAD": "ref: refs/heads/feature-a",
4675 },
4676 },
4677 },
4678 "src": {},
4679 }),
4680 )
4681 .await;
4682
4683 fs.insert_tree(
4684 "/worktrees/project/feature-a/project",
4685 serde_json::json!({
4686 ".git": "gitdir: /project/.git/worktrees/feature-a",
4687 "src": {},
4688 }),
4689 )
4690 .await;
4691
4692 fs.add_linked_worktree_for_repo(
4693 Path::new("/project/.git"),
4694 false,
4695 git::repository::Worktree {
4696 path: PathBuf::from("/worktrees/project/feature-a/project"),
4697 ref_name: Some("refs/heads/feature-a".into()),
4698 sha: "abc".into(),
4699 is_main: false,
4700 is_bare: false,
4701 },
4702 )
4703 .await;
4704
4705 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4706
4707 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4708 let worktree_project = project::Project::test(
4709 fs.clone(),
4710 ["/worktrees/project/feature-a/project".as_ref()],
4711 cx,
4712 )
4713 .await;
4714
4715 main_project
4716 .update(cx, |p, cx| p.git_scans_complete(cx))
4717 .await;
4718 worktree_project
4719 .update(cx, |p, cx| p.git_scans_complete(cx))
4720 .await;
4721
4722 let (multi_workspace, cx) =
4723 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4724 let sidebar = setup_sidebar(&multi_workspace, cx);
4725
4726 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4727 mw.test_add_workspace(worktree_project.clone(), window, cx)
4728 });
4729
4730 // Save a thread for the main project.
4731 save_thread_metadata(
4732 acp::SessionId::new(Arc::from("main-thread")),
4733 Some("Main Thread".into()),
4734 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4735 None,
4736 None,
4737 &main_project,
4738 cx,
4739 );
4740
4741 // Save a thread for the linked worktree.
4742 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
4743 save_thread_metadata(
4744 wt_thread_id.clone(),
4745 Some("Worktree Thread".into()),
4746 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4747 None,
4748 None,
4749 &worktree_project,
4750 cx,
4751 );
4752 cx.run_until_parked();
4753
4754 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4755 cx.run_until_parked();
4756
4757 // Should have 2 workspaces.
4758 assert_eq!(
4759 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4760 2,
4761 "should start with 2 workspaces (main + linked worktree)"
4762 );
4763
4764 // Archive the worktree thread (the only thread for /wt-feature-a).
4765 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
4766 sidebar.archive_thread(&wt_thread_id, window, cx);
4767 });
4768
4769 // archive_thread spawns a multi-layered chain of tasks (workspace
4770 // removal → git persist → disk removal), each of which may spawn
4771 // further background work. Each run_until_parked() call drives one
4772 // layer of pending work.
4773
4774 cx.run_until_parked();
4775
4776 // The linked worktree workspace should have been removed.
4777 assert_eq!(
4778 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4779 1,
4780 "linked worktree workspace should be removed after archiving its last thread"
4781 );
4782
4783 // The linked worktree checkout directory should also be removed from disk.
4784 assert!(
4785 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
4786 .await,
4787 "linked worktree directory should be removed from disk after archiving its last thread"
4788 );
4789
4790 // The main thread should still be visible.
4791 let entries = visible_entries_as_strings(&sidebar, cx);
4792 assert!(
4793 entries.iter().any(|e| e.contains("Main Thread")),
4794 "main thread should still be visible: {entries:?}"
4795 );
4796 assert!(
4797 !entries.iter().any(|e| e.contains("Worktree Thread")),
4798 "archived worktree thread should not be visible: {entries:?}"
4799 );
4800
4801 // The archived thread must retain its folder_paths so it can be
4802 // restored to the correct workspace later.
4803 let wt_thread_id = cx.update(|_window, cx| {
4804 ThreadMetadataStore::global(cx)
4805 .read(cx)
4806 .entry_by_session(&wt_thread_id)
4807 .unwrap()
4808 .thread_id
4809 });
4810 let archived_paths = cx.update(|_window, cx| {
4811 ThreadMetadataStore::global(cx)
4812 .read(cx)
4813 .entry(wt_thread_id)
4814 .unwrap()
4815 .folder_paths()
4816 .clone()
4817 });
4818 assert_eq!(
4819 archived_paths.paths(),
4820 &[PathBuf::from("/worktrees/project/feature-a/project")],
4821 "archived thread must retain its folder_paths for restore"
4822 );
4823}
4824
4825#[gpui::test]
4826async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
4827 // restore_worktree_via_git should succeed when the branch has moved
4828 // to a different SHA since archival. The worktree stays in detached
4829 // HEAD and the moved branch is left untouched.
4830 init_test(cx);
4831 let fs = FakeFs::new(cx.executor());
4832
4833 fs.insert_tree(
4834 "/project",
4835 serde_json::json!({
4836 ".git": {
4837 "worktrees": {
4838 "feature-a": {
4839 "commondir": "../../",
4840 "HEAD": "ref: refs/heads/feature-a",
4841 },
4842 },
4843 },
4844 "src": {},
4845 }),
4846 )
4847 .await;
4848 fs.insert_tree(
4849 "/wt-feature-a",
4850 serde_json::json!({
4851 ".git": "gitdir: /project/.git/worktrees/feature-a",
4852 "src": {},
4853 }),
4854 )
4855 .await;
4856 fs.add_linked_worktree_for_repo(
4857 Path::new("/project/.git"),
4858 false,
4859 git::repository::Worktree {
4860 path: PathBuf::from("/wt-feature-a"),
4861 ref_name: Some("refs/heads/feature-a".into()),
4862 sha: "original-sha".into(),
4863 is_main: false,
4864 is_bare: false,
4865 },
4866 )
4867 .await;
4868 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4869
4870 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4871 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4872 main_project
4873 .update(cx, |p, cx| p.git_scans_complete(cx))
4874 .await;
4875 worktree_project
4876 .update(cx, |p, cx| p.git_scans_complete(cx))
4877 .await;
4878
4879 let (multi_workspace, _cx) =
4880 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4881 multi_workspace.update_in(_cx, |mw, window, cx| {
4882 mw.test_add_workspace(worktree_project.clone(), window, cx)
4883 });
4884
4885 let wt_repo = worktree_project.read_with(cx, |project, cx| {
4886 project.repositories(cx).values().next().unwrap().clone()
4887 });
4888 let (staged_hash, unstaged_hash) = cx
4889 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
4890 .await
4891 .unwrap()
4892 .unwrap();
4893
4894 // Move the branch to a different SHA.
4895 fs.with_git_state(Path::new("/project/.git"), false, |state| {
4896 state
4897 .refs
4898 .insert("refs/heads/feature-a".into(), "moved-sha".into());
4899 })
4900 .unwrap();
4901
4902 let result = cx
4903 .spawn(|mut cx| async move {
4904 agent_ui::thread_worktree_archive::restore_worktree_via_git(
4905 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
4906 id: 1,
4907 worktree_path: PathBuf::from("/wt-feature-a"),
4908 main_repo_path: PathBuf::from("/project"),
4909 branch_name: Some("feature-a".to_string()),
4910 staged_commit_hash: staged_hash,
4911 unstaged_commit_hash: unstaged_hash,
4912 original_commit_hash: "original-sha".to_string(),
4913 },
4914 None,
4915 &mut cx,
4916 )
4917 .await
4918 })
4919 .await;
4920
4921 assert!(
4922 result.is_ok(),
4923 "restore should succeed even when branch has moved: {:?}",
4924 result.err()
4925 );
4926
4927 // The moved branch ref should be completely untouched.
4928 let branch_sha = fs
4929 .with_git_state(Path::new("/project/.git"), false, |state| {
4930 state.refs.get("refs/heads/feature-a").cloned()
4931 })
4932 .unwrap();
4933 assert_eq!(
4934 branch_sha.as_deref(),
4935 Some("moved-sha"),
4936 "the moved branch ref should not be modified by the restore"
4937 );
4938}
4939
4940#[gpui::test]
4941async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
4942 // restore_worktree_via_git should succeed when the branch still
4943 // points at the same SHA as at archive time.
4944 init_test(cx);
4945 let fs = FakeFs::new(cx.executor());
4946
4947 fs.insert_tree(
4948 "/project",
4949 serde_json::json!({
4950 ".git": {
4951 "worktrees": {
4952 "feature-b": {
4953 "commondir": "../../",
4954 "HEAD": "ref: refs/heads/feature-b",
4955 },
4956 },
4957 },
4958 "src": {},
4959 }),
4960 )
4961 .await;
4962 fs.insert_tree(
4963 "/wt-feature-b",
4964 serde_json::json!({
4965 ".git": "gitdir: /project/.git/worktrees/feature-b",
4966 "src": {},
4967 }),
4968 )
4969 .await;
4970 fs.add_linked_worktree_for_repo(
4971 Path::new("/project/.git"),
4972 false,
4973 git::repository::Worktree {
4974 path: PathBuf::from("/wt-feature-b"),
4975 ref_name: Some("refs/heads/feature-b".into()),
4976 sha: "original-sha".into(),
4977 is_main: false,
4978 is_bare: false,
4979 },
4980 )
4981 .await;
4982 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4983
4984 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4985 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
4986 main_project
4987 .update(cx, |p, cx| p.git_scans_complete(cx))
4988 .await;
4989 worktree_project
4990 .update(cx, |p, cx| p.git_scans_complete(cx))
4991 .await;
4992
4993 let (multi_workspace, _cx) =
4994 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4995 multi_workspace.update_in(_cx, |mw, window, cx| {
4996 mw.test_add_workspace(worktree_project.clone(), window, cx)
4997 });
4998
4999 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5000 project.repositories(cx).values().next().unwrap().clone()
5001 });
5002 let (staged_hash, unstaged_hash) = cx
5003 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5004 .await
5005 .unwrap()
5006 .unwrap();
5007
5008 // refs/heads/feature-b already points at "original-sha" (set by
5009 // add_linked_worktree_for_repo), matching original_commit_hash.
5010
5011 let result = cx
5012 .spawn(|mut cx| async move {
5013 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5014 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5015 id: 1,
5016 worktree_path: PathBuf::from("/wt-feature-b"),
5017 main_repo_path: PathBuf::from("/project"),
5018 branch_name: Some("feature-b".to_string()),
5019 staged_commit_hash: staged_hash,
5020 unstaged_commit_hash: unstaged_hash,
5021 original_commit_hash: "original-sha".to_string(),
5022 },
5023 None,
5024 &mut cx,
5025 )
5026 .await
5027 })
5028 .await;
5029
5030 assert!(
5031 result.is_ok(),
5032 "restore should succeed when branch has not moved: {:?}",
5033 result.err()
5034 );
5035}
5036
5037#[gpui::test]
5038async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
5039 // restore_worktree_via_git should succeed when the branch no longer
5040 // exists (e.g. it was deleted while the thread was archived). The
5041 // code should attempt to recreate the branch.
5042 init_test(cx);
5043 let fs = FakeFs::new(cx.executor());
5044
5045 fs.insert_tree(
5046 "/project",
5047 serde_json::json!({
5048 ".git": {
5049 "worktrees": {
5050 "feature-d": {
5051 "commondir": "../../",
5052 "HEAD": "ref: refs/heads/feature-d",
5053 },
5054 },
5055 },
5056 "src": {},
5057 }),
5058 )
5059 .await;
5060 fs.insert_tree(
5061 "/wt-feature-d",
5062 serde_json::json!({
5063 ".git": "gitdir: /project/.git/worktrees/feature-d",
5064 "src": {},
5065 }),
5066 )
5067 .await;
5068 fs.add_linked_worktree_for_repo(
5069 Path::new("/project/.git"),
5070 false,
5071 git::repository::Worktree {
5072 path: PathBuf::from("/wt-feature-d"),
5073 ref_name: Some("refs/heads/feature-d".into()),
5074 sha: "original-sha".into(),
5075 is_main: false,
5076 is_bare: false,
5077 },
5078 )
5079 .await;
5080 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5081
5082 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5083 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
5084 main_project
5085 .update(cx, |p, cx| p.git_scans_complete(cx))
5086 .await;
5087 worktree_project
5088 .update(cx, |p, cx| p.git_scans_complete(cx))
5089 .await;
5090
5091 let (multi_workspace, _cx) =
5092 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5093 multi_workspace.update_in(_cx, |mw, window, cx| {
5094 mw.test_add_workspace(worktree_project.clone(), window, cx)
5095 });
5096
5097 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5098 project.repositories(cx).values().next().unwrap().clone()
5099 });
5100 let (staged_hash, unstaged_hash) = cx
5101 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5102 .await
5103 .unwrap()
5104 .unwrap();
5105
5106 // Remove the branch ref so change_branch will fail.
5107 fs.with_git_state(Path::new("/project/.git"), false, |state| {
5108 state.refs.remove("refs/heads/feature-d");
5109 })
5110 .unwrap();
5111
5112 let result = cx
5113 .spawn(|mut cx| async move {
5114 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5115 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5116 id: 1,
5117 worktree_path: PathBuf::from("/wt-feature-d"),
5118 main_repo_path: PathBuf::from("/project"),
5119 branch_name: Some("feature-d".to_string()),
5120 staged_commit_hash: staged_hash,
5121 unstaged_commit_hash: unstaged_hash,
5122 original_commit_hash: "original-sha".to_string(),
5123 },
5124 None,
5125 &mut cx,
5126 )
5127 .await
5128 })
5129 .await;
5130
5131 assert!(
5132 result.is_ok(),
5133 "restore should succeed when branch does not exist: {:?}",
5134 result.err()
5135 );
5136}
5137
5138#[gpui::test]
5139async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
5140 // Activating an archived linked worktree thread whose directory has
5141 // been deleted should reuse the existing main repo workspace, not
5142 // create a new one. The provisional ProjectGroupKey must be derived
5143 // from main_worktree_paths so that find_or_create_local_workspace
5144 // matches the main repo workspace when the worktree path is absent.
5145 init_test(cx);
5146 let fs = FakeFs::new(cx.executor());
5147
5148 fs.insert_tree(
5149 "/project",
5150 serde_json::json!({
5151 ".git": {
5152 "worktrees": {
5153 "feature-c": {
5154 "commondir": "../../",
5155 "HEAD": "ref: refs/heads/feature-c",
5156 },
5157 },
5158 },
5159 "src": {},
5160 }),
5161 )
5162 .await;
5163
5164 fs.insert_tree(
5165 "/wt-feature-c",
5166 serde_json::json!({
5167 ".git": "gitdir: /project/.git/worktrees/feature-c",
5168 "src": {},
5169 }),
5170 )
5171 .await;
5172
5173 fs.add_linked_worktree_for_repo(
5174 Path::new("/project/.git"),
5175 false,
5176 git::repository::Worktree {
5177 path: PathBuf::from("/wt-feature-c"),
5178 ref_name: Some("refs/heads/feature-c".into()),
5179 sha: "original-sha".into(),
5180 is_main: false,
5181 is_bare: false,
5182 },
5183 )
5184 .await;
5185
5186 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5187
5188 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5189 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
5190
5191 main_project
5192 .update(cx, |p, cx| p.git_scans_complete(cx))
5193 .await;
5194 worktree_project
5195 .update(cx, |p, cx| p.git_scans_complete(cx))
5196 .await;
5197
5198 let (multi_workspace, cx) =
5199 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5200 let sidebar = setup_sidebar(&multi_workspace, cx);
5201
5202 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5203 mw.test_add_workspace(worktree_project.clone(), window, cx)
5204 });
5205
5206 // Save thread metadata for the linked worktree.
5207 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
5208 save_thread_metadata(
5209 wt_session_id.clone(),
5210 Some("Worktree Thread C".into()),
5211 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5212 None,
5213 None,
5214 &worktree_project,
5215 cx,
5216 );
5217 cx.run_until_parked();
5218
5219 let thread_id = cx.update(|_window, cx| {
5220 ThreadMetadataStore::global(cx)
5221 .read(cx)
5222 .entry_by_session(&wt_session_id)
5223 .unwrap()
5224 .thread_id
5225 });
5226
5227 // Archive the thread without creating ArchivedGitWorktree records.
5228 let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
5229 cx.update(|_window, cx| {
5230 store.update(cx, |store, cx| store.archive(thread_id, None, cx));
5231 });
5232 cx.run_until_parked();
5233
5234 // Remove the worktree workspace and delete the worktree from disk.
5235 let main_workspace =
5236 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5237 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
5238 mw.remove(
5239 vec![worktree_workspace],
5240 move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
5241 window,
5242 cx,
5243 )
5244 });
5245 remove_task.await.ok();
5246 cx.run_until_parked();
5247 cx.run_until_parked();
5248 fs.remove_dir(
5249 Path::new("/wt-feature-c"),
5250 fs::RemoveOptions {
5251 recursive: true,
5252 ignore_if_not_exists: true,
5253 },
5254 )
5255 .await
5256 .unwrap();
5257
5258 let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5259 assert_eq!(
5260 workspace_count_before, 1,
5261 "should have only the main workspace"
5262 );
5263
5264 // Activate the archived thread. The worktree path is missing from
5265 // disk, so find_or_create_local_workspace falls back to the
5266 // provisional ProjectGroupKey to find a matching workspace.
5267 let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
5268 sidebar.update_in(cx, |sidebar, window, cx| {
5269 sidebar.open_thread_from_archive(metadata, window, cx);
5270 });
5271 cx.run_until_parked();
5272
5273 // The provisional key should use [/project] (the main repo),
5274 // which matches the existing main workspace. If it incorrectly
5275 // used [/wt-feature-c] (the linked worktree path), no workspace
5276 // would match and a spurious new one would be created.
5277 let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5278 assert_eq!(
5279 workspace_count_after, 1,
5280 "restoring a linked worktree thread should reuse the main repo workspace, \
5281 not create a new one (workspace count went from {workspace_count_before} to \
5282 {workspace_count_after})"
5283 );
5284}
5285
5286#[gpui::test]
5287async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
5288 cx: &mut TestAppContext,
5289) {
5290 // A remote thread at the same path as a local linked worktree thread
5291 // should not prevent the local workspace from being removed when the
5292 // local thread is archived (the last local thread for that worktree).
5293 init_test(cx);
5294 let fs = FakeFs::new(cx.executor());
5295
5296 fs.insert_tree(
5297 "/project",
5298 serde_json::json!({
5299 ".git": {
5300 "worktrees": {
5301 "feature-a": {
5302 "commondir": "../../",
5303 "HEAD": "ref: refs/heads/feature-a",
5304 },
5305 },
5306 },
5307 "src": {},
5308 }),
5309 )
5310 .await;
5311
5312 fs.insert_tree(
5313 "/wt-feature-a",
5314 serde_json::json!({
5315 ".git": "gitdir: /project/.git/worktrees/feature-a",
5316 "src": {},
5317 }),
5318 )
5319 .await;
5320
5321 fs.add_linked_worktree_for_repo(
5322 Path::new("/project/.git"),
5323 false,
5324 git::repository::Worktree {
5325 path: PathBuf::from("/wt-feature-a"),
5326 ref_name: Some("refs/heads/feature-a".into()),
5327 sha: "abc".into(),
5328 is_main: false,
5329 is_bare: false,
5330 },
5331 )
5332 .await;
5333
5334 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5335
5336 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5337 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5338
5339 main_project
5340 .update(cx, |p, cx| p.git_scans_complete(cx))
5341 .await;
5342 worktree_project
5343 .update(cx, |p, cx| p.git_scans_complete(cx))
5344 .await;
5345
5346 let (multi_workspace, cx) =
5347 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5348 let sidebar = setup_sidebar(&multi_workspace, cx);
5349
5350 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5351 mw.test_add_workspace(worktree_project.clone(), window, cx)
5352 });
5353
5354 // Save a thread for the main project.
5355 save_thread_metadata(
5356 acp::SessionId::new(Arc::from("main-thread")),
5357 Some("Main Thread".into()),
5358 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5359 None,
5360 None,
5361 &main_project,
5362 cx,
5363 );
5364
5365 // Save a local thread for the linked worktree.
5366 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
5367 save_thread_metadata(
5368 wt_thread_id.clone(),
5369 Some("Local Worktree Thread".into()),
5370 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5371 None,
5372 None,
5373 &worktree_project,
5374 cx,
5375 );
5376
5377 // Save a remote thread at the same /wt-feature-a path but on a
5378 // different host. This should NOT count as a remaining thread for
5379 // the local linked worktree workspace.
5380 let remote_host =
5381 remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
5382 cx.update(|_window, cx| {
5383 let metadata = ThreadMetadata {
5384 thread_id: ThreadId::new(),
5385 session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
5386 agent_id: agent::ZED_AGENT_ID.clone(),
5387 title: Some("Remote Worktree Thread".into()),
5388 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5389 created_at: None,
5390 interacted_at: None,
5391 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
5392 "/wt-feature-a",
5393 )])),
5394 archived: false,
5395 remote_connection: Some(remote_host),
5396 };
5397 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5398 store.save(metadata, cx);
5399 });
5400 });
5401 cx.run_until_parked();
5402
5403 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5404 cx.run_until_parked();
5405
5406 assert_eq!(
5407 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5408 2,
5409 "should start with 2 workspaces (main + linked worktree)"
5410 );
5411
5412 // The remote thread should NOT appear in the sidebar (it belongs
5413 // to a different host and no matching remote project group exists).
5414 let entries_before = visible_entries_as_strings(&sidebar, cx);
5415 assert!(
5416 !entries_before
5417 .iter()
5418 .any(|e| e.contains("Remote Worktree Thread")),
5419 "remote thread should not appear in local sidebar: {entries_before:?}"
5420 );
5421
5422 // Archive the local worktree thread.
5423 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5424 sidebar.archive_thread(&wt_thread_id, window, cx);
5425 });
5426
5427 cx.run_until_parked();
5428
5429 // The linked worktree workspace should be removed because the
5430 // only *local* thread for it was archived. The remote thread at
5431 // the same path should not have prevented removal.
5432 assert_eq!(
5433 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5434 1,
5435 "linked worktree workspace should be removed; the remote thread at the same path \
5436 should not count as a remaining local thread"
5437 );
5438
5439 let entries = visible_entries_as_strings(&sidebar, cx);
5440 assert!(
5441 entries.iter().any(|e| e.contains("Main Thread")),
5442 "main thread should still be visible: {entries:?}"
5443 );
5444 assert!(
5445 !entries.iter().any(|e| e.contains("Local Worktree Thread")),
5446 "archived local worktree thread should not be visible: {entries:?}"
5447 );
5448 assert!(
5449 !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
5450 "remote thread should still not appear in local sidebar: {entries:?}"
5451 );
5452}
5453
5454#[gpui::test]
5455async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
5456 // When a multi-root workspace (e.g. [/other, /project]) shares a
5457 // repo with a single-root workspace (e.g. [/project]), linked
5458 // worktree threads from the shared repo should only appear under
5459 // the dedicated group [project], not under [other, project].
5460 agent_ui::test_support::init_test(cx);
5461 cx.update(|cx| {
5462 ThreadStore::init_global(cx);
5463 ThreadMetadataStore::init_global(cx);
5464 language_model::LanguageModelRegistry::test(cx);
5465 prompt_store::init(cx);
5466 });
5467 let fs = FakeFs::new(cx.executor());
5468
5469 // Two independent repos, each with their own git history.
5470 fs.insert_tree(
5471 "/project",
5472 serde_json::json!({
5473 ".git": {},
5474 "src": {},
5475 }),
5476 )
5477 .await;
5478 fs.insert_tree(
5479 "/other",
5480 serde_json::json!({
5481 ".git": {},
5482 "src": {},
5483 }),
5484 )
5485 .await;
5486
5487 // Register the linked worktree in the main repo.
5488 fs.add_linked_worktree_for_repo(
5489 Path::new("/project/.git"),
5490 false,
5491 git::repository::Worktree {
5492 path: std::path::PathBuf::from("/wt-feature-a"),
5493 ref_name: Some("refs/heads/feature-a".into()),
5494 sha: "aaa".into(),
5495 is_main: false,
5496 is_bare: false,
5497 },
5498 )
5499 .await;
5500
5501 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5502
5503 // Workspace 1: just /project.
5504 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5505 project_only
5506 .update(cx, |p, cx| p.git_scans_complete(cx))
5507 .await;
5508
5509 // Workspace 2: /other and /project together (multi-root).
5510 let multi_root =
5511 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
5512 multi_root
5513 .update(cx, |p, cx| p.git_scans_complete(cx))
5514 .await;
5515
5516 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5517 worktree_project
5518 .update(cx, |p, cx| p.git_scans_complete(cx))
5519 .await;
5520
5521 // Save a thread under the linked worktree path BEFORE setting up
5522 // the sidebar and panels, so that reconciliation sees the [project]
5523 // group as non-empty and doesn't create a spurious draft there.
5524 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
5525 save_thread_metadata(
5526 wt_session_id,
5527 Some("Worktree Thread".into()),
5528 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5529 None,
5530 None,
5531 &worktree_project,
5532 cx,
5533 );
5534
5535 let (multi_workspace, cx) =
5536 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
5537 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5538 let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5539 mw.test_add_workspace(multi_root.clone(), window, cx)
5540 });
5541 add_agent_panel(&multi_root_workspace, cx);
5542 cx.run_until_parked();
5543
5544 // The thread should appear only under [project] (the dedicated
5545 // group for the /project repo), not under [other, project].
5546 assert_eq!(
5547 visible_entries_as_strings(&sidebar, cx),
5548 vec![
5549 //
5550 "v [other, project]",
5551 "v [project]",
5552 " Worktree Thread {wt-feature-a}",
5553 ]
5554 );
5555}
5556
5557fn thread_id_for(session_id: &acp::SessionId, cx: &mut TestAppContext) -> ThreadId {
5558 cx.read(|cx| {
5559 ThreadMetadataStore::global(cx)
5560 .read(cx)
5561 .entry_by_session(session_id)
5562 .map(|m| m.thread_id)
5563 .expect("thread metadata should exist")
5564 })
5565}
5566
5567#[gpui::test]
5568async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
5569 let project = init_test_project_with_agent_panel("/my-project", cx).await;
5570 let (multi_workspace, cx) =
5571 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5572 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5573
5574 let switcher_ids =
5575 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<ThreadId> {
5576 sidebar.read_with(cx, |sidebar, cx| {
5577 let switcher = sidebar
5578 .thread_switcher
5579 .as_ref()
5580 .expect("switcher should be open");
5581 switcher
5582 .read(cx)
5583 .entries()
5584 .iter()
5585 .map(|e| e.metadata.thread_id)
5586 .collect()
5587 })
5588 };
5589
5590 let switcher_selected_id =
5591 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> ThreadId {
5592 sidebar.read_with(cx, |sidebar, cx| {
5593 let switcher = sidebar
5594 .thread_switcher
5595 .as_ref()
5596 .expect("switcher should be open");
5597 let s = switcher.read(cx);
5598 s.selected_entry()
5599 .expect("should have selection")
5600 .metadata
5601 .thread_id
5602 })
5603 };
5604
5605 // ── Setup: create three threads with distinct created_at times ──────
5606 // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
5607 // We send messages in each so they also get last_message_sent_or_queued timestamps.
5608 let connection_c = StubAgentConnection::new();
5609 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5610 acp::ContentChunk::new("Done C".into()),
5611 )]);
5612 open_thread_with_connection(&panel, connection_c, cx);
5613 send_message(&panel, cx);
5614 let session_id_c = active_session_id(&panel, cx);
5615 let thread_id_c = active_thread_id(&panel, cx);
5616 save_thread_metadata(
5617 session_id_c.clone(),
5618 Some("Thread C".into()),
5619 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5620 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
5621 None,
5622 &project,
5623 cx,
5624 );
5625
5626 let connection_b = StubAgentConnection::new();
5627 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5628 acp::ContentChunk::new("Done B".into()),
5629 )]);
5630 open_thread_with_connection(&panel, connection_b, cx);
5631 send_message(&panel, cx);
5632 let session_id_b = active_session_id(&panel, cx);
5633 let thread_id_b = active_thread_id(&panel, cx);
5634 save_thread_metadata(
5635 session_id_b.clone(),
5636 Some("Thread B".into()),
5637 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5638 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
5639 None,
5640 &project,
5641 cx,
5642 );
5643
5644 let connection_a = StubAgentConnection::new();
5645 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5646 acp::ContentChunk::new("Done A".into()),
5647 )]);
5648 open_thread_with_connection(&panel, connection_a, cx);
5649 send_message(&panel, cx);
5650 let session_id_a = active_session_id(&panel, cx);
5651 let thread_id_a = active_thread_id(&panel, cx);
5652 save_thread_metadata(
5653 session_id_a.clone(),
5654 Some("Thread A".into()),
5655 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
5656 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
5657 None,
5658 &project,
5659 cx,
5660 );
5661
5662 // All three threads are now live. Thread A was opened last, so it's
5663 // the one being viewed. Opening each thread called record_thread_access,
5664 // so all three have last_accessed_at set.
5665 // Access order is: A (most recent), B, C (oldest).
5666
5667 // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
5668 focus_sidebar(&sidebar, cx);
5669 sidebar.update_in(cx, |sidebar, window, cx| {
5670 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5671 });
5672 cx.run_until_parked();
5673
5674 // All three have last_accessed_at, so they sort by access time.
5675 // A was accessed most recently (it's the currently viewed thread),
5676 // then B, then C.
5677 assert_eq!(
5678 switcher_ids(&sidebar, cx),
5679 vec![thread_id_a, thread_id_b, thread_id_c,],
5680 );
5681 // First ctrl-tab selects the second entry (B).
5682 assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_b);
5683
5684 // Dismiss the switcher without confirming.
5685 sidebar.update_in(cx, |sidebar, _window, cx| {
5686 sidebar.dismiss_thread_switcher(cx);
5687 });
5688 cx.run_until_parked();
5689
5690 // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
5691 sidebar.update_in(cx, |sidebar, window, cx| {
5692 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5693 });
5694 cx.run_until_parked();
5695
5696 // Cycle twice to land on Thread C (index 2).
5697 sidebar.read_with(cx, |sidebar, cx| {
5698 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5699 assert_eq!(switcher.read(cx).selected_index(), 1);
5700 });
5701 sidebar.update_in(cx, |sidebar, _window, cx| {
5702 sidebar
5703 .thread_switcher
5704 .as_ref()
5705 .unwrap()
5706 .update(cx, |s, cx| s.cycle_selection(cx));
5707 });
5708 cx.run_until_parked();
5709 assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_c);
5710
5711 assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
5712
5713 // Confirm on Thread C.
5714 sidebar.update_in(cx, |sidebar, window, cx| {
5715 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5716 let focus = switcher.focus_handle(cx);
5717 focus.dispatch_action(&menu::Confirm, window, cx);
5718 });
5719 cx.run_until_parked();
5720
5721 // Switcher should be dismissed after confirm.
5722 sidebar.read_with(cx, |sidebar, _cx| {
5723 assert!(
5724 sidebar.thread_switcher.is_none(),
5725 "switcher should be dismissed"
5726 );
5727 });
5728
5729 sidebar.update(cx, |sidebar, _cx| {
5730 let last_accessed = sidebar
5731 .thread_last_accessed
5732 .keys()
5733 .cloned()
5734 .collect::<Vec<_>>();
5735 assert_eq!(last_accessed.len(), 1);
5736 assert!(last_accessed.contains(&thread_id_c));
5737 assert!(
5738 is_active_session(&sidebar, &session_id_c),
5739 "active_entry should be Thread({session_id_c:?})"
5740 );
5741 });
5742
5743 sidebar.update_in(cx, |sidebar, window, cx| {
5744 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5745 });
5746 cx.run_until_parked();
5747
5748 assert_eq!(
5749 switcher_ids(&sidebar, cx),
5750 vec![thread_id_c, thread_id_a, thread_id_b],
5751 );
5752
5753 // Confirm on Thread A.
5754 sidebar.update_in(cx, |sidebar, window, cx| {
5755 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5756 let focus = switcher.focus_handle(cx);
5757 focus.dispatch_action(&menu::Confirm, window, cx);
5758 });
5759 cx.run_until_parked();
5760
5761 sidebar.update(cx, |sidebar, _cx| {
5762 let last_accessed = sidebar
5763 .thread_last_accessed
5764 .keys()
5765 .cloned()
5766 .collect::<Vec<_>>();
5767 assert_eq!(last_accessed.len(), 2);
5768 assert!(last_accessed.contains(&thread_id_c));
5769 assert!(last_accessed.contains(&thread_id_a));
5770 assert!(
5771 is_active_session(&sidebar, &session_id_a),
5772 "active_entry should be Thread({session_id_a:?})"
5773 );
5774 });
5775
5776 sidebar.update_in(cx, |sidebar, window, cx| {
5777 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5778 });
5779 cx.run_until_parked();
5780
5781 assert_eq!(
5782 switcher_ids(&sidebar, cx),
5783 vec![thread_id_a, thread_id_c, thread_id_b,],
5784 );
5785
5786 sidebar.update_in(cx, |sidebar, _window, cx| {
5787 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5788 switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
5789 });
5790 cx.run_until_parked();
5791
5792 // Confirm on Thread B.
5793 sidebar.update_in(cx, |sidebar, window, cx| {
5794 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5795 let focus = switcher.focus_handle(cx);
5796 focus.dispatch_action(&menu::Confirm, window, cx);
5797 });
5798 cx.run_until_parked();
5799
5800 sidebar.update(cx, |sidebar, _cx| {
5801 let last_accessed = sidebar
5802 .thread_last_accessed
5803 .keys()
5804 .cloned()
5805 .collect::<Vec<_>>();
5806 assert_eq!(last_accessed.len(), 3);
5807 assert!(last_accessed.contains(&thread_id_c));
5808 assert!(last_accessed.contains(&thread_id_a));
5809 assert!(last_accessed.contains(&thread_id_b));
5810 assert!(
5811 is_active_session(&sidebar, &session_id_b),
5812 "active_entry should be Thread({session_id_b:?})"
5813 );
5814 });
5815
5816 // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
5817 // This thread was never opened in a panel — it only exists in metadata.
5818 save_thread_metadata(
5819 acp::SessionId::new(Arc::from("thread-historical")),
5820 Some("Historical Thread".into()),
5821 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
5822 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
5823 None,
5824 &project,
5825 cx,
5826 );
5827
5828 sidebar.update_in(cx, |sidebar, window, cx| {
5829 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5830 });
5831 cx.run_until_parked();
5832
5833 // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
5834 // so it falls to tier 3 (sorted by created_at). It should appear after all
5835 // accessed threads, even though its created_at (June 2024) is much later
5836 // than the others.
5837 //
5838 // But the live threads (A, B, C) each had send_message called which sets
5839 // last_message_sent_or_queued. So for the accessed threads (tier 1) the
5840 // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
5841 let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
5842 let thread_id_hist = thread_id_for(&session_id_hist, cx);
5843
5844 let ids = switcher_ids(&sidebar, cx);
5845 assert_eq!(
5846 ids,
5847 vec![thread_id_b, thread_id_a, thread_id_c, thread_id_hist],
5848 );
5849
5850 sidebar.update_in(cx, |sidebar, _window, cx| {
5851 sidebar.dismiss_thread_switcher(cx);
5852 });
5853 cx.run_until_parked();
5854
5855 // ── 4. Add another historical thread with older created_at ─────────
5856 save_thread_metadata(
5857 acp::SessionId::new(Arc::from("thread-old-historical")),
5858 Some("Old Historical Thread".into()),
5859 chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
5860 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
5861 None,
5862 &project,
5863 cx,
5864 );
5865
5866 sidebar.update_in(cx, |sidebar, window, cx| {
5867 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5868 });
5869 cx.run_until_parked();
5870
5871 // Both historical threads have no access or message times. They should
5872 // appear after accessed threads, sorted by created_at (newest first).
5873 let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
5874 let thread_id_old_hist = thread_id_for(&session_id_old_hist, cx);
5875 let ids = switcher_ids(&sidebar, cx);
5876 assert_eq!(
5877 ids,
5878 vec![
5879 thread_id_b,
5880 thread_id_a,
5881 thread_id_c,
5882 thread_id_hist,
5883 thread_id_old_hist,
5884 ],
5885 );
5886
5887 sidebar.update_in(cx, |sidebar, _window, cx| {
5888 sidebar.dismiss_thread_switcher(cx);
5889 });
5890 cx.run_until_parked();
5891}
5892
5893#[gpui::test]
5894async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
5895 let project = init_test_project("/my-project", cx).await;
5896 let (multi_workspace, cx) =
5897 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5898 let sidebar = setup_sidebar(&multi_workspace, cx);
5899
5900 save_thread_metadata(
5901 acp::SessionId::new(Arc::from("thread-to-archive")),
5902 Some("Thread To Archive".into()),
5903 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5904 None,
5905 None,
5906 &project,
5907 cx,
5908 );
5909 cx.run_until_parked();
5910
5911 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5912 cx.run_until_parked();
5913
5914 let entries = visible_entries_as_strings(&sidebar, cx);
5915 assert!(
5916 entries.iter().any(|e| e.contains("Thread To Archive")),
5917 "expected thread to be visible before archiving, got: {entries:?}"
5918 );
5919
5920 sidebar.update_in(cx, |sidebar, window, cx| {
5921 sidebar.archive_thread(
5922 &acp::SessionId::new(Arc::from("thread-to-archive")),
5923 window,
5924 cx,
5925 );
5926 });
5927 cx.run_until_parked();
5928
5929 let entries = visible_entries_as_strings(&sidebar, cx);
5930 assert!(
5931 !entries.iter().any(|e| e.contains("Thread To Archive")),
5932 "expected thread to be hidden after archiving, got: {entries:?}"
5933 );
5934
5935 cx.update(|_, cx| {
5936 let store = ThreadMetadataStore::global(cx);
5937 let archived: Vec<_> = store.read(cx).archived_entries().collect();
5938 assert_eq!(archived.len(), 1);
5939 assert_eq!(
5940 archived[0].session_id.as_ref().unwrap().0.as_ref(),
5941 "thread-to-archive"
5942 );
5943 assert!(archived[0].archived);
5944 });
5945}
5946
5947#[gpui::test]
5948async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
5949 // Tests two archive scenarios:
5950 // 1. Archiving a thread in a non-active workspace leaves active_entry
5951 // as the current draft.
5952 // 2. Archiving the thread the user is looking at falls back to a draft
5953 // on the same workspace.
5954 agent_ui::test_support::init_test(cx);
5955 cx.update(|cx| {
5956 ThreadStore::init_global(cx);
5957 ThreadMetadataStore::init_global(cx);
5958 language_model::LanguageModelRegistry::test(cx);
5959 prompt_store::init(cx);
5960 });
5961
5962 let fs = FakeFs::new(cx.executor());
5963 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
5964 .await;
5965 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
5966 .await;
5967 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5968
5969 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
5970 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
5971
5972 let (multi_workspace, cx) =
5973 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5974 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5975
5976 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5977 mw.test_add_workspace(project_b.clone(), window, cx)
5978 });
5979 let panel_b = add_agent_panel(&workspace_b, cx);
5980 cx.run_until_parked();
5981
5982 // Explicitly create a draft on workspace_b so the sidebar tracks one.
5983 sidebar.update_in(cx, |sidebar, window, cx| {
5984 sidebar.create_new_thread(&workspace_b, window, cx);
5985 });
5986 cx.run_until_parked();
5987
5988 // --- Scenario 1: archive a thread in the non-active workspace ---
5989
5990 // Create a thread in project-a (non-active — project-b is active).
5991 let connection = acp_thread::StubAgentConnection::new();
5992 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5993 acp::ContentChunk::new("Done".into()),
5994 )]);
5995 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
5996 agent_ui::test_support::send_message(&panel_a, cx);
5997 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
5998 cx.run_until_parked();
5999
6000 sidebar.update_in(cx, |sidebar, window, cx| {
6001 sidebar.archive_thread(&thread_a, window, cx);
6002 });
6003 cx.run_until_parked();
6004
6005 // active_entry should still be a draft on workspace_b (the active one).
6006 sidebar.read_with(cx, |sidebar, _| {
6007 assert!(
6008 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
6009 "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
6010 sidebar.active_entry,
6011 );
6012 });
6013
6014 // --- Scenario 2: archive the thread the user is looking at ---
6015
6016 // Create a thread in project-b (the active workspace) and verify it
6017 // becomes the active entry.
6018 let connection = acp_thread::StubAgentConnection::new();
6019 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6020 acp::ContentChunk::new("Done".into()),
6021 )]);
6022 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6023 agent_ui::test_support::send_message(&panel_b, cx);
6024 let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
6025 cx.run_until_parked();
6026
6027 sidebar.read_with(cx, |sidebar, _| {
6028 assert!(
6029 is_active_session(&sidebar, &thread_b),
6030 "expected active_entry to be Thread({thread_b}), got: {:?}",
6031 sidebar.active_entry,
6032 );
6033 });
6034
6035 sidebar.update_in(cx, |sidebar, window, cx| {
6036 sidebar.archive_thread(&thread_b, window, cx);
6037 });
6038 cx.run_until_parked();
6039
6040 // Archiving the active thread activates a draft on the same workspace
6041 // (via clear_base_view → activate_draft). The draft is not shown as a
6042 // sidebar row but active_entry tracks it.
6043 sidebar.read_with(cx, |sidebar, _| {
6044 assert!(
6045 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
6046 "expected draft on workspace_b after archiving active thread, got: {:?}",
6047 sidebar.active_entry,
6048 );
6049 });
6050}
6051
6052#[gpui::test]
6053async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
6054 // Full flow: create a thread, archive it (removing the workspace),
6055 // then unarchive. Only the restored thread should appear — no
6056 // leftover drafts or previously-serialized threads.
6057 let project = init_test_project_with_agent_panel("/my-project", cx).await;
6058 let (multi_workspace, cx) =
6059 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6060 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6061 cx.run_until_parked();
6062
6063 // Create a thread and send a message so it's a real thread.
6064 let connection = acp_thread::StubAgentConnection::new();
6065 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6066 acp::ContentChunk::new("Hello".into()),
6067 )]);
6068 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6069 agent_ui::test_support::send_message(&panel, cx);
6070 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6071 cx.run_until_parked();
6072
6073 // Archive it.
6074 sidebar.update_in(cx, |sidebar, window, cx| {
6075 sidebar.archive_thread(&session_id, window, cx);
6076 });
6077 cx.run_until_parked();
6078
6079 // Grab metadata for unarchive.
6080 let thread_id = cx.update(|_, cx| {
6081 ThreadMetadataStore::global(cx)
6082 .read(cx)
6083 .entries()
6084 .find(|e| e.session_id.as_ref() == Some(&session_id))
6085 .map(|e| e.thread_id)
6086 .expect("thread should exist")
6087 });
6088 let metadata = cx.update(|_, cx| {
6089 ThreadMetadataStore::global(cx)
6090 .read(cx)
6091 .entry(thread_id)
6092 .cloned()
6093 .expect("metadata should exist")
6094 });
6095
6096 // Unarchive it — the draft should be replaced by the restored thread.
6097 sidebar.update_in(cx, |sidebar, window, cx| {
6098 sidebar.open_thread_from_archive(metadata, window, cx);
6099 });
6100 cx.run_until_parked();
6101
6102 // Only the unarchived thread should be visible — no drafts, no other threads.
6103 let entries = visible_entries_as_strings(&sidebar, cx);
6104 let thread_count = entries
6105 .iter()
6106 .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
6107 .count();
6108 assert_eq!(
6109 thread_count, 1,
6110 "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
6111 );
6112 assert!(
6113 !entries.iter().any(|e| e.contains("Draft")),
6114 "expected no drafts after restoring, got entries: {entries:?}"
6115 );
6116}
6117
6118#[gpui::test]
6119async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
6120 cx: &mut TestAppContext,
6121) {
6122 // When a thread is unarchived into a project group that has no open
6123 // workspace, the sidebar opens a new workspace and loads the thread.
6124 // No spurious draft should appear alongside the unarchived thread.
6125 agent_ui::test_support::init_test(cx);
6126 cx.update(|cx| {
6127 ThreadStore::init_global(cx);
6128 ThreadMetadataStore::init_global(cx);
6129 language_model::LanguageModelRegistry::test(cx);
6130 prompt_store::init(cx);
6131 });
6132
6133 let fs = FakeFs::new(cx.executor());
6134 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6135 .await;
6136 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6137 .await;
6138 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6139
6140 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6141 let (multi_workspace, cx) =
6142 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6143 let sidebar = setup_sidebar(&multi_workspace, cx);
6144 cx.run_until_parked();
6145
6146 // Save an archived thread whose folder_paths point to project-b,
6147 // which has no open workspace.
6148 let session_id = acp::SessionId::new(Arc::from("archived-thread"));
6149 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6150 let thread_id = ThreadId::new();
6151 cx.update(|_, cx| {
6152 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6153 store.save(
6154 ThreadMetadata {
6155 thread_id,
6156 session_id: Some(session_id.clone()),
6157 agent_id: agent::ZED_AGENT_ID.clone(),
6158 title: Some("Unarchived Thread".into()),
6159 updated_at: Utc::now(),
6160 created_at: None,
6161 interacted_at: None,
6162 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6163 archived: true,
6164 remote_connection: None,
6165 },
6166 cx,
6167 )
6168 });
6169 });
6170 cx.run_until_parked();
6171
6172 // Verify no workspace for project-b exists yet.
6173 assert_eq!(
6174 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6175 1,
6176 "should start with only the project-a workspace"
6177 );
6178
6179 // Un-archive the thread — should open project-b workspace and load it.
6180 let metadata = cx.update(|_, cx| {
6181 ThreadMetadataStore::global(cx)
6182 .read(cx)
6183 .entry(thread_id)
6184 .cloned()
6185 .expect("metadata should exist")
6186 });
6187
6188 sidebar.update_in(cx, |sidebar, window, cx| {
6189 sidebar.open_thread_from_archive(metadata, window, cx);
6190 });
6191 cx.run_until_parked();
6192
6193 // A second workspace should have been created for project-b.
6194 assert_eq!(
6195 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6196 2,
6197 "should have opened a workspace for the unarchived thread"
6198 );
6199
6200 // The sidebar should show the unarchived thread without a spurious draft
6201 // in the project-b group.
6202 let entries = visible_entries_as_strings(&sidebar, cx);
6203 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6204 // project-a gets a draft (it's the active workspace with no threads),
6205 // but project-b should NOT have one — only the unarchived thread.
6206 assert!(
6207 draft_count <= 1,
6208 "expected at most one draft (for project-a), got entries: {entries:?}"
6209 );
6210 assert!(
6211 entries.iter().any(|e| e.contains("Unarchived Thread")),
6212 "expected unarchived thread to appear, got entries: {entries:?}"
6213 );
6214}
6215
6216#[gpui::test]
6217async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
6218 cx: &mut TestAppContext,
6219) {
6220 agent_ui::test_support::init_test(cx);
6221 cx.update(|cx| {
6222 ThreadStore::init_global(cx);
6223 ThreadMetadataStore::init_global(cx);
6224 language_model::LanguageModelRegistry::test(cx);
6225 prompt_store::init(cx);
6226 });
6227
6228 let fs = FakeFs::new(cx.executor());
6229 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6230 .await;
6231 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6232 .await;
6233 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6234
6235 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6236 let (multi_workspace, cx) =
6237 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6238 let sidebar = setup_sidebar(&multi_workspace, cx);
6239 cx.run_until_parked();
6240
6241 let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
6242 let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
6243 let original_thread_id = ThreadId::new();
6244 cx.update(|_, cx| {
6245 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6246 store.save(
6247 ThreadMetadata {
6248 thread_id: original_thread_id,
6249 session_id: Some(session_id.clone()),
6250 agent_id: agent::ZED_AGENT_ID.clone(),
6251 title: Some("Unarchived Thread".into()),
6252 updated_at: Utc::now(),
6253 created_at: None,
6254 interacted_at: None,
6255 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6256 archived: true,
6257 remote_connection: None,
6258 },
6259 cx,
6260 )
6261 });
6262 });
6263 cx.run_until_parked();
6264
6265 let metadata = cx.update(|_, cx| {
6266 ThreadMetadataStore::global(cx)
6267 .read(cx)
6268 .entry(original_thread_id)
6269 .cloned()
6270 .expect("metadata should exist before unarchive")
6271 });
6272
6273 sidebar.update_in(cx, |sidebar, window, cx| {
6274 sidebar.open_thread_from_archive(metadata, window, cx);
6275 });
6276
6277 cx.run_until_parked();
6278
6279 assert_eq!(
6280 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6281 2,
6282 "expected unarchive to open the target workspace"
6283 );
6284
6285 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6286 mw.workspaces()
6287 .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
6288 .cloned()
6289 .expect("expected restored workspace for unarchived thread")
6290 });
6291 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6292 workspace
6293 .panel::<AgentPanel>(cx)
6294 .expect("expected unarchive to install an agent panel in the new workspace")
6295 });
6296
6297 let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6298 assert_eq!(
6299 restored_thread_id,
6300 Some(original_thread_id),
6301 "expected the new workspace's agent panel to target the restored archived thread id"
6302 );
6303
6304 let session_entries = cx.update(|_, cx| {
6305 ThreadMetadataStore::global(cx)
6306 .read(cx)
6307 .entries()
6308 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6309 .cloned()
6310 .collect::<Vec<_>>()
6311 });
6312 assert_eq!(
6313 session_entries.len(),
6314 1,
6315 "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
6316 );
6317 assert_eq!(
6318 session_entries[0].thread_id, original_thread_id,
6319 "expected restore into a new workspace to reuse the original thread id"
6320 );
6321 assert!(
6322 !session_entries[0].archived,
6323 "expected restored thread metadata to be unarchived, got: {:?}",
6324 session_entries[0]
6325 );
6326
6327 let mapped_thread_id = cx.update(|_, cx| {
6328 ThreadMetadataStore::global(cx)
6329 .read(cx)
6330 .entries()
6331 .find(|e| e.session_id.as_ref() == Some(&session_id))
6332 .map(|e| e.thread_id)
6333 });
6334 assert_eq!(
6335 mapped_thread_id,
6336 Some(original_thread_id),
6337 "expected session mapping to remain stable after opening the new workspace"
6338 );
6339
6340 let entries = visible_entries_as_strings(&sidebar, cx);
6341 let real_thread_rows = entries
6342 .iter()
6343 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6344 .filter(|entry| !entry.contains("Draft"))
6345 .count();
6346 assert_eq!(
6347 real_thread_rows, 1,
6348 "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
6349 );
6350 assert!(
6351 entries
6352 .iter()
6353 .any(|entry| entry.contains("Unarchived Thread")),
6354 "expected restored thread row to be visible, got entries: {entries:?}"
6355 );
6356}
6357
6358#[gpui::test]
6359async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
6360 // When a workspace already exists with an empty draft and a thread
6361 // is unarchived into it, the draft should be replaced — not kept
6362 // alongside the loaded thread.
6363 agent_ui::test_support::init_test(cx);
6364 cx.update(|cx| {
6365 ThreadStore::init_global(cx);
6366 ThreadMetadataStore::init_global(cx);
6367 language_model::LanguageModelRegistry::test(cx);
6368 prompt_store::init(cx);
6369 });
6370
6371 let fs = FakeFs::new(cx.executor());
6372 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6373 .await;
6374 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6375
6376 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6377 let (multi_workspace, cx) =
6378 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6379 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6380 cx.run_until_parked();
6381
6382 // Create a thread and send a message so it's no longer a draft.
6383 let connection = acp_thread::StubAgentConnection::new();
6384 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6385 acp::ContentChunk::new("Done".into()),
6386 )]);
6387 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6388 agent_ui::test_support::send_message(&panel, cx);
6389 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6390 cx.run_until_parked();
6391
6392 // Archive the thread — the group is left empty (no draft created).
6393 sidebar.update_in(cx, |sidebar, window, cx| {
6394 sidebar.archive_thread(&session_id, window, cx);
6395 });
6396 cx.run_until_parked();
6397
6398 // Un-archive the thread.
6399 let thread_id = cx.update(|_, cx| {
6400 ThreadMetadataStore::global(cx)
6401 .read(cx)
6402 .entries()
6403 .find(|e| e.session_id.as_ref() == Some(&session_id))
6404 .map(|e| e.thread_id)
6405 .expect("thread should exist in store")
6406 });
6407 let metadata = cx.update(|_, cx| {
6408 ThreadMetadataStore::global(cx)
6409 .read(cx)
6410 .entry(thread_id)
6411 .cloned()
6412 .expect("metadata should exist")
6413 });
6414
6415 sidebar.update_in(cx, |sidebar, window, cx| {
6416 sidebar.open_thread_from_archive(metadata, window, cx);
6417 });
6418 cx.run_until_parked();
6419
6420 // The draft should be gone — only the unarchived thread remains.
6421 let entries = visible_entries_as_strings(&sidebar, cx);
6422 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6423 assert_eq!(
6424 draft_count, 0,
6425 "expected no drafts after unarchiving, got entries: {entries:?}"
6426 );
6427}
6428
6429#[gpui::test]
6430async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
6431 cx: &mut TestAppContext,
6432) {
6433 agent_ui::test_support::init_test(cx);
6434 cx.update(|cx| {
6435 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6436 ThreadStore::init_global(cx);
6437 ThreadMetadataStore::init_global(cx);
6438 language_model::LanguageModelRegistry::test(cx);
6439 prompt_store::init(cx);
6440 });
6441
6442 let fs = FakeFs::new(cx.executor());
6443 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6444 .await;
6445 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6446 .await;
6447 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6448
6449 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6450 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6451
6452 let (multi_workspace, cx) =
6453 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6454 let sidebar = setup_sidebar(&multi_workspace, cx);
6455
6456 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6457 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6458 mw.test_add_workspace(project_b.clone(), window, cx)
6459 });
6460 let _panel_b = add_agent_panel(&workspace_b, cx);
6461 cx.run_until_parked();
6462
6463 multi_workspace.update_in(cx, |mw, window, cx| {
6464 mw.activate(workspace_a.clone(), None, window, cx);
6465 });
6466 cx.run_until_parked();
6467
6468 let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
6469 let thread_id = ThreadId::new();
6470 cx.update(|_, cx| {
6471 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6472 store.save(
6473 ThreadMetadata {
6474 thread_id,
6475 session_id: Some(session_id.clone()),
6476 agent_id: agent::ZED_AGENT_ID.clone(),
6477 title: Some("Restored In Inactive Workspace".into()),
6478 updated_at: Utc::now(),
6479 created_at: None,
6480 interacted_at: None,
6481 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
6482 PathBuf::from("/project-b"),
6483 ])),
6484 archived: true,
6485 remote_connection: None,
6486 },
6487 cx,
6488 )
6489 });
6490 });
6491 cx.run_until_parked();
6492
6493 let metadata = cx.update(|_, cx| {
6494 ThreadMetadataStore::global(cx)
6495 .read(cx)
6496 .entry(thread_id)
6497 .cloned()
6498 .expect("archived metadata should exist before restore")
6499 });
6500
6501 sidebar.update_in(cx, |sidebar, window, cx| {
6502 sidebar.open_thread_from_archive(metadata, window, cx);
6503 });
6504
6505 let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
6506 workspace.panel::<AgentPanel>(cx).expect(
6507 "target workspace should still have an agent panel immediately after activation",
6508 )
6509 });
6510 let immediate_active_thread_id =
6511 panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6512
6513 cx.run_until_parked();
6514
6515 sidebar.read_with(cx, |sidebar, _cx| {
6516 assert_active_thread(
6517 sidebar,
6518 &session_id,
6519 "unarchiving into an inactive existing workspace should end on the restored thread",
6520 );
6521 });
6522
6523 let panel_b = workspace_b.read_with(cx, |workspace, cx| {
6524 workspace
6525 .panel::<AgentPanel>(cx)
6526 .expect("target workspace should still have an agent panel")
6527 });
6528 assert_eq!(
6529 panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6530 Some(thread_id),
6531 "expected target panel to activate the restored thread id"
6532 );
6533 assert!(
6534 immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
6535 "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
6536 );
6537
6538 let entries = visible_entries_as_strings(&sidebar, cx);
6539 let target_rows: Vec<_> = entries
6540 .iter()
6541 .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
6542 .cloned()
6543 .collect();
6544 assert_eq!(
6545 target_rows.len(),
6546 1,
6547 "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
6548 );
6549 assert!(
6550 target_rows[0].contains("Restored In Inactive Workspace"),
6551 "expected the remaining row to be the restored thread, got entries: {entries:?}"
6552 );
6553 assert!(
6554 !target_rows[0].contains("Draft"),
6555 "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
6556 );
6557}
6558
6559#[gpui::test]
6560async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
6561 cx: &mut TestAppContext,
6562) {
6563 agent_ui::test_support::init_test(cx);
6564 cx.update(|cx| {
6565 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6566 ThreadStore::init_global(cx);
6567 ThreadMetadataStore::init_global(cx);
6568 language_model::LanguageModelRegistry::test(cx);
6569 prompt_store::init(cx);
6570 });
6571
6572 let fs = FakeFs::new(cx.executor());
6573 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6574 .await;
6575 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6576 .await;
6577 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6578
6579 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6580 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6581
6582 let (multi_workspace, cx) =
6583 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6584 let sidebar = setup_sidebar(&multi_workspace, cx);
6585
6586 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6587 mw.test_add_workspace(project_b.clone(), window, cx)
6588 });
6589 let panel_b = add_agent_panel(&workspace_b, cx);
6590 cx.run_until_parked();
6591
6592 let connection = acp_thread::StubAgentConnection::new();
6593 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6594 acp::ContentChunk::new("Done".into()),
6595 )]);
6596 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6597 agent_ui::test_support::send_message(&panel_b, cx);
6598 let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
6599 save_test_thread_metadata(&session_id, &project_b, cx).await;
6600 cx.run_until_parked();
6601
6602 sidebar.update_in(cx, |sidebar, window, cx| {
6603 sidebar.archive_thread(&session_id, window, cx);
6604 });
6605
6606 cx.run_until_parked();
6607
6608 let archived_metadata = cx.update(|_, cx| {
6609 let store = ThreadMetadataStore::global(cx).read(cx);
6610 let thread_id = store
6611 .entries()
6612 .find(|e| e.session_id.as_ref() == Some(&session_id))
6613 .map(|e| e.thread_id)
6614 .expect("archived thread should still exist in metadata store");
6615 let metadata = store
6616 .entry(thread_id)
6617 .cloned()
6618 .expect("archived metadata should still exist after archive");
6619 assert!(
6620 metadata.archived,
6621 "thread should be archived before project removal"
6622 );
6623 metadata
6624 });
6625
6626 let group_key_b =
6627 project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
6628 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
6629 mw.remove_project_group(&group_key_b, window, cx)
6630 });
6631 remove_task
6632 .await
6633 .expect("remove project group task should complete");
6634 cx.run_until_parked();
6635
6636 assert_eq!(
6637 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6638 1,
6639 "removing the archived thread's parent project group should remove its workspace"
6640 );
6641
6642 sidebar.update_in(cx, |sidebar, window, cx| {
6643 sidebar.open_thread_from_archive(archived_metadata.clone(), window, cx);
6644 });
6645 cx.run_until_parked();
6646
6647 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6648 mw.workspaces()
6649 .find(|workspace| {
6650 PathList::new(&workspace.read(cx).root_paths(cx))
6651 == PathList::new(&[PathBuf::from("/project-b")])
6652 })
6653 .cloned()
6654 .expect("expected unarchive to recreate the removed project workspace")
6655 });
6656 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6657 workspace
6658 .panel::<AgentPanel>(cx)
6659 .expect("expected restored workspace to bootstrap an agent panel")
6660 });
6661
6662 let restored_thread_id = cx.update(|_, cx| {
6663 ThreadMetadataStore::global(cx)
6664 .read(cx)
6665 .entries()
6666 .find(|e| e.session_id.as_ref() == Some(&session_id))
6667 .map(|e| e.thread_id)
6668 .expect("session should still map to restored thread id")
6669 });
6670 assert_eq!(
6671 restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6672 Some(restored_thread_id),
6673 "expected unarchive after project removal to activate the restored real thread"
6674 );
6675
6676 sidebar.read_with(cx, |sidebar, _cx| {
6677 assert_active_thread(
6678 sidebar,
6679 &session_id,
6680 "expected sidebar active entry to track the restored thread after project removal",
6681 );
6682 });
6683
6684 let entries = visible_entries_as_strings(&sidebar, cx);
6685 let restored_title = archived_metadata.display_title().to_string();
6686 let matching_rows: Vec<_> = entries
6687 .iter()
6688 .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
6689 .cloned()
6690 .collect();
6691 assert_eq!(
6692 matching_rows.len(),
6693 1,
6694 "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
6695 );
6696 assert!(
6697 !matching_rows[0].contains("Draft"),
6698 "expected no draft row after unarchive following project removal, got entries: {entries:?}"
6699 );
6700}
6701
6702#[gpui::test]
6703async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
6704 agent_ui::test_support::init_test(cx);
6705 cx.update(|cx| {
6706 ThreadStore::init_global(cx);
6707 ThreadMetadataStore::init_global(cx);
6708 language_model::LanguageModelRegistry::test(cx);
6709 prompt_store::init(cx);
6710 });
6711
6712 let fs = FakeFs::new(cx.executor());
6713 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6714 .await;
6715 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6716
6717 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6718 let (multi_workspace, cx) =
6719 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6720 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6721 cx.run_until_parked();
6722
6723 let connection = acp_thread::StubAgentConnection::new();
6724 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6725 acp::ContentChunk::new("Done".into()),
6726 )]);
6727 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6728 agent_ui::test_support::send_message(&panel, cx);
6729 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6730 cx.run_until_parked();
6731
6732 let original_thread_id = cx.update(|_, cx| {
6733 ThreadMetadataStore::global(cx)
6734 .read(cx)
6735 .entries()
6736 .find(|e| e.session_id.as_ref() == Some(&session_id))
6737 .map(|e| e.thread_id)
6738 .expect("thread should exist in store before archiving")
6739 });
6740
6741 sidebar.update_in(cx, |sidebar, window, cx| {
6742 sidebar.archive_thread(&session_id, window, cx);
6743 });
6744 cx.run_until_parked();
6745
6746 let metadata = cx.update(|_, cx| {
6747 ThreadMetadataStore::global(cx)
6748 .read(cx)
6749 .entry(original_thread_id)
6750 .cloned()
6751 .expect("metadata should exist after archiving")
6752 });
6753
6754 sidebar.update_in(cx, |sidebar, window, cx| {
6755 sidebar.open_thread_from_archive(metadata, window, cx);
6756 });
6757 cx.run_until_parked();
6758
6759 let session_entries = cx.update(|_, cx| {
6760 ThreadMetadataStore::global(cx)
6761 .read(cx)
6762 .entries()
6763 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6764 .cloned()
6765 .collect::<Vec<_>>()
6766 });
6767
6768 assert_eq!(
6769 session_entries.len(),
6770 1,
6771 "expected exactly one metadata row for the restored session, got: {session_entries:?}"
6772 );
6773 assert_eq!(
6774 session_entries[0].thread_id, original_thread_id,
6775 "expected unarchive to reuse the original thread id instead of creating a duplicate row"
6776 );
6777 assert!(
6778 session_entries[0].session_id.is_some(),
6779 "expected restored metadata to be a real thread, got: {:?}",
6780 session_entries[0]
6781 );
6782
6783 let entries = visible_entries_as_strings(&sidebar, cx);
6784 let real_thread_rows = entries
6785 .iter()
6786 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6787 .filter(|entry| !entry.contains("Draft"))
6788 .count();
6789 assert_eq!(
6790 real_thread_rows, 1,
6791 "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
6792 );
6793 assert!(
6794 !entries.iter().any(|entry| entry.contains("Draft")),
6795 "expected no draft rows after restoring, got entries: {entries:?}"
6796 );
6797}
6798
6799#[gpui::test]
6800async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
6801 cx: &mut TestAppContext,
6802) {
6803 // When a thread is archived while the user is in a different workspace,
6804 // clear_base_view creates a draft on the archived workspace's panel.
6805 // Switching back to that workspace shows the draft as active_entry.
6806 agent_ui::test_support::init_test(cx);
6807 cx.update(|cx| {
6808 ThreadStore::init_global(cx);
6809 ThreadMetadataStore::init_global(cx);
6810 language_model::LanguageModelRegistry::test(cx);
6811 prompt_store::init(cx);
6812 });
6813
6814 let fs = FakeFs::new(cx.executor());
6815 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6816 .await;
6817 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6818 .await;
6819 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6820
6821 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6822 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6823
6824 let (multi_workspace, cx) =
6825 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6826 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6827
6828 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6829 mw.test_add_workspace(project_b.clone(), window, cx)
6830 });
6831 let _panel_b = add_agent_panel(&workspace_b, cx);
6832 cx.run_until_parked();
6833
6834 // Create a thread in project-a's panel (currently non-active).
6835 let connection = acp_thread::StubAgentConnection::new();
6836 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6837 acp::ContentChunk::new("Done".into()),
6838 )]);
6839 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
6840 agent_ui::test_support::send_message(&panel_a, cx);
6841 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
6842 cx.run_until_parked();
6843
6844 // Archive it while project-b is active.
6845 sidebar.update_in(cx, |sidebar, window, cx| {
6846 sidebar.archive_thread(&thread_a, window, cx);
6847 });
6848 cx.run_until_parked();
6849
6850 // Switch back to project-a. Its panel was cleared during archiving
6851 // (clear_base_view activated a draft), so active_entry should point
6852 // to the draft on workspace_a.
6853 let workspace_a =
6854 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
6855 multi_workspace.update_in(cx, |mw, window, cx| {
6856 mw.activate(workspace_a.clone(), None, window, cx);
6857 });
6858 cx.run_until_parked();
6859
6860 sidebar.update_in(cx, |sidebar, _window, cx| {
6861 sidebar.update_entries(cx);
6862 });
6863 cx.run_until_parked();
6864
6865 sidebar.read_with(cx, |sidebar, _| {
6866 assert_active_draft(
6867 sidebar,
6868 &workspace_a,
6869 "after switching to workspace with archived thread, active_entry should be the draft",
6870 );
6871 });
6872}
6873
6874#[gpui::test]
6875async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
6876 let project = init_test_project("/my-project", cx).await;
6877 let (multi_workspace, cx) =
6878 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6879 let sidebar = setup_sidebar(&multi_workspace, cx);
6880
6881 save_thread_metadata(
6882 acp::SessionId::new(Arc::from("visible-thread")),
6883 Some("Visible Thread".into()),
6884 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6885 None,
6886 None,
6887 &project,
6888 cx,
6889 );
6890
6891 let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
6892 save_thread_metadata(
6893 archived_thread_session_id.clone(),
6894 Some("Archived Thread".into()),
6895 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6896 None,
6897 None,
6898 &project,
6899 cx,
6900 );
6901
6902 cx.update(|_, cx| {
6903 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6904 let thread_id = store
6905 .entries()
6906 .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
6907 .map(|e| e.thread_id)
6908 .unwrap();
6909 store.archive(thread_id, None, cx)
6910 })
6911 });
6912 cx.run_until_parked();
6913
6914 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6915 cx.run_until_parked();
6916
6917 let entries = visible_entries_as_strings(&sidebar, cx);
6918 assert!(
6919 entries.iter().any(|e| e.contains("Visible Thread")),
6920 "expected visible thread in sidebar, got: {entries:?}"
6921 );
6922 assert!(
6923 !entries.iter().any(|e| e.contains("Archived Thread")),
6924 "expected archived thread to be hidden from sidebar, got: {entries:?}"
6925 );
6926
6927 cx.update(|_, cx| {
6928 let store = ThreadMetadataStore::global(cx);
6929 let all: Vec<_> = store.read(cx).entries().collect();
6930 assert_eq!(
6931 all.len(),
6932 2,
6933 "expected 2 total entries in the store, got: {}",
6934 all.len()
6935 );
6936
6937 let archived: Vec<_> = store.read(cx).archived_entries().collect();
6938 assert_eq!(archived.len(), 1);
6939 assert_eq!(
6940 archived[0].session_id.as_ref().unwrap().0.as_ref(),
6941 "archived-thread"
6942 );
6943 });
6944}
6945
6946#[gpui::test]
6947async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
6948 cx: &mut TestAppContext,
6949) {
6950 // When a linked worktree has a single thread and that thread is archived,
6951 // the sidebar must NOT create a new thread on the same worktree (which
6952 // would prevent the worktree from being cleaned up on disk). Instead,
6953 // archive_thread switches to a sibling thread on the main workspace (or
6954 // creates a draft there) before archiving the metadata.
6955 agent_ui::test_support::init_test(cx);
6956 cx.update(|cx| {
6957 ThreadStore::init_global(cx);
6958 ThreadMetadataStore::init_global(cx);
6959 language_model::LanguageModelRegistry::test(cx);
6960 prompt_store::init(cx);
6961 });
6962
6963 let fs = FakeFs::new(cx.executor());
6964
6965 fs.insert_tree(
6966 "/project",
6967 serde_json::json!({
6968 ".git": {},
6969 "src": {},
6970 }),
6971 )
6972 .await;
6973
6974 fs.add_linked_worktree_for_repo(
6975 Path::new("/project/.git"),
6976 false,
6977 git::repository::Worktree {
6978 path: std::path::PathBuf::from("/wt-ochre-drift"),
6979 ref_name: Some("refs/heads/ochre-drift".into()),
6980 sha: "aaa".into(),
6981 is_main: false,
6982 is_bare: false,
6983 },
6984 )
6985 .await;
6986
6987 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6988
6989 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6990 let worktree_project =
6991 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
6992
6993 main_project
6994 .update(cx, |p, cx| p.git_scans_complete(cx))
6995 .await;
6996 worktree_project
6997 .update(cx, |p, cx| p.git_scans_complete(cx))
6998 .await;
6999
7000 let (multi_workspace, cx) =
7001 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7002
7003 let sidebar = setup_sidebar(&multi_workspace, cx);
7004
7005 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7006 mw.test_add_workspace(worktree_project.clone(), window, cx)
7007 });
7008
7009 // Set up both workspaces with agent panels.
7010 let main_workspace =
7011 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7012 let _main_panel = add_agent_panel(&main_workspace, cx);
7013 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7014
7015 // Activate the linked worktree workspace so the sidebar tracks it.
7016 multi_workspace.update_in(cx, |mw, window, cx| {
7017 mw.activate(worktree_workspace.clone(), None, window, cx);
7018 });
7019
7020 // Open a thread in the linked worktree panel and send a message
7021 // so it becomes the active thread.
7022 let connection = StubAgentConnection::new();
7023 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7024 send_message(&worktree_panel, cx);
7025
7026 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7027
7028 // Give the thread a response chunk so it has content.
7029 cx.update(|_, cx| {
7030 connection.send_update(
7031 worktree_thread_id.clone(),
7032 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7033 cx,
7034 );
7035 });
7036
7037 // Save the worktree thread's metadata.
7038 save_thread_metadata(
7039 worktree_thread_id.clone(),
7040 Some("Ochre Drift Thread".into()),
7041 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7042 None,
7043 None,
7044 &worktree_project,
7045 cx,
7046 );
7047
7048 // Also save a thread on the main project so there's a sibling in the
7049 // group that can be selected after archiving.
7050 save_thread_metadata(
7051 acp::SessionId::new(Arc::from("main-project-thread")),
7052 Some("Main Project Thread".into()),
7053 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7054 None,
7055 None,
7056 &main_project,
7057 cx,
7058 );
7059
7060 cx.run_until_parked();
7061
7062 // Verify the linked worktree thread appears with its chip.
7063 // The live thread title comes from the message text ("Hello"), not
7064 // the metadata title we saved.
7065 let entries_before = visible_entries_as_strings(&sidebar, cx);
7066 assert!(
7067 entries_before
7068 .iter()
7069 .any(|s| s.contains("{wt-ochre-drift}")),
7070 "expected worktree thread with chip before archiving, got: {entries_before:?}"
7071 );
7072 assert!(
7073 entries_before
7074 .iter()
7075 .any(|s| s.contains("Main Project Thread")),
7076 "expected main project thread before archiving, got: {entries_before:?}"
7077 );
7078
7079 // Confirm the worktree thread is the active entry.
7080 sidebar.read_with(cx, |s, _| {
7081 assert_active_thread(
7082 s,
7083 &worktree_thread_id,
7084 "worktree thread should be active before archiving",
7085 );
7086 });
7087
7088 // Archive the worktree thread — it's the only thread using ochre-drift.
7089 sidebar.update_in(cx, |sidebar, window, cx| {
7090 sidebar.archive_thread(&worktree_thread_id, window, cx);
7091 });
7092
7093 cx.run_until_parked();
7094
7095 // The archived thread should no longer appear in the sidebar.
7096 let entries_after = visible_entries_as_strings(&sidebar, cx);
7097 assert!(
7098 !entries_after
7099 .iter()
7100 .any(|s| s.contains("Ochre Drift Thread")),
7101 "archived thread should be hidden, got: {entries_after:?}"
7102 );
7103
7104 // No "+ New Thread" entry should appear with the ochre-drift worktree
7105 // chip — that would keep the worktree alive and prevent cleanup.
7106 assert!(
7107 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7108 "no entry should reference the archived worktree, got: {entries_after:?}"
7109 );
7110
7111 // The main project thread should still be visible.
7112 assert!(
7113 entries_after
7114 .iter()
7115 .any(|s| s.contains("Main Project Thread")),
7116 "main project thread should still be visible, got: {entries_after:?}"
7117 );
7118}
7119
7120#[gpui::test]
7121async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
7122 cx: &mut TestAppContext,
7123) {
7124 // When a linked worktree thread is the ONLY thread in the project group
7125 // (no threads on the main repo either), archiving it should leave the
7126 // group empty with no active entry.
7127 agent_ui::test_support::init_test(cx);
7128 cx.update(|cx| {
7129 ThreadStore::init_global(cx);
7130 ThreadMetadataStore::init_global(cx);
7131 language_model::LanguageModelRegistry::test(cx);
7132 prompt_store::init(cx);
7133 });
7134
7135 let fs = FakeFs::new(cx.executor());
7136
7137 fs.insert_tree(
7138 "/project",
7139 serde_json::json!({
7140 ".git": {},
7141 "src": {},
7142 }),
7143 )
7144 .await;
7145
7146 fs.add_linked_worktree_for_repo(
7147 Path::new("/project/.git"),
7148 false,
7149 git::repository::Worktree {
7150 path: std::path::PathBuf::from("/wt-ochre-drift"),
7151 ref_name: Some("refs/heads/ochre-drift".into()),
7152 sha: "aaa".into(),
7153 is_main: false,
7154 is_bare: false,
7155 },
7156 )
7157 .await;
7158
7159 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7160
7161 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7162 let worktree_project =
7163 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7164
7165 main_project
7166 .update(cx, |p, cx| p.git_scans_complete(cx))
7167 .await;
7168 worktree_project
7169 .update(cx, |p, cx| p.git_scans_complete(cx))
7170 .await;
7171
7172 let (multi_workspace, cx) =
7173 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7174
7175 let sidebar = setup_sidebar(&multi_workspace, cx);
7176
7177 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7178 mw.test_add_workspace(worktree_project.clone(), window, cx)
7179 });
7180
7181 let main_workspace =
7182 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7183 let _main_panel = add_agent_panel(&main_workspace, cx);
7184 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7185
7186 // Activate the linked worktree workspace.
7187 multi_workspace.update_in(cx, |mw, window, cx| {
7188 mw.activate(worktree_workspace.clone(), None, window, cx);
7189 });
7190
7191 // Open a thread on the linked worktree — this is the ONLY thread.
7192 let connection = StubAgentConnection::new();
7193 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7194 send_message(&worktree_panel, cx);
7195
7196 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7197
7198 cx.update(|_, cx| {
7199 connection.send_update(
7200 worktree_thread_id.clone(),
7201 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7202 cx,
7203 );
7204 });
7205
7206 save_thread_metadata(
7207 worktree_thread_id.clone(),
7208 Some("Ochre Drift Thread".into()),
7209 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7210 None,
7211 None,
7212 &worktree_project,
7213 cx,
7214 );
7215
7216 cx.run_until_parked();
7217
7218 // Archive it — there are no other threads in the group.
7219 sidebar.update_in(cx, |sidebar, window, cx| {
7220 sidebar.archive_thread(&worktree_thread_id, window, cx);
7221 });
7222
7223 cx.run_until_parked();
7224
7225 let entries_after = visible_entries_as_strings(&sidebar, cx);
7226
7227 // No entry should reference the linked worktree.
7228 assert!(
7229 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7230 "no entry should reference the archived worktree, got: {entries_after:?}"
7231 );
7232
7233 // The active entry should be None — no draft is created.
7234 sidebar.read_with(cx, |s, _| {
7235 assert!(
7236 s.active_entry.is_none(),
7237 "expected no active entry after archiving the last thread, got: {:?}",
7238 s.active_entry,
7239 );
7240 });
7241}
7242
7243#[gpui::test]
7244async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
7245 cx: &mut TestAppContext,
7246) {
7247 // When an archived thread belongs to a linked worktree whose main repo is
7248 // already open, unarchiving should reopen the linked workspace into the
7249 // same project group and show only the restored real thread row.
7250 agent_ui::test_support::init_test(cx);
7251 cx.update(|cx| {
7252 ThreadStore::init_global(cx);
7253 ThreadMetadataStore::init_global(cx);
7254 language_model::LanguageModelRegistry::test(cx);
7255 prompt_store::init(cx);
7256 });
7257
7258 let fs = FakeFs::new(cx.executor());
7259
7260 fs.insert_tree(
7261 "/project",
7262 serde_json::json!({
7263 ".git": {},
7264 "src": {},
7265 }),
7266 )
7267 .await;
7268
7269 fs.insert_tree(
7270 "/wt-ochre-drift",
7271 serde_json::json!({
7272 ".git": "gitdir: /project/.git/worktrees/ochre-drift",
7273 "src": {},
7274 }),
7275 )
7276 .await;
7277
7278 fs.add_linked_worktree_for_repo(
7279 Path::new("/project/.git"),
7280 false,
7281 git::repository::Worktree {
7282 path: std::path::PathBuf::from("/wt-ochre-drift"),
7283 ref_name: Some("refs/heads/ochre-drift".into()),
7284 sha: "aaa".into(),
7285 is_main: false,
7286 is_bare: false,
7287 },
7288 )
7289 .await;
7290
7291 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7292
7293 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7294 let worktree_project =
7295 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7296
7297 main_project
7298 .update(cx, |p, cx| p.git_scans_complete(cx))
7299 .await;
7300 worktree_project
7301 .update(cx, |p, cx| p.git_scans_complete(cx))
7302 .await;
7303
7304 let (multi_workspace, cx) =
7305 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7306
7307 let sidebar = setup_sidebar(&multi_workspace, cx);
7308 let main_workspace =
7309 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7310 let _main_panel = add_agent_panel(&main_workspace, cx);
7311 cx.run_until_parked();
7312
7313 let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
7314 let original_thread_id = ThreadId::new();
7315 let main_paths = PathList::new(&[PathBuf::from("/project")]);
7316 let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
7317
7318 cx.update(|_, cx| {
7319 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7320 store.save(
7321 ThreadMetadata {
7322 thread_id: original_thread_id,
7323 session_id: Some(session_id.clone()),
7324 agent_id: agent::ZED_AGENT_ID.clone(),
7325 title: Some("Unarchived Linked Thread".into()),
7326 updated_at: Utc::now(),
7327 created_at: None,
7328 interacted_at: None,
7329 worktree_paths: WorktreePaths::from_path_lists(
7330 main_paths.clone(),
7331 folder_paths.clone(),
7332 )
7333 .expect("main and folder paths should be well-formed"),
7334 archived: true,
7335 remote_connection: None,
7336 },
7337 cx,
7338 )
7339 });
7340 });
7341 cx.run_until_parked();
7342
7343 let metadata = cx.update(|_, cx| {
7344 ThreadMetadataStore::global(cx)
7345 .read(cx)
7346 .entry(original_thread_id)
7347 .cloned()
7348 .expect("archived linked-worktree metadata should exist before restore")
7349 });
7350
7351 sidebar.update_in(cx, |sidebar, window, cx| {
7352 sidebar.open_thread_from_archive(metadata, window, cx);
7353 });
7354 cx.run_until_parked();
7355
7356 assert_eq!(
7357 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7358 2,
7359 "expected unarchive to open the linked worktree workspace into the project group"
7360 );
7361
7362 let session_entries = cx.update(|_, cx| {
7363 ThreadMetadataStore::global(cx)
7364 .read(cx)
7365 .entries()
7366 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7367 .cloned()
7368 .collect::<Vec<_>>()
7369 });
7370 assert_eq!(
7371 session_entries.len(),
7372 1,
7373 "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
7374 );
7375 assert_eq!(
7376 session_entries[0].thread_id, original_thread_id,
7377 "expected unarchive to reuse the original linked worktree thread id"
7378 );
7379 assert!(
7380 !session_entries[0].archived,
7381 "expected restored linked worktree metadata to be unarchived, got: {:?}",
7382 session_entries[0]
7383 );
7384
7385 let assert_no_extra_rows = |entries: &[String]| {
7386 let real_thread_rows = entries
7387 .iter()
7388 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7389 .filter(|entry| !entry.contains("Draft"))
7390 .count();
7391 assert_eq!(
7392 real_thread_rows, 1,
7393 "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
7394 );
7395 assert!(
7396 !entries.iter().any(|entry| entry.contains("Draft")),
7397 "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
7398 );
7399 assert!(
7400 !entries
7401 .iter()
7402 .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
7403 "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
7404 );
7405 assert!(
7406 entries
7407 .iter()
7408 .any(|entry| entry.contains("Unarchived Linked Thread")),
7409 "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
7410 );
7411 };
7412
7413 let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
7414 assert_no_extra_rows(&entries_after_restore);
7415
7416 // The reported bug may only appear after an extra scheduling turn.
7417 cx.run_until_parked();
7418
7419 let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
7420 assert_no_extra_rows(&entries_after_extra_turns);
7421}
7422
7423#[gpui::test]
7424async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
7425 // When a linked worktree thread is archived but the group has other
7426 // threads (e.g. on the main project), archive_thread should select
7427 // the nearest sibling.
7428 agent_ui::test_support::init_test(cx);
7429 cx.update(|cx| {
7430 ThreadStore::init_global(cx);
7431 ThreadMetadataStore::init_global(cx);
7432 language_model::LanguageModelRegistry::test(cx);
7433 prompt_store::init(cx);
7434 });
7435
7436 let fs = FakeFs::new(cx.executor());
7437
7438 fs.insert_tree(
7439 "/project",
7440 serde_json::json!({
7441 ".git": {},
7442 "src": {},
7443 }),
7444 )
7445 .await;
7446
7447 fs.add_linked_worktree_for_repo(
7448 Path::new("/project/.git"),
7449 false,
7450 git::repository::Worktree {
7451 path: std::path::PathBuf::from("/wt-ochre-drift"),
7452 ref_name: Some("refs/heads/ochre-drift".into()),
7453 sha: "aaa".into(),
7454 is_main: false,
7455 is_bare: false,
7456 },
7457 )
7458 .await;
7459
7460 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7461
7462 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7463 let worktree_project =
7464 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7465
7466 main_project
7467 .update(cx, |p, cx| p.git_scans_complete(cx))
7468 .await;
7469 worktree_project
7470 .update(cx, |p, cx| p.git_scans_complete(cx))
7471 .await;
7472
7473 let (multi_workspace, cx) =
7474 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7475
7476 let sidebar = setup_sidebar(&multi_workspace, cx);
7477
7478 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7479 mw.test_add_workspace(worktree_project.clone(), window, cx)
7480 });
7481
7482 let main_workspace =
7483 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7484 let _main_panel = add_agent_panel(&main_workspace, cx);
7485 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7486
7487 // Activate the linked worktree workspace.
7488 multi_workspace.update_in(cx, |mw, window, cx| {
7489 mw.activate(worktree_workspace.clone(), None, window, cx);
7490 });
7491
7492 // Open a thread on the linked worktree.
7493 let connection = StubAgentConnection::new();
7494 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7495 send_message(&worktree_panel, cx);
7496
7497 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7498
7499 cx.update(|_, cx| {
7500 connection.send_update(
7501 worktree_thread_id.clone(),
7502 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7503 cx,
7504 );
7505 });
7506
7507 save_thread_metadata(
7508 worktree_thread_id.clone(),
7509 Some("Ochre Drift Thread".into()),
7510 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7511 None,
7512 None,
7513 &worktree_project,
7514 cx,
7515 );
7516
7517 // Save a sibling thread on the main project.
7518 let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
7519 save_thread_metadata(
7520 main_thread_id,
7521 Some("Main Project Thread".into()),
7522 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7523 None,
7524 None,
7525 &main_project,
7526 cx,
7527 );
7528
7529 cx.run_until_parked();
7530
7531 // Confirm the worktree thread is active.
7532 sidebar.read_with(cx, |s, _| {
7533 assert_active_thread(
7534 s,
7535 &worktree_thread_id,
7536 "worktree thread should be active before archiving",
7537 );
7538 });
7539
7540 // Archive the worktree thread.
7541 sidebar.update_in(cx, |sidebar, window, cx| {
7542 sidebar.archive_thread(&worktree_thread_id, window, cx);
7543 });
7544
7545 cx.run_until_parked();
7546
7547 // The worktree workspace was removed and a draft was created on the
7548 // main workspace. No entry should reference the linked worktree.
7549 let entries_after = visible_entries_as_strings(&sidebar, cx);
7550 assert!(
7551 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7552 "no entry should reference the archived worktree, got: {entries_after:?}"
7553 );
7554
7555 // The main project thread should still be visible.
7556 assert!(
7557 entries_after
7558 .iter()
7559 .any(|s| s.contains("Main Project Thread")),
7560 "main project thread should still be visible, got: {entries_after:?}"
7561 );
7562}
7563
7564// TODO: Restore this test once linked worktree draft entries are re-implemented.
7565// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
7566#[gpui::test]
7567#[ignore = "linked worktree draft entries not yet implemented"]
7568async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
7569 init_test(cx);
7570 let fs = FakeFs::new(cx.executor());
7571
7572 fs.insert_tree(
7573 "/project",
7574 serde_json::json!({
7575 ".git": {
7576 "worktrees": {
7577 "feature-a": {
7578 "commondir": "../../",
7579 "HEAD": "ref: refs/heads/feature-a",
7580 },
7581 },
7582 },
7583 "src": {},
7584 }),
7585 )
7586 .await;
7587
7588 fs.insert_tree(
7589 "/wt-feature-a",
7590 serde_json::json!({
7591 ".git": "gitdir: /project/.git/worktrees/feature-a",
7592 "src": {},
7593 }),
7594 )
7595 .await;
7596
7597 fs.add_linked_worktree_for_repo(
7598 Path::new("/project/.git"),
7599 false,
7600 git::repository::Worktree {
7601 path: PathBuf::from("/wt-feature-a"),
7602 ref_name: Some("refs/heads/feature-a".into()),
7603 sha: "aaa".into(),
7604 is_main: false,
7605 is_bare: false,
7606 },
7607 )
7608 .await;
7609
7610 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7611
7612 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7613 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7614
7615 main_project
7616 .update(cx, |p, cx| p.git_scans_complete(cx))
7617 .await;
7618 worktree_project
7619 .update(cx, |p, cx| p.git_scans_complete(cx))
7620 .await;
7621
7622 let (multi_workspace, cx) =
7623 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7624 let sidebar = setup_sidebar(&multi_workspace, cx);
7625
7626 // Open the linked worktree as a separate workspace (simulates cmd-o).
7627 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7628 mw.test_add_workspace(worktree_project.clone(), window, cx)
7629 });
7630 add_agent_panel(&worktree_workspace, cx);
7631 cx.run_until_parked();
7632
7633 // Explicitly create a draft thread from the linked worktree workspace.
7634 // Auto-created drafts use the group's first workspace (the main one),
7635 // so a user-created draft is needed to make the linked worktree reachable.
7636 sidebar.update_in(cx, |sidebar, window, cx| {
7637 sidebar.create_new_thread(&worktree_workspace, window, cx);
7638 });
7639 cx.run_until_parked();
7640
7641 // Switch back to the main workspace.
7642 multi_workspace.update_in(cx, |mw, window, cx| {
7643 let main_ws = mw.workspaces().next().unwrap().clone();
7644 mw.activate(main_ws, None, window, cx);
7645 });
7646 cx.run_until_parked();
7647
7648 sidebar.update_in(cx, |sidebar, _window, cx| {
7649 sidebar.update_entries(cx);
7650 });
7651 cx.run_until_parked();
7652
7653 // The linked worktree workspace must be reachable from some sidebar entry.
7654 let worktree_ws_id = worktree_workspace.entity_id();
7655 let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
7656 let mw = multi_workspace.read(cx);
7657 sidebar
7658 .contents
7659 .entries
7660 .iter()
7661 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
7662 .map(|ws| ws.entity_id())
7663 .collect()
7664 });
7665 assert!(
7666 reachable.contains(&worktree_ws_id),
7667 "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
7668 );
7669
7670 // Find the draft Thread entry whose workspace is the linked worktree.
7671 let _ = (worktree_ws_id, sidebar, multi_workspace);
7672 // todo("re-implement once linked worktree draft entries exist");
7673}
7674
7675#[gpui::test]
7676async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
7677 // When only a linked worktree workspace is open (not the main repo),
7678 // threads saved against the main repo should still appear in the sidebar.
7679 init_test(cx);
7680 let fs = FakeFs::new(cx.executor());
7681
7682 // Create the main repo with a linked worktree.
7683 fs.insert_tree(
7684 "/project",
7685 serde_json::json!({
7686 ".git": {
7687 "worktrees": {
7688 "feature-a": {
7689 "commondir": "../../",
7690 "HEAD": "ref: refs/heads/feature-a",
7691 },
7692 },
7693 },
7694 "src": {},
7695 }),
7696 )
7697 .await;
7698
7699 fs.insert_tree(
7700 "/wt-feature-a",
7701 serde_json::json!({
7702 ".git": "gitdir: /project/.git/worktrees/feature-a",
7703 "src": {},
7704 }),
7705 )
7706 .await;
7707
7708 fs.add_linked_worktree_for_repo(
7709 std::path::Path::new("/project/.git"),
7710 false,
7711 git::repository::Worktree {
7712 path: std::path::PathBuf::from("/wt-feature-a"),
7713 ref_name: Some("refs/heads/feature-a".into()),
7714 sha: "abc".into(),
7715 is_main: false,
7716 is_bare: false,
7717 },
7718 )
7719 .await;
7720
7721 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7722
7723 // Only open the linked worktree as a workspace — NOT the main repo.
7724 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7725 worktree_project
7726 .update(cx, |p, cx| p.git_scans_complete(cx))
7727 .await;
7728
7729 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7730 main_project
7731 .update(cx, |p, cx| p.git_scans_complete(cx))
7732 .await;
7733
7734 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7735 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
7736 });
7737 let sidebar = setup_sidebar(&multi_workspace, cx);
7738
7739 // Save a thread against the MAIN repo path.
7740 save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
7741
7742 // Save a thread against the linked worktree path.
7743 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
7744
7745 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7746 cx.run_until_parked();
7747
7748 // Both threads should be visible: the worktree thread by direct lookup,
7749 // and the main repo thread because the workspace is a linked worktree
7750 // and we also query the main repo path.
7751 let entries = visible_entries_as_strings(&sidebar, cx);
7752 assert!(
7753 entries.iter().any(|e| e.contains("Main Repo Thread")),
7754 "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
7755 );
7756 assert!(
7757 entries.iter().any(|e| e.contains("Worktree Thread")),
7758 "expected worktree thread to be visible, got: {entries:?}"
7759 );
7760}
7761
7762async fn init_multi_project_test(
7763 paths: &[&str],
7764 cx: &mut TestAppContext,
7765) -> (Arc<FakeFs>, Entity<project::Project>) {
7766 agent_ui::test_support::init_test(cx);
7767 cx.update(|cx| {
7768 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
7769 ThreadStore::init_global(cx);
7770 ThreadMetadataStore::init_global(cx);
7771 language_model::LanguageModelRegistry::test(cx);
7772 prompt_store::init(cx);
7773 });
7774 let fs = FakeFs::new(cx.executor());
7775 for path in paths {
7776 fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
7777 .await;
7778 }
7779 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7780 let project =
7781 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
7782 (fs, project)
7783}
7784
7785async fn add_test_project(
7786 path: &str,
7787 fs: &Arc<FakeFs>,
7788 multi_workspace: &Entity<MultiWorkspace>,
7789 cx: &mut gpui::VisualTestContext,
7790) -> Entity<Workspace> {
7791 let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
7792 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7793 mw.test_add_workspace(project, window, cx)
7794 });
7795 cx.run_until_parked();
7796 workspace
7797}
7798
7799#[gpui::test]
7800async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
7801 let (fs, project_a) =
7802 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
7803 let (multi_workspace, cx) =
7804 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
7805 let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
7806
7807 // Sidebar starts closed. Initial workspace A is transient.
7808 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7809 assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
7810 assert_eq!(
7811 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7812 1
7813 );
7814 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
7815
7816 // Add B — replaces A as the transient workspace.
7817 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
7818 assert_eq!(
7819 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7820 1
7821 );
7822 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
7823
7824 // Add C — replaces B as the transient workspace.
7825 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
7826 assert_eq!(
7827 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7828 1
7829 );
7830 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
7831}
7832
7833#[gpui::test]
7834async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
7835 let (fs, project_a) = init_multi_project_test(
7836 &["/project-a", "/project-b", "/project-c", "/project-d"],
7837 cx,
7838 )
7839 .await;
7840 let (multi_workspace, cx) =
7841 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
7842 let _sidebar = setup_sidebar(&multi_workspace, cx);
7843 assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
7844
7845 // Add B — retained since sidebar is open.
7846 let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
7847 assert_eq!(
7848 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7849 2
7850 );
7851
7852 // Switch to A — B survives. (Switching from one internal workspace, to another)
7853 multi_workspace.update_in(cx, |mw, window, cx| {
7854 mw.activate(workspace_a, None, window, cx)
7855 });
7856 cx.run_until_parked();
7857 assert_eq!(
7858 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7859 2
7860 );
7861
7862 // Close sidebar — both A and B remain retained.
7863 multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
7864 cx.run_until_parked();
7865 assert_eq!(
7866 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7867 2
7868 );
7869
7870 // Add C — added as new transient workspace. (switching from retained, to transient)
7871 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
7872 assert_eq!(
7873 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7874 3
7875 );
7876 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
7877
7878 // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
7879 let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
7880 assert_eq!(
7881 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7882 3
7883 );
7884 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
7885}
7886
7887#[gpui::test]
7888async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
7889 let (fs, project_a) =
7890 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
7891 let (multi_workspace, cx) =
7892 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
7893 setup_sidebar_closed(&multi_workspace, cx);
7894
7895 // Add B — replaces A as the transient workspace (A is discarded).
7896 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
7897 assert_eq!(
7898 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7899 1
7900 );
7901 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
7902
7903 // Open sidebar — promotes the transient B to retained.
7904 multi_workspace.update_in(cx, |mw, window, cx| {
7905 mw.toggle_sidebar(window, cx);
7906 });
7907 cx.run_until_parked();
7908 assert_eq!(
7909 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7910 1
7911 );
7912 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
7913
7914 // Close sidebar — the retained B remains.
7915 multi_workspace.update_in(cx, |mw, window, cx| {
7916 mw.toggle_sidebar(window, cx);
7917 });
7918
7919 // Add C — added as new transient workspace.
7920 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
7921 assert_eq!(
7922 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7923 2
7924 );
7925 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
7926}
7927
7928#[gpui::test]
7929async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
7930 init_test(cx);
7931 let fs = FakeFs::new(cx.executor());
7932
7933 fs.insert_tree(
7934 "/project",
7935 serde_json::json!({
7936 ".git": {
7937 "worktrees": {
7938 "feature-a": {
7939 "commondir": "../../",
7940 "HEAD": "ref: refs/heads/feature-a",
7941 },
7942 },
7943 },
7944 "src": {},
7945 }),
7946 )
7947 .await;
7948
7949 fs.insert_tree(
7950 "/wt-feature-a",
7951 serde_json::json!({
7952 ".git": "gitdir: /project/.git/worktrees/feature-a",
7953 "src": {},
7954 }),
7955 )
7956 .await;
7957
7958 fs.add_linked_worktree_for_repo(
7959 Path::new("/project/.git"),
7960 false,
7961 git::repository::Worktree {
7962 path: PathBuf::from("/wt-feature-a"),
7963 ref_name: Some("refs/heads/feature-a".into()),
7964 sha: "abc".into(),
7965 is_main: false,
7966 is_bare: false,
7967 },
7968 )
7969 .await;
7970
7971 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7972
7973 // Only a linked worktree workspace is open — no workspace for /project.
7974 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7975 worktree_project
7976 .update(cx, |p, cx| p.git_scans_complete(cx))
7977 .await;
7978
7979 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7980 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
7981 });
7982 let sidebar = setup_sidebar(&multi_workspace, cx);
7983
7984 // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
7985 let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
7986 cx.update(|_, cx| {
7987 let metadata = ThreadMetadata {
7988 thread_id: ThreadId::new(),
7989 session_id: Some(legacy_session.clone()),
7990 agent_id: agent::ZED_AGENT_ID.clone(),
7991 title: Some("Legacy Main Thread".into()),
7992 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7993 created_at: None,
7994 interacted_at: None,
7995 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
7996 "/project",
7997 )])),
7998 archived: false,
7999 remote_connection: None,
8000 };
8001 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
8002 });
8003 cx.run_until_parked();
8004
8005 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
8006 cx.run_until_parked();
8007
8008 // The legacy thread should appear in the sidebar under the project group.
8009 let entries = visible_entries_as_strings(&sidebar, cx);
8010 assert!(
8011 entries.iter().any(|e| e.contains("Legacy Main Thread")),
8012 "legacy thread should be visible: {entries:?}",
8013 );
8014
8015 // Verify only 1 workspace before clicking.
8016 assert_eq!(
8017 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8018 1,
8019 );
8020
8021 // Focus and select the legacy thread, then confirm.
8022 focus_sidebar(&sidebar, cx);
8023 let thread_index = sidebar.read_with(cx, |sidebar, _| {
8024 sidebar
8025 .contents
8026 .entries
8027 .iter()
8028 .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
8029 .expect("legacy thread should be in entries")
8030 });
8031 sidebar.update_in(cx, |sidebar, _window, _cx| {
8032 sidebar.selection = Some(thread_index);
8033 });
8034 cx.dispatch_action(Confirm);
8035 cx.run_until_parked();
8036
8037 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8038 let new_path_list =
8039 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
8040 assert_eq!(
8041 new_path_list,
8042 PathList::new(&[PathBuf::from("/project")]),
8043 "the new workspace should be for the main repo, not the linked worktree",
8044 );
8045}
8046
8047#[gpui::test]
8048async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
8049 cx: &mut TestAppContext,
8050) {
8051 // Regression test for a property-test finding:
8052 // AddLinkedWorktree { project_group_index: 0 }
8053 // AddProject { use_worktree: true }
8054 // AddProject { use_worktree: false }
8055 // After these three steps, the linked-worktree workspace was not
8056 // reachable from any sidebar entry.
8057 agent_ui::test_support::init_test(cx);
8058 cx.update(|cx| {
8059 ThreadStore::init_global(cx);
8060 ThreadMetadataStore::init_global(cx);
8061 language_model::LanguageModelRegistry::test(cx);
8062 prompt_store::init(cx);
8063
8064 cx.observe_new(
8065 |workspace: &mut Workspace,
8066 window: Option<&mut Window>,
8067 cx: &mut gpui::Context<Workspace>| {
8068 if let Some(window) = window {
8069 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
8070 workspace.add_panel(panel, window, cx);
8071 }
8072 },
8073 )
8074 .detach();
8075 });
8076
8077 let fs = FakeFs::new(cx.executor());
8078 fs.insert_tree(
8079 "/my-project",
8080 serde_json::json!({
8081 ".git": {},
8082 "src": {},
8083 }),
8084 )
8085 .await;
8086 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8087 let project =
8088 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
8089 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8090
8091 let (multi_workspace, cx) =
8092 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8093 let sidebar = setup_sidebar(&multi_workspace, cx);
8094
8095 // Step 1: Create a linked worktree for the main project.
8096 let worktree_name = "wt-0";
8097 let worktree_path = "/worktrees/wt-0";
8098
8099 fs.insert_tree(
8100 worktree_path,
8101 serde_json::json!({
8102 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8103 "src": {},
8104 }),
8105 )
8106 .await;
8107 fs.insert_tree(
8108 "/my-project/.git/worktrees/wt-0",
8109 serde_json::json!({
8110 "commondir": "../../",
8111 "HEAD": "ref: refs/heads/wt-0",
8112 }),
8113 )
8114 .await;
8115 fs.add_linked_worktree_for_repo(
8116 Path::new("/my-project/.git"),
8117 false,
8118 git::repository::Worktree {
8119 path: PathBuf::from(worktree_path),
8120 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
8121 sha: "aaa".into(),
8122 is_main: false,
8123 is_bare: false,
8124 },
8125 )
8126 .await;
8127
8128 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8129 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
8130 main_project
8131 .update(cx, |p, cx| p.git_scans_complete(cx))
8132 .await;
8133 cx.run_until_parked();
8134
8135 // Step 2: Open the linked worktree as its own workspace.
8136 let worktree_project =
8137 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
8138 worktree_project
8139 .update(cx, |p, cx| p.git_scans_complete(cx))
8140 .await;
8141 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8142 mw.test_add_workspace(worktree_project.clone(), window, cx)
8143 });
8144 cx.run_until_parked();
8145
8146 // Step 3: Add an unrelated project.
8147 fs.insert_tree(
8148 "/other-project",
8149 serde_json::json!({
8150 ".git": {},
8151 "src": {},
8152 }),
8153 )
8154 .await;
8155 let other_project = project::Project::test(
8156 fs.clone() as Arc<dyn fs::Fs>,
8157 ["/other-project".as_ref()],
8158 cx,
8159 )
8160 .await;
8161 other_project
8162 .update(cx, |p, cx| p.git_scans_complete(cx))
8163 .await;
8164 multi_workspace.update_in(cx, |mw, window, cx| {
8165 mw.test_add_workspace(other_project.clone(), window, cx);
8166 });
8167 cx.run_until_parked();
8168
8169 // Force a full sidebar rebuild with all groups expanded.
8170 sidebar.update_in(cx, |sidebar, _window, cx| {
8171 if let Some(mw) = sidebar.multi_workspace.upgrade() {
8172 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
8173 }
8174 sidebar.update_entries(cx);
8175 });
8176 cx.run_until_parked();
8177
8178 // The linked-worktree workspace must be reachable from at least one
8179 // sidebar entry — otherwise the user has no way to navigate to it.
8180 let worktree_ws_id = worktree_workspace.entity_id();
8181 let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
8182 let mw = multi_workspace.read(cx);
8183
8184 let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
8185 let reachable: HashSet<gpui::EntityId> = sidebar
8186 .contents
8187 .entries
8188 .iter()
8189 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
8190 .map(|ws| ws.entity_id())
8191 .collect();
8192 (all, reachable)
8193 });
8194
8195 let unreachable = &all_ids - &reachable_ids;
8196 eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
8197
8198 assert!(
8199 unreachable.is_empty(),
8200 "workspaces not reachable from any sidebar entry: {:?}\n\
8201 (linked-worktree workspace id: {:?})",
8202 unreachable,
8203 worktree_ws_id,
8204 );
8205}
8206
8207#[gpui::test]
8208async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
8209 // Empty project groups no longer auto-create drafts via reconciliation.
8210 // A fresh startup with no restorable thread should show only the header.
8211 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8212 let (multi_workspace, cx) =
8213 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8214 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8215
8216 let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8217
8218 let entries = visible_entries_as_strings(&sidebar, cx);
8219 assert_eq!(
8220 entries,
8221 vec!["v [my-project]"],
8222 "empty group should show only the header, no auto-created draft"
8223 );
8224}
8225
8226#[gpui::test]
8227async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
8228 // Rule 5: When the app starts and the AgentPanel successfully loads
8229 // a thread, no spurious draft should appear.
8230 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8231 let (multi_workspace, cx) =
8232 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8233 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8234
8235 // Create and send a message to make a real thread.
8236 let connection = StubAgentConnection::new();
8237 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8238 acp::ContentChunk::new("Done".into()),
8239 )]);
8240 open_thread_with_connection(&panel, connection, cx);
8241 send_message(&panel, cx);
8242 let session_id = active_session_id(&panel, cx);
8243 save_test_thread_metadata(&session_id, &project, cx).await;
8244 cx.run_until_parked();
8245
8246 // Should show the thread, NOT a spurious draft.
8247 let entries = visible_entries_as_strings(&sidebar, cx);
8248 assert_eq!(entries, vec!["v [my-project]", " Hello *"]);
8249
8250 // active_entry should be Thread, not Draft.
8251 sidebar.read_with(cx, |sidebar, _| {
8252 assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
8253 });
8254}
8255
8256#[gpui::test]
8257async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
8258 // Rule 9: Clicking a project header should restore whatever the
8259 // user was last looking at in that group, not create new drafts
8260 // or jump to the first entry.
8261 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
8262 let (multi_workspace, cx) =
8263 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8264 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8265
8266 // Create two threads in project-a.
8267 let conn1 = StubAgentConnection::new();
8268 conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8269 acp::ContentChunk::new("Done".into()),
8270 )]);
8271 open_thread_with_connection(&panel_a, conn1, cx);
8272 send_message(&panel_a, cx);
8273 let thread_a1 = active_session_id(&panel_a, cx);
8274 save_test_thread_metadata(&thread_a1, &project_a, cx).await;
8275
8276 let conn2 = StubAgentConnection::new();
8277 conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8278 acp::ContentChunk::new("Done".into()),
8279 )]);
8280 open_thread_with_connection(&panel_a, conn2, cx);
8281 send_message(&panel_a, cx);
8282 let thread_a2 = active_session_id(&panel_a, cx);
8283 save_test_thread_metadata(&thread_a2, &project_a, cx).await;
8284 cx.run_until_parked();
8285
8286 // The user is now looking at thread_a2.
8287 sidebar.read_with(cx, |sidebar, _| {
8288 assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
8289 });
8290
8291 // Add project-b and switch to it.
8292 let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
8293 fs.as_fake()
8294 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
8295 .await;
8296 let project_b =
8297 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8298 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8299 mw.test_add_workspace(project_b.clone(), window, cx)
8300 });
8301 let _panel_b = add_agent_panel(&workspace_b, cx);
8302 cx.run_until_parked();
8303
8304 // Now switch BACK to project-a by activating its workspace.
8305 let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
8306 mw.workspaces()
8307 .find(|ws| {
8308 ws.read(cx)
8309 .project()
8310 .read(cx)
8311 .visible_worktrees(cx)
8312 .any(|wt| {
8313 wt.read(cx)
8314 .abs_path()
8315 .to_string_lossy()
8316 .contains("project-a")
8317 })
8318 })
8319 .unwrap()
8320 .clone()
8321 });
8322 multi_workspace.update_in(cx, |mw, window, cx| {
8323 mw.activate(workspace_a.clone(), None, window, cx);
8324 });
8325 cx.run_until_parked();
8326
8327 // The panel should still show thread_a2 (the last thing the user
8328 // was viewing in project-a), not a draft or thread_a1.
8329 sidebar.read_with(cx, |sidebar, _| {
8330 assert_active_thread(
8331 sidebar,
8332 &thread_a2,
8333 "switching back to project-a should restore thread_a2",
8334 );
8335 });
8336
8337 // No spurious draft entries should have been created in
8338 // project-a's group (project-b may have a placeholder).
8339 let entries = visible_entries_as_strings(&sidebar, cx);
8340 // Find project-a's section and check it has no drafts.
8341 let project_a_start = entries
8342 .iter()
8343 .position(|e| e.contains("project-a"))
8344 .unwrap();
8345 let project_a_end = entries[project_a_start + 1..]
8346 .iter()
8347 .position(|e| e.starts_with("v "))
8348 .map(|i| i + project_a_start + 1)
8349 .unwrap_or(entries.len());
8350 let project_a_drafts = entries[project_a_start..project_a_end]
8351 .iter()
8352 .filter(|e| e.contains("Draft"))
8353 .count();
8354 assert_eq!(
8355 project_a_drafts, 0,
8356 "switching back to project-a should not create drafts in its group"
8357 );
8358}
8359
8360#[gpui::test]
8361async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
8362 // When a workspace has a draft (from the panel's load fallback)
8363 // and the user activates it (e.g. by clicking the placeholder or
8364 // the project header), no extra drafts should be created.
8365 init_test(cx);
8366 let fs = FakeFs::new(cx.executor());
8367 fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
8368 .await;
8369 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8370 .await;
8371 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8372
8373 let project_a =
8374 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
8375 let (multi_workspace, cx) =
8376 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8377 let sidebar = setup_sidebar(&multi_workspace, cx);
8378 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8379 let _panel_a = add_agent_panel(&workspace_a, cx);
8380 cx.run_until_parked();
8381
8382 // Add project-b with its own workspace and agent panel.
8383 let project_b =
8384 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8385 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8386 mw.test_add_workspace(project_b.clone(), window, cx)
8387 });
8388 let _panel_b = add_agent_panel(&workspace_b, cx);
8389 cx.run_until_parked();
8390
8391 // Explicitly create a draft on workspace_b so the sidebar tracks one.
8392 sidebar.update_in(cx, |sidebar, window, cx| {
8393 sidebar.create_new_thread(&workspace_b, window, cx);
8394 });
8395 cx.run_until_parked();
8396
8397 // Count project-b's drafts.
8398 let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
8399 let entries = visible_entries_as_strings(&sidebar, cx);
8400 entries
8401 .iter()
8402 .skip_while(|e| !e.contains("project-b"))
8403 .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
8404 .filter(|e| e.contains("Draft"))
8405 .count()
8406 };
8407 let drafts_before = count_b_drafts(cx);
8408
8409 // Switch away from project-b, then back.
8410 multi_workspace.update_in(cx, |mw, window, cx| {
8411 mw.activate(workspace_a.clone(), None, window, cx);
8412 });
8413 cx.run_until_parked();
8414 multi_workspace.update_in(cx, |mw, window, cx| {
8415 mw.activate(workspace_b.clone(), None, window, cx);
8416 });
8417 cx.run_until_parked();
8418
8419 let drafts_after = count_b_drafts(cx);
8420 assert_eq!(
8421 drafts_before, drafts_after,
8422 "activating workspace should not create extra drafts"
8423 );
8424
8425 // The draft should be highlighted as active after switching back.
8426 sidebar.read_with(cx, |sidebar, _| {
8427 assert_active_draft(
8428 sidebar,
8429 &workspace_b,
8430 "draft should be active after switching back to its workspace",
8431 );
8432 });
8433}
8434
8435#[gpui::test]
8436async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
8437 // Historical threads (not open in any agent panel) should have their
8438 // worktree paths updated when a folder is added to or removed from the
8439 // project.
8440 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
8441 let (multi_workspace, cx) =
8442 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8443 let sidebar = setup_sidebar(&multi_workspace, cx);
8444
8445 // Save two threads directly into the metadata store (not via the agent
8446 // panel), so they are purely historical — no open views hold them.
8447 // Use different timestamps so sort order is deterministic.
8448 save_thread_metadata(
8449 acp::SessionId::new(Arc::from("hist-1")),
8450 Some("Historical 1".into()),
8451 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8452 None,
8453 None,
8454 &project,
8455 cx,
8456 );
8457 save_thread_metadata(
8458 acp::SessionId::new(Arc::from("hist-2")),
8459 Some("Historical 2".into()),
8460 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
8461 None,
8462 None,
8463 &project,
8464 cx,
8465 );
8466 cx.run_until_parked();
8467 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8468 cx.run_until_parked();
8469
8470 // Sanity-check: both threads exist under the initial key [/project-a].
8471 let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
8472 cx.update(|_window, cx| {
8473 let store = ThreadMetadataStore::global(cx).read(cx);
8474 assert_eq!(
8475 store
8476 .entries_for_main_worktree_path(&old_key_paths, None)
8477 .count(),
8478 2,
8479 "should have 2 historical threads under old key before worktree add"
8480 );
8481 });
8482
8483 // Add a second worktree to the project.
8484 project
8485 .update(cx, |project, cx| {
8486 project.find_or_create_worktree("/project-b", true, cx)
8487 })
8488 .await
8489 .expect("should add worktree");
8490 cx.run_until_parked();
8491
8492 // The historical threads should now be indexed under the new combined
8493 // key [/project-a, /project-b].
8494 let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
8495 cx.update(|_window, cx| {
8496 let store = ThreadMetadataStore::global(cx).read(cx);
8497 assert_eq!(
8498 store
8499 .entries_for_main_worktree_path(&old_key_paths, None)
8500 .count(),
8501 0,
8502 "should have 0 historical threads under old key after worktree add"
8503 );
8504 assert_eq!(
8505 store
8506 .entries_for_main_worktree_path(&new_key_paths, None)
8507 .count(),
8508 2,
8509 "should have 2 historical threads under new key after worktree add"
8510 );
8511 });
8512
8513 // Sidebar should show threads under the new header.
8514 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8515 cx.run_until_parked();
8516 assert_eq!(
8517 visible_entries_as_strings(&sidebar, cx),
8518 vec![
8519 "v [project-a, project-b]",
8520 " Historical 2",
8521 " Historical 1",
8522 ]
8523 );
8524
8525 // Now remove the second worktree.
8526 let worktree_id = project.read_with(cx, |project, cx| {
8527 project
8528 .visible_worktrees(cx)
8529 .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
8530 .map(|wt| wt.read(cx).id())
8531 .expect("should find project-b worktree")
8532 });
8533 project.update(cx, |project, cx| {
8534 project.remove_worktree(worktree_id, cx);
8535 });
8536 cx.run_until_parked();
8537
8538 // Historical threads should migrate back to the original key.
8539 cx.update(|_window, cx| {
8540 let store = ThreadMetadataStore::global(cx).read(cx);
8541 assert_eq!(
8542 store
8543 .entries_for_main_worktree_path(&new_key_paths, None)
8544 .count(),
8545 0,
8546 "should have 0 historical threads under new key after worktree remove"
8547 );
8548 assert_eq!(
8549 store
8550 .entries_for_main_worktree_path(&old_key_paths, None)
8551 .count(),
8552 2,
8553 "should have 2 historical threads under old key after worktree remove"
8554 );
8555 });
8556
8557 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8558 cx.run_until_parked();
8559 assert_eq!(
8560 visible_entries_as_strings(&sidebar, cx),
8561 vec!["v [project-a]", " Historical 2", " Historical 1",]
8562 );
8563}
8564
8565#[gpui::test]
8566async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
8567 // When two workspaces share the same project group (same main path)
8568 // but have different folder paths (main repo vs linked worktree),
8569 // adding a worktree to the main workspace should regroup only that
8570 // workspace and its threads into the new project group. Threads for the
8571 // linked worktree workspace should remain under the original group.
8572 agent_ui::test_support::init_test(cx);
8573 cx.update(|cx| {
8574 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
8575 ThreadStore::init_global(cx);
8576 ThreadMetadataStore::init_global(cx);
8577 language_model::LanguageModelRegistry::test(cx);
8578 prompt_store::init(cx);
8579 });
8580
8581 let fs = FakeFs::new(cx.executor());
8582 fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
8583 .await;
8584 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8585 .await;
8586 fs.add_linked_worktree_for_repo(
8587 Path::new("/project/.git"),
8588 false,
8589 git::repository::Worktree {
8590 path: std::path::PathBuf::from("/wt-feature"),
8591 ref_name: Some("refs/heads/feature".into()),
8592 sha: "aaa".into(),
8593 is_main: false,
8594 is_bare: false,
8595 },
8596 )
8597 .await;
8598 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8599
8600 // Workspace A: main repo at /project.
8601 let main_project =
8602 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
8603 // Workspace B: linked worktree of the same repo (same group, different folder).
8604 let worktree_project =
8605 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
8606
8607 main_project
8608 .update(cx, |p, cx| p.git_scans_complete(cx))
8609 .await;
8610 worktree_project
8611 .update(cx, |p, cx| p.git_scans_complete(cx))
8612 .await;
8613
8614 let (multi_workspace, cx) =
8615 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
8616 let sidebar = setup_sidebar(&multi_workspace, cx);
8617 multi_workspace.update_in(cx, |mw, window, cx| {
8618 mw.test_add_workspace(worktree_project.clone(), window, cx);
8619 });
8620 cx.run_until_parked();
8621
8622 // Save a thread for each workspace's folder paths.
8623 let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
8624 let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
8625 save_thread_metadata(
8626 acp::SessionId::new(Arc::from("thread-main")),
8627 Some("Main Thread".into()),
8628 time_main,
8629 Some(time_main),
8630 None,
8631 &main_project,
8632 cx,
8633 );
8634 save_thread_metadata(
8635 acp::SessionId::new(Arc::from("thread-wt")),
8636 Some("Worktree Thread".into()),
8637 time_wt,
8638 Some(time_wt),
8639 None,
8640 &worktree_project,
8641 cx,
8642 );
8643 cx.run_until_parked();
8644
8645 let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
8646 let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
8647
8648 // Sanity-check: each thread is indexed under its own folder paths, but
8649 // both appear under the shared sidebar group keyed by the main worktree.
8650 cx.update(|_window, cx| {
8651 let store = ThreadMetadataStore::global(cx).read(cx);
8652 assert_eq!(
8653 store.entries_for_path(&folder_paths_main, None).count(),
8654 1,
8655 "one thread under [/project]"
8656 );
8657 assert_eq!(
8658 store.entries_for_path(&folder_paths_wt, None).count(),
8659 1,
8660 "one thread under [/wt-feature]"
8661 );
8662 });
8663 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8664 cx.run_until_parked();
8665 assert_eq!(
8666 visible_entries_as_strings(&sidebar, cx),
8667 vec![
8668 "v [project]",
8669 " Worktree Thread {wt-feature}",
8670 " Main Thread",
8671 ]
8672 );
8673
8674 // Add /project-b to the main project only.
8675 main_project
8676 .update(cx, |project, cx| {
8677 project.find_or_create_worktree("/project-b", true, cx)
8678 })
8679 .await
8680 .expect("should add worktree");
8681 cx.run_until_parked();
8682
8683 // Main Thread (folder paths [/project]) should be regrouped to
8684 // [/project, /project-b]. Worktree Thread should remain under the
8685 // original [/project] group.
8686 let folder_paths_main_b =
8687 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
8688 cx.update(|_window, cx| {
8689 let store = ThreadMetadataStore::global(cx).read(cx);
8690 assert_eq!(
8691 store.entries_for_path(&folder_paths_main, None).count(),
8692 0,
8693 "main thread should no longer be under old folder paths [/project]"
8694 );
8695 assert_eq!(
8696 store.entries_for_path(&folder_paths_main_b, None).count(),
8697 1,
8698 "main thread should now be under [/project, /project-b]"
8699 );
8700 assert_eq!(
8701 store.entries_for_path(&folder_paths_wt, None).count(),
8702 1,
8703 "worktree thread should remain unchanged under [/wt-feature]"
8704 );
8705 });
8706
8707 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8708 cx.run_until_parked();
8709 assert_eq!(
8710 visible_entries_as_strings(&sidebar, cx),
8711 vec![
8712 "v [project]",
8713 " Worktree Thread {wt-feature}",
8714 "v [project, project-b]",
8715 " Main Thread",
8716 ]
8717 );
8718}
8719
8720#[gpui::test]
8721async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
8722 cx: &mut TestAppContext,
8723) {
8724 // When a linked worktree is opened as its own workspace and then a new
8725 // folder is added to the main project group, the linked worktree
8726 // workspace must still be reachable from some sidebar entry.
8727 let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
8728 let fs = _fs.clone();
8729
8730 // Set up git worktree infrastructure.
8731 fs.insert_tree(
8732 "/my-project/.git/worktrees/wt-0",
8733 serde_json::json!({
8734 "commondir": "../../",
8735 "HEAD": "ref: refs/heads/wt-0",
8736 }),
8737 )
8738 .await;
8739 fs.insert_tree(
8740 "/worktrees/wt-0",
8741 serde_json::json!({
8742 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8743 "src": {},
8744 }),
8745 )
8746 .await;
8747 fs.add_linked_worktree_for_repo(
8748 Path::new("/my-project/.git"),
8749 false,
8750 git::repository::Worktree {
8751 path: PathBuf::from("/worktrees/wt-0"),
8752 ref_name: Some("refs/heads/wt-0".into()),
8753 sha: "aaa".into(),
8754 is_main: false,
8755 is_bare: false,
8756 },
8757 )
8758 .await;
8759
8760 // Re-scan so the main project discovers the linked worktree.
8761 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8762
8763 let (multi_workspace, cx) =
8764 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8765 let sidebar = setup_sidebar(&multi_workspace, cx);
8766
8767 // Open the linked worktree as its own workspace.
8768 let worktree_project = project::Project::test(
8769 fs.clone() as Arc<dyn fs::Fs>,
8770 ["/worktrees/wt-0".as_ref()],
8771 cx,
8772 )
8773 .await;
8774 worktree_project
8775 .update(cx, |p, cx| p.git_scans_complete(cx))
8776 .await;
8777 multi_workspace.update_in(cx, |mw, window, cx| {
8778 mw.test_add_workspace(worktree_project.clone(), window, cx);
8779 });
8780 cx.run_until_parked();
8781
8782 // Both workspaces should be reachable.
8783 let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
8784 assert_eq!(workspace_count, 2, "should have 2 workspaces");
8785
8786 // Add a new folder to the main project, changing the project group key.
8787 fs.insert_tree(
8788 "/other-project",
8789 serde_json::json!({ ".git": {}, "src": {} }),
8790 )
8791 .await;
8792 project
8793 .update(cx, |project, cx| {
8794 project.find_or_create_worktree("/other-project", true, cx)
8795 })
8796 .await
8797 .expect("should add worktree");
8798 cx.run_until_parked();
8799
8800 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8801 cx.run_until_parked();
8802
8803 // The linked worktree workspace must still be reachable.
8804 let entries = visible_entries_as_strings(&sidebar, cx);
8805 let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
8806 mw.workspaces().map(|ws| ws.entity_id()).collect()
8807 });
8808 sidebar.read_with(cx, |sidebar, cx| {
8809 let multi_workspace = multi_workspace.read(cx);
8810 let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
8811 .contents
8812 .entries
8813 .iter()
8814 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
8815 .map(|ws| ws.entity_id())
8816 .collect();
8817 let all: std::collections::HashSet<gpui::EntityId> =
8818 mw_workspaces.iter().copied().collect();
8819 let unreachable = &all - &reachable;
8820 assert!(
8821 unreachable.is_empty(),
8822 "all workspaces should be reachable after adding folder; \
8823 unreachable: {:?}, entries: {:?}",
8824 unreachable,
8825 entries,
8826 );
8827 });
8828}
8829
8830mod property_test {
8831 use super::*;
8832 use gpui::proptest::prelude::*;
8833
8834 struct UnopenedWorktree {
8835 path: String,
8836 main_workspace_path: String,
8837 }
8838
8839 struct TestState {
8840 fs: Arc<FakeFs>,
8841 thread_counter: u32,
8842 workspace_counter: u32,
8843 worktree_counter: u32,
8844 saved_thread_ids: Vec<acp::SessionId>,
8845 unopened_worktrees: Vec<UnopenedWorktree>,
8846 }
8847
8848 impl TestState {
8849 fn new(fs: Arc<FakeFs>) -> Self {
8850 Self {
8851 fs,
8852 thread_counter: 0,
8853 workspace_counter: 1,
8854 worktree_counter: 0,
8855 saved_thread_ids: Vec::new(),
8856 unopened_worktrees: Vec::new(),
8857 }
8858 }
8859
8860 fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
8861 let id = self.thread_counter;
8862 self.thread_counter += 1;
8863 acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
8864 }
8865
8866 fn next_workspace_path(&mut self) -> String {
8867 let id = self.workspace_counter;
8868 self.workspace_counter += 1;
8869 format!("/prop-project-{id}")
8870 }
8871
8872 fn next_worktree_name(&mut self) -> String {
8873 let id = self.worktree_counter;
8874 self.worktree_counter += 1;
8875 format!("wt-{id}")
8876 }
8877 }
8878
8879 #[derive(Debug)]
8880 enum Operation {
8881 SaveThread { project_group_index: usize },
8882 SaveWorktreeThread { worktree_index: usize },
8883 ToggleAgentPanel,
8884 CreateDraftThread,
8885 AddProject { use_worktree: bool },
8886 ArchiveThread { index: usize },
8887 SwitchToThread { index: usize },
8888 SwitchToProjectGroup { index: usize },
8889 AddLinkedWorktree { project_group_index: usize },
8890 AddWorktreeToProject { project_group_index: usize },
8891 RemoveWorktreeFromProject { project_group_index: usize },
8892 }
8893
8894 // Distribution (out of 24 slots):
8895 // SaveThread: 5 slots (~21%)
8896 // SaveWorktreeThread: 2 slots (~8%)
8897 // ToggleAgentPanel: 1 slot (~4%)
8898 // CreateDraftThread: 1 slot (~4%)
8899 // AddProject: 1 slot (~4%)
8900 // ArchiveThread: 2 slots (~8%)
8901 // SwitchToThread: 2 slots (~8%)
8902 // SwitchToProjectGroup: 2 slots (~8%)
8903 // AddLinkedWorktree: 4 slots (~17%)
8904 // AddWorktreeToProject: 2 slots (~8%)
8905 // RemoveWorktreeFromProject: 2 slots (~8%)
8906 const DISTRIBUTION_SLOTS: u32 = 24;
8907
8908 impl TestState {
8909 fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
8910 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
8911
8912 match raw % DISTRIBUTION_SLOTS {
8913 0..=4 => Operation::SaveThread {
8914 project_group_index: extra % project_group_count,
8915 },
8916 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
8917 worktree_index: extra % self.unopened_worktrees.len(),
8918 },
8919 5..=6 => Operation::SaveThread {
8920 project_group_index: extra % project_group_count,
8921 },
8922 7 => Operation::ToggleAgentPanel,
8923 8 => Operation::CreateDraftThread,
8924 9 => Operation::AddProject {
8925 use_worktree: !self.unopened_worktrees.is_empty(),
8926 },
8927 10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
8928 index: extra % self.saved_thread_ids.len(),
8929 },
8930 10..=11 => Operation::AddProject {
8931 use_worktree: !self.unopened_worktrees.is_empty(),
8932 },
8933 12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
8934 index: extra % self.saved_thread_ids.len(),
8935 },
8936 12..=13 => Operation::SwitchToProjectGroup {
8937 index: extra % project_group_count,
8938 },
8939 14..=15 => Operation::SwitchToProjectGroup {
8940 index: extra % project_group_count,
8941 },
8942 16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
8943 project_group_index: extra % project_group_count,
8944 },
8945 16..=19 => Operation::SaveThread {
8946 project_group_index: extra % project_group_count,
8947 },
8948 20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
8949 project_group_index: extra % project_group_count,
8950 },
8951 20..=21 => Operation::SaveThread {
8952 project_group_index: extra % project_group_count,
8953 },
8954 22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
8955 project_group_index: extra % project_group_count,
8956 },
8957 22..=23 => Operation::SaveThread {
8958 project_group_index: extra % project_group_count,
8959 },
8960 _ => unreachable!(),
8961 }
8962 }
8963 }
8964
8965 fn save_thread_to_path_with_main(
8966 state: &mut TestState,
8967 path_list: PathList,
8968 main_worktree_paths: PathList,
8969 cx: &mut gpui::VisualTestContext,
8970 ) {
8971 let session_id = state.next_metadata_only_thread_id();
8972 let title: SharedString = format!("Thread {}", session_id).into();
8973 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
8974 .unwrap()
8975 + chrono::Duration::seconds(state.thread_counter as i64);
8976 let metadata = ThreadMetadata {
8977 thread_id: ThreadId::new(),
8978 session_id: Some(session_id),
8979 agent_id: agent::ZED_AGENT_ID.clone(),
8980 title: Some(title),
8981 updated_at,
8982 created_at: None,
8983 interacted_at: None,
8984 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
8985 archived: false,
8986 remote_connection: None,
8987 };
8988 cx.update(|_, cx| {
8989 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
8990 });
8991 cx.run_until_parked();
8992 }
8993
8994 async fn perform_operation(
8995 operation: Operation,
8996 state: &mut TestState,
8997 multi_workspace: &Entity<MultiWorkspace>,
8998 sidebar: &Entity<Sidebar>,
8999 cx: &mut gpui::VisualTestContext,
9000 ) {
9001 match operation {
9002 Operation::SaveThread {
9003 project_group_index,
9004 } => {
9005 // Find a workspace for this project group and create a real
9006 // thread via its agent panel.
9007 let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
9008 let keys = mw.project_group_keys();
9009 let key = &keys[project_group_index];
9010 let ws = mw
9011 .workspaces_for_project_group(key, cx)
9012 .and_then(|ws| ws.first().cloned())
9013 .unwrap_or_else(|| mw.workspace().clone());
9014 let project = ws.read(cx).project().clone();
9015 (ws, project)
9016 });
9017
9018 let panel =
9019 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9020 if let Some(panel) = panel {
9021 let connection = StubAgentConnection::new();
9022 connection.set_next_prompt_updates(vec![
9023 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
9024 "Done".into(),
9025 )),
9026 ]);
9027 open_thread_with_connection(&panel, connection, cx);
9028 send_message(&panel, cx);
9029 let session_id = active_session_id(&panel, cx);
9030 state.saved_thread_ids.push(session_id.clone());
9031
9032 let title: SharedString = format!("Thread {}", state.thread_counter).into();
9033 state.thread_counter += 1;
9034 let updated_at =
9035 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9036 .unwrap()
9037 + chrono::Duration::seconds(state.thread_counter as i64);
9038 save_thread_metadata(
9039 session_id,
9040 Some(title),
9041 updated_at,
9042 None,
9043 None,
9044 &project,
9045 cx,
9046 );
9047 }
9048 }
9049 Operation::SaveWorktreeThread { worktree_index } => {
9050 let worktree = &state.unopened_worktrees[worktree_index];
9051 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
9052 let main_worktree_paths =
9053 PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
9054 save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
9055 }
9056
9057 Operation::ToggleAgentPanel => {
9058 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9059 let panel_open =
9060 workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
9061 workspace.update_in(cx, |workspace, window, cx| {
9062 if panel_open {
9063 workspace.close_panel::<AgentPanel>(window, cx);
9064 } else {
9065 workspace.open_panel::<AgentPanel>(window, cx);
9066 }
9067 });
9068 }
9069 Operation::CreateDraftThread => {
9070 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9071 let panel =
9072 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9073 if let Some(panel) = panel {
9074 panel.update_in(cx, |panel, window, cx| {
9075 panel.new_thread(&NewThread, window, cx);
9076 });
9077 cx.run_until_parked();
9078 }
9079 workspace.update_in(cx, |workspace, window, cx| {
9080 workspace.focus_panel::<AgentPanel>(window, cx);
9081 });
9082 }
9083 Operation::AddProject { use_worktree } => {
9084 let path = if use_worktree {
9085 // Open an existing linked worktree as a project (simulates Cmd+O
9086 // on a worktree directory).
9087 state.unopened_worktrees.remove(0).path
9088 } else {
9089 // Create a brand new project.
9090 let path = state.next_workspace_path();
9091 state
9092 .fs
9093 .insert_tree(
9094 &path,
9095 serde_json::json!({
9096 ".git": {},
9097 "src": {},
9098 }),
9099 )
9100 .await;
9101 path
9102 };
9103 let project = project::Project::test(
9104 state.fs.clone() as Arc<dyn fs::Fs>,
9105 [path.as_ref()],
9106 cx,
9107 )
9108 .await;
9109 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9110 multi_workspace.update_in(cx, |mw, window, cx| {
9111 mw.test_add_workspace(project.clone(), window, cx)
9112 });
9113 }
9114
9115 Operation::ArchiveThread { index } => {
9116 let session_id = state.saved_thread_ids[index].clone();
9117 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
9118 sidebar.archive_thread(&session_id, window, cx);
9119 });
9120 cx.run_until_parked();
9121 state.saved_thread_ids.remove(index);
9122 }
9123 Operation::SwitchToThread { index } => {
9124 let session_id = state.saved_thread_ids[index].clone();
9125 // Find the thread's position in the sidebar entries and select it.
9126 let thread_index = sidebar.read_with(cx, |sidebar, _| {
9127 sidebar.contents.entries.iter().position(|entry| {
9128 matches!(
9129 entry,
9130 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
9131 )
9132 })
9133 });
9134 if let Some(ix) = thread_index {
9135 sidebar.update_in(cx, |sidebar, window, cx| {
9136 sidebar.selection = Some(ix);
9137 sidebar.confirm(&Confirm, window, cx);
9138 });
9139 cx.run_until_parked();
9140 }
9141 }
9142 Operation::SwitchToProjectGroup { index } => {
9143 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9144 let keys = mw.project_group_keys();
9145 let key = &keys[index];
9146 mw.workspaces_for_project_group(key, cx)
9147 .and_then(|ws| ws.first().cloned())
9148 .unwrap_or_else(|| mw.workspace().clone())
9149 });
9150 multi_workspace.update_in(cx, |mw, window, cx| {
9151 mw.activate(workspace, None, window, cx);
9152 });
9153 }
9154 Operation::AddLinkedWorktree {
9155 project_group_index,
9156 } => {
9157 // Get the main worktree path from the project group key.
9158 let main_path = multi_workspace.read_with(cx, |mw, _| {
9159 let keys = mw.project_group_keys();
9160 let key = &keys[project_group_index];
9161 key.path_list()
9162 .paths()
9163 .first()
9164 .unwrap()
9165 .to_string_lossy()
9166 .to_string()
9167 });
9168 let dot_git = format!("{}/.git", main_path);
9169 let worktree_name = state.next_worktree_name();
9170 let worktree_path = format!("/worktrees/{}", worktree_name);
9171
9172 state.fs
9173 .insert_tree(
9174 &worktree_path,
9175 serde_json::json!({
9176 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
9177 "src": {},
9178 }),
9179 )
9180 .await;
9181
9182 // Also create the worktree metadata dir inside the main repo's .git
9183 state
9184 .fs
9185 .insert_tree(
9186 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
9187 serde_json::json!({
9188 "commondir": "../../",
9189 "HEAD": format!("ref: refs/heads/{}", worktree_name),
9190 }),
9191 )
9192 .await;
9193
9194 let dot_git_path = std::path::Path::new(&dot_git);
9195 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
9196 state
9197 .fs
9198 .add_linked_worktree_for_repo(
9199 dot_git_path,
9200 false,
9201 git::repository::Worktree {
9202 path: worktree_pathbuf,
9203 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
9204 sha: "aaa".into(),
9205 is_main: false,
9206 is_bare: false,
9207 },
9208 )
9209 .await;
9210
9211 // Re-scan the main workspace's project so it discovers the new worktree.
9212 let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
9213 let keys = mw.project_group_keys();
9214 let key = &keys[project_group_index];
9215 mw.workspaces_for_project_group(key, cx)
9216 .and_then(|ws| ws.first().cloned())
9217 .unwrap()
9218 });
9219 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
9220 main_project
9221 .update(cx, |p, cx| p.git_scans_complete(cx))
9222 .await;
9223
9224 state.unopened_worktrees.push(UnopenedWorktree {
9225 path: worktree_path,
9226 main_workspace_path: main_path.clone(),
9227 });
9228 }
9229 Operation::AddWorktreeToProject {
9230 project_group_index,
9231 } => {
9232 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9233 let keys = mw.project_group_keys();
9234 let key = &keys[project_group_index];
9235 mw.workspaces_for_project_group(key, cx)
9236 .and_then(|ws| ws.first().cloned())
9237 });
9238 let Some(workspace) = workspace else { return };
9239 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9240
9241 let new_path = state.next_workspace_path();
9242 state
9243 .fs
9244 .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
9245 .await;
9246
9247 let result = project
9248 .update(cx, |project, cx| {
9249 project.find_or_create_worktree(&new_path, true, cx)
9250 })
9251 .await;
9252 if result.is_err() {
9253 return;
9254 }
9255 cx.run_until_parked();
9256 }
9257 Operation::RemoveWorktreeFromProject {
9258 project_group_index,
9259 } => {
9260 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9261 let keys = mw.project_group_keys();
9262 let key = &keys[project_group_index];
9263 mw.workspaces_for_project_group(key, cx)
9264 .and_then(|ws| ws.first().cloned())
9265 });
9266 let Some(workspace) = workspace else { return };
9267 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9268
9269 let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
9270 if worktree_count <= 1 {
9271 return;
9272 }
9273
9274 let worktree_id = project.read_with(cx, |p, cx| {
9275 p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
9276 });
9277 if let Some(worktree_id) = worktree_id {
9278 project.update(cx, |project, cx| {
9279 project.remove_worktree(worktree_id, cx);
9280 });
9281 cx.run_until_parked();
9282 }
9283 }
9284 }
9285 }
9286
9287 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
9288 sidebar.update_in(cx, |sidebar, _window, cx| {
9289 if let Some(mw) = sidebar.multi_workspace.upgrade() {
9290 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
9291 }
9292 sidebar.update_entries(cx);
9293 });
9294 }
9295
9296 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9297 verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
9298 verify_no_duplicate_threads(sidebar)?;
9299 verify_all_threads_are_shown(sidebar, cx)?;
9300 verify_active_state_matches_current_workspace(sidebar, cx)?;
9301 verify_all_workspaces_are_reachable(sidebar, cx)?;
9302 verify_workspace_group_key_integrity(sidebar, cx)?;
9303 Ok(())
9304 }
9305
9306 fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
9307 let mut seen: HashSet<acp::SessionId> = HashSet::default();
9308 let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
9309
9310 for entry in &sidebar.contents.entries {
9311 if let Some(session_id) = entry.session_id() {
9312 if !seen.insert(session_id.clone()) {
9313 let title = match entry {
9314 ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
9315 _ => "<unknown>".to_string(),
9316 };
9317 duplicates.push((session_id.clone(), title));
9318 }
9319 }
9320 }
9321
9322 anyhow::ensure!(
9323 duplicates.is_empty(),
9324 "threads appear more than once in sidebar: {:?}",
9325 duplicates,
9326 );
9327 Ok(())
9328 }
9329
9330 fn verify_every_group_in_multiworkspace_is_shown(
9331 sidebar: &Sidebar,
9332 cx: &App,
9333 ) -> anyhow::Result<()> {
9334 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9335 anyhow::bail!("sidebar should still have an associated multi-workspace");
9336 };
9337
9338 let mw = multi_workspace.read(cx);
9339
9340 // Every project group key in the multi-workspace that has a
9341 // non-empty path list should appear as a ProjectHeader in the
9342 // sidebar.
9343 let all_keys = mw.project_group_keys();
9344 let expected_keys: HashSet<&ProjectGroupKey> = all_keys
9345 .iter()
9346 .filter(|k| !k.path_list().paths().is_empty())
9347 .collect();
9348
9349 let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
9350 .contents
9351 .entries
9352 .iter()
9353 .filter_map(|entry| match entry {
9354 ListEntry::ProjectHeader { key, .. } => Some(key),
9355 _ => None,
9356 })
9357 .collect();
9358
9359 let missing = &expected_keys - &sidebar_keys;
9360 let stray = &sidebar_keys - &expected_keys;
9361
9362 anyhow::ensure!(
9363 missing.is_empty() && stray.is_empty(),
9364 "sidebar project groups don't match multi-workspace.\n\
9365 Only in multi-workspace (missing): {:?}\n\
9366 Only in sidebar (stray): {:?}",
9367 missing,
9368 stray,
9369 );
9370
9371 Ok(())
9372 }
9373
9374 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9375 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9376 anyhow::bail!("sidebar should still have an associated multi-workspace");
9377 };
9378 let workspaces = multi_workspace
9379 .read(cx)
9380 .workspaces()
9381 .cloned()
9382 .collect::<Vec<_>>();
9383 let thread_store = ThreadMetadataStore::global(cx);
9384
9385 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
9386 .contents
9387 .entries
9388 .iter()
9389 .filter_map(|entry| entry.session_id().cloned())
9390 .collect();
9391
9392 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
9393
9394 // Query using the same approach as the sidebar: iterate project
9395 // group keys, then do main + legacy queries per group.
9396 let mw = multi_workspace.read(cx);
9397 let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
9398 HashMap::default();
9399 for workspace in &workspaces {
9400 let key = workspace.read(cx).project_group_key(cx);
9401 workspaces_by_group
9402 .entry(key)
9403 .or_default()
9404 .push(workspace.clone());
9405 }
9406
9407 for group_key in mw.project_group_keys() {
9408 let path_list = group_key.path_list().clone();
9409 if path_list.paths().is_empty() {
9410 continue;
9411 }
9412
9413 let group_workspaces = workspaces_by_group
9414 .get(&group_key)
9415 .map(|ws| ws.as_slice())
9416 .unwrap_or_default();
9417
9418 // Main code path queries (run for all groups, even without workspaces).
9419 // Skip drafts (session_id: None) — they are not shown in the
9420 // sidebar entries.
9421 for metadata in thread_store
9422 .read(cx)
9423 .entries_for_main_worktree_path(&path_list, None)
9424 {
9425 if let Some(sid) = metadata.session_id.clone() {
9426 metadata_thread_ids.insert(sid);
9427 }
9428 }
9429 for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
9430 if let Some(sid) = metadata.session_id.clone() {
9431 metadata_thread_ids.insert(sid);
9432 }
9433 }
9434
9435 // Legacy: per-workspace queries for different root paths.
9436 let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
9437 .iter()
9438 .flat_map(|ws| {
9439 ws.read(cx)
9440 .root_paths(cx)
9441 .into_iter()
9442 .map(|p| p.to_path_buf())
9443 })
9444 .collect();
9445
9446 for workspace in group_workspaces {
9447 let ws_path_list = workspace_path_list(workspace, cx);
9448 if ws_path_list != path_list {
9449 for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
9450 if let Some(sid) = metadata.session_id.clone() {
9451 metadata_thread_ids.insert(sid);
9452 }
9453 }
9454 }
9455 }
9456
9457 for workspace in group_workspaces {
9458 for snapshot in root_repository_snapshots(workspace, cx) {
9459 let repo_path_list =
9460 PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
9461 if repo_path_list != path_list {
9462 continue;
9463 }
9464 for linked_worktree in snapshot.linked_worktrees() {
9465 if covered_paths.contains(&*linked_worktree.path) {
9466 continue;
9467 }
9468 let worktree_path_list =
9469 PathList::new(std::slice::from_ref(&linked_worktree.path));
9470 for metadata in thread_store
9471 .read(cx)
9472 .entries_for_path(&worktree_path_list, None)
9473 {
9474 if let Some(sid) = metadata.session_id.clone() {
9475 metadata_thread_ids.insert(sid);
9476 }
9477 }
9478 }
9479 }
9480 }
9481 }
9482
9483 anyhow::ensure!(
9484 sidebar_thread_ids == metadata_thread_ids,
9485 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
9486 sidebar_thread_ids,
9487 metadata_thread_ids,
9488 );
9489 Ok(())
9490 }
9491
9492 fn verify_active_state_matches_current_workspace(
9493 sidebar: &Sidebar,
9494 cx: &App,
9495 ) -> anyhow::Result<()> {
9496 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9497 anyhow::bail!("sidebar should still have an associated multi-workspace");
9498 };
9499
9500 let active_workspace = multi_workspace.read(cx).workspace();
9501
9502 // 1. active_entry should be Some when the panel has content.
9503 // It may be None when the panel is uninitialized (no drafts,
9504 // no threads), which is fine.
9505 // It may also temporarily point at a different workspace
9506 // when the workspace just changed and the new panel has no
9507 // content yet.
9508 let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
9509 let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
9510 || panel.read(cx).active_conversation_view().is_some();
9511
9512 let Some(entry) = sidebar.active_entry.as_ref() else {
9513 if panel_has_content {
9514 anyhow::bail!("active_entry is None but panel has content (draft or thread)");
9515 }
9516 return Ok(());
9517 };
9518
9519 // If the entry workspace doesn't match the active workspace
9520 // and the panel has no content, this is a transient state that
9521 // will resolve when the panel gets content.
9522 if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
9523 return Ok(());
9524 }
9525
9526 // 2. The entry's workspace must agree with the multi-workspace's
9527 // active workspace.
9528 anyhow::ensure!(
9529 entry.workspace().entity_id() == active_workspace.entity_id(),
9530 "active_entry workspace ({:?}) != active workspace ({:?})",
9531 entry.workspace().entity_id(),
9532 active_workspace.entity_id(),
9533 );
9534
9535 // 3. The entry must match the agent panel's current state.
9536 if panel.read(cx).active_thread_id(cx).is_some() {
9537 anyhow::ensure!(
9538 matches!(entry, ActiveEntry { .. }),
9539 "panel shows a tracked draft but active_entry is {:?}",
9540 entry,
9541 );
9542 } else if let Some(thread_id) = panel
9543 .read(cx)
9544 .active_conversation_view()
9545 .map(|cv| cv.read(cx).parent_id())
9546 {
9547 anyhow::ensure!(
9548 matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
9549 "panel has thread {:?} but active_entry is {:?}",
9550 thread_id,
9551 entry,
9552 );
9553 }
9554
9555 // 4. Exactly one entry in sidebar contents must be uniquely
9556 // identified by the active_entry — unless the panel is showing
9557 // a draft, which is represented by the + button's active state
9558 // rather than a sidebar row.
9559 // TODO: Make this check more complete
9560 let is_draft = panel.read(cx).active_thread_is_draft(cx)
9561 || panel.read(cx).active_conversation_view().is_none();
9562 if is_draft {
9563 return Ok(());
9564 }
9565 let matching_count = sidebar
9566 .contents
9567 .entries
9568 .iter()
9569 .filter(|e| entry.matches_entry(e))
9570 .count();
9571 if matching_count != 1 {
9572 let thread_entries: Vec<_> = sidebar
9573 .contents
9574 .entries
9575 .iter()
9576 .filter_map(|e| match e {
9577 ListEntry::Thread(t) => Some(format!(
9578 "tid={:?} sid={:?}",
9579 t.metadata.thread_id, t.metadata.session_id
9580 )),
9581 _ => None,
9582 })
9583 .collect();
9584 let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
9585 let store_entries: Vec<_> = store
9586 .entries()
9587 .map(|m| {
9588 format!(
9589 "tid={:?} sid={:?} archived={} paths={:?}",
9590 m.thread_id,
9591 m.session_id,
9592 m.archived,
9593 m.folder_paths()
9594 )
9595 })
9596 .collect();
9597 anyhow::bail!(
9598 "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
9599 entry,
9600 matching_count,
9601 thread_entries,
9602 store_entries,
9603 );
9604 }
9605
9606 Ok(())
9607 }
9608
9609 /// Every workspace in the multi-workspace should be "reachable" from
9610 /// the sidebar — meaning there is at least one entry (thread, draft,
9611 /// new-thread, or project header) that, when clicked, would activate
9612 /// that workspace.
9613 fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9614 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9615 anyhow::bail!("sidebar should still have an associated multi-workspace");
9616 };
9617
9618 let multi_workspace = multi_workspace.read(cx);
9619
9620 let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
9621 .contents
9622 .entries
9623 .iter()
9624 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
9625 .map(|ws| ws.entity_id())
9626 .collect();
9627
9628 let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
9629 .workspaces()
9630 .map(|ws| ws.entity_id())
9631 .collect();
9632
9633 let unreachable = &all_workspace_ids - &reachable_workspaces;
9634
9635 anyhow::ensure!(
9636 unreachable.is_empty(),
9637 "The following workspaces are not reachable from any sidebar entry: {:?}",
9638 unreachable,
9639 );
9640
9641 Ok(())
9642 }
9643
9644 fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9645 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9646 anyhow::bail!("sidebar should still have an associated multi-workspace");
9647 };
9648 multi_workspace
9649 .read(cx)
9650 .assert_project_group_key_integrity(cx)
9651 }
9652
9653 #[gpui::property_test(config = ProptestConfig {
9654 cases: 20,
9655 ..Default::default()
9656 })]
9657 async fn test_sidebar_invariants(
9658 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
9659 raw_operations: Vec<u32>,
9660 cx: &mut TestAppContext,
9661 ) {
9662 use std::sync::atomic::{AtomicUsize, Ordering};
9663 static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
9664
9665 agent_ui::test_support::init_test(cx);
9666 cx.update(|cx| {
9667 cx.set_global(db::AppDatabase::test_new());
9668 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
9669 cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
9670 format!(
9671 "PROPTEST_THREAD_METADATA_{}",
9672 NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
9673 ),
9674 ));
9675
9676 ThreadStore::init_global(cx);
9677 ThreadMetadataStore::init_global(cx);
9678 language_model::LanguageModelRegistry::test(cx);
9679 prompt_store::init(cx);
9680
9681 // Auto-add an AgentPanel to every workspace so that implicitly
9682 // created workspaces (e.g. from thread activation) also have one.
9683 cx.observe_new(
9684 |workspace: &mut Workspace,
9685 window: Option<&mut Window>,
9686 cx: &mut gpui::Context<Workspace>| {
9687 if let Some(window) = window {
9688 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
9689 workspace.add_panel(panel, window, cx);
9690 }
9691 },
9692 )
9693 .detach();
9694 });
9695
9696 let fs = FakeFs::new(cx.executor());
9697 fs.insert_tree(
9698 "/my-project",
9699 serde_json::json!({
9700 ".git": {},
9701 "src": {},
9702 }),
9703 )
9704 .await;
9705 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
9706 let project =
9707 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
9708 .await;
9709 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9710
9711 let (multi_workspace, cx) =
9712 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9713 let sidebar = setup_sidebar(&multi_workspace, cx);
9714
9715 let mut state = TestState::new(fs);
9716 let mut executed: Vec<String> = Vec::new();
9717
9718 for &raw_op in &raw_operations {
9719 let project_group_count =
9720 multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
9721 let operation = state.generate_operation(raw_op, project_group_count);
9722 executed.push(format!("{:?}", operation));
9723 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
9724 cx.run_until_parked();
9725
9726 update_sidebar(&sidebar, cx);
9727 cx.run_until_parked();
9728
9729 let result =
9730 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
9731 if let Err(err) = result {
9732 let log = executed.join("\n ");
9733 panic!(
9734 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
9735 executed.len(),
9736 );
9737 }
9738 }
9739 }
9740}
9741
9742#[gpui::test]
9743async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
9744 cx: &mut TestAppContext,
9745 server_cx: &mut TestAppContext,
9746) {
9747 init_test(cx);
9748
9749 cx.update(|cx| {
9750 release_channel::init(semver::Version::new(0, 0, 0), cx);
9751 });
9752
9753 let app_state = cx.update(|cx| {
9754 let app_state = workspace::AppState::test(cx);
9755 workspace::init(app_state.clone(), cx);
9756 app_state
9757 });
9758
9759 // Set up the remote server side.
9760 let server_fs = FakeFs::new(server_cx.executor());
9761 server_fs
9762 .insert_tree(
9763 "/project",
9764 serde_json::json!({
9765 ".git": {},
9766 "src": { "main.rs": "fn main() {}" }
9767 }),
9768 )
9769 .await;
9770 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
9771
9772 // Create the linked worktree checkout path on the remote server,
9773 // but do not yet register it as a git-linked worktree. The real
9774 // regrouping update in this test should happen only after the
9775 // sidebar opens the closed remote thread.
9776 server_fs
9777 .insert_tree(
9778 "/project-wt-1",
9779 serde_json::json!({
9780 "src": { "main.rs": "fn main() {}" }
9781 }),
9782 )
9783 .await;
9784
9785 server_cx.update(|cx| {
9786 release_channel::init(semver::Version::new(0, 0, 0), cx);
9787 });
9788
9789 let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
9790
9791 server_cx.update(remote_server::HeadlessProject::init);
9792 let server_executor = server_cx.executor();
9793 let _headless = server_cx.new(|cx| {
9794 remote_server::HeadlessProject::new(
9795 remote_server::HeadlessAppState {
9796 session: server_session,
9797 fs: server_fs.clone(),
9798 http_client: Arc::new(http_client::BlockedHttpClient),
9799 node_runtime: node_runtime::NodeRuntime::unavailable(),
9800 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
9801 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
9802 startup_time: std::time::Instant::now(),
9803 },
9804 false,
9805 cx,
9806 )
9807 });
9808
9809 // Connect the client side and build a remote project.
9810 let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
9811 let project = cx.update(|cx| {
9812 let project_client = client::Client::new(
9813 Arc::new(clock::FakeSystemClock::new()),
9814 http_client::FakeHttpClient::with_404_response(),
9815 cx,
9816 );
9817 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
9818 project::Project::remote(
9819 remote_client,
9820 project_client,
9821 node_runtime::NodeRuntime::unavailable(),
9822 user_store,
9823 app_state.languages.clone(),
9824 app_state.fs.clone(),
9825 false,
9826 cx,
9827 )
9828 });
9829
9830 // Open the remote worktree.
9831 project
9832 .update(cx, |project, cx| {
9833 project.find_or_create_worktree(Path::new("/project"), true, cx)
9834 })
9835 .await
9836 .expect("should open remote worktree");
9837 cx.run_until_parked();
9838
9839 // Verify the project is remote.
9840 project.read_with(cx, |project, cx| {
9841 assert!(!project.is_local(), "project should be remote");
9842 assert!(
9843 project.remote_connection_options(cx).is_some(),
9844 "project should have remote connection options"
9845 );
9846 });
9847
9848 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
9849
9850 // Create MultiWorkspace with the remote project.
9851 let (multi_workspace, cx) =
9852 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9853 let sidebar = setup_sidebar(&multi_workspace, cx);
9854
9855 cx.run_until_parked();
9856
9857 // Save a thread for the main remote workspace (folder_paths match
9858 // the open workspace, so it will be classified as Open).
9859 let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
9860 save_thread_metadata(
9861 main_thread_id.clone(),
9862 Some("Main Thread".into()),
9863 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
9864 None,
9865 None,
9866 &project,
9867 cx,
9868 );
9869 cx.run_until_parked();
9870
9871 // Save a thread whose folder_paths point to a linked worktree path
9872 // that doesn't have an open workspace ("/project-wt-1"), but whose
9873 // main_worktree_paths match the project group key so it appears
9874 // in the sidebar under the same remote group. This simulates a
9875 // linked worktree workspace that was closed.
9876 let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
9877 let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
9878 (
9879 p.project_group_key(cx).path_list().clone(),
9880 p.remote_connection_options(cx),
9881 )
9882 });
9883 cx.update(|_window, cx| {
9884 let metadata = ThreadMetadata {
9885 thread_id: ThreadId::new(),
9886 session_id: Some(remote_thread_id.clone()),
9887 agent_id: agent::ZED_AGENT_ID.clone(),
9888 title: Some("Worktree Thread".into()),
9889 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
9890 created_at: None,
9891 interacted_at: None,
9892 worktree_paths: WorktreePaths::from_path_lists(
9893 main_worktree_paths,
9894 PathList::new(&[PathBuf::from("/project-wt-1")]),
9895 )
9896 .unwrap(),
9897 archived: false,
9898 remote_connection,
9899 };
9900 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
9901 });
9902 cx.run_until_parked();
9903
9904 focus_sidebar(&sidebar, cx);
9905 sidebar.update_in(cx, |sidebar, _window, _cx| {
9906 sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
9907 matches!(
9908 entry,
9909 ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
9910 )
9911 });
9912 });
9913
9914 let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
9915 let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
9916
9917 sidebar
9918 .update(cx, |_, cx| {
9919 cx.observe_self(move |sidebar, _cx| {
9920 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
9921 if let ListEntry::ProjectHeader { label, .. } = entry {
9922 Some(label.as_ref())
9923 } else {
9924 None
9925 }
9926 });
9927
9928 let Some(project_header) = project_headers.next() else {
9929 saw_separate_project_header_for_observer
9930 .store(true, std::sync::atomic::Ordering::SeqCst);
9931 return;
9932 };
9933
9934 if project_header != "project" || project_headers.next().is_some() {
9935 saw_separate_project_header_for_observer
9936 .store(true, std::sync::atomic::Ordering::SeqCst);
9937 }
9938 })
9939 })
9940 .detach();
9941
9942 multi_workspace.update(cx, |multi_workspace, cx| {
9943 let workspace = multi_workspace.workspace().clone();
9944 workspace.update(cx, |workspace: &mut Workspace, cx| {
9945 let remote_client = workspace
9946 .project()
9947 .read(cx)
9948 .remote_client()
9949 .expect("main remote project should have a remote client");
9950 remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
9951 remote_client.force_server_not_running(cx);
9952 });
9953 });
9954 });
9955 cx.run_until_parked();
9956
9957 let (server_session_2, connect_guard_2) =
9958 remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
9959 let _headless_2 = server_cx.new(|cx| {
9960 remote_server::HeadlessProject::new(
9961 remote_server::HeadlessAppState {
9962 session: server_session_2,
9963 fs: server_fs.clone(),
9964 http_client: Arc::new(http_client::BlockedHttpClient),
9965 node_runtime: node_runtime::NodeRuntime::unavailable(),
9966 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
9967 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
9968 startup_time: std::time::Instant::now(),
9969 },
9970 false,
9971 cx,
9972 )
9973 });
9974 drop(connect_guard_2);
9975
9976 let window = cx.windows()[0];
9977 cx.update_window(window, |_, window, cx| {
9978 window.dispatch_action(Confirm.boxed_clone(), cx);
9979 })
9980 .unwrap();
9981
9982 cx.run_until_parked();
9983
9984 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
9985 assert_eq!(
9986 mw.workspaces().count(),
9987 2,
9988 "confirming a closed remote thread should open a second workspace"
9989 );
9990 mw.workspaces()
9991 .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
9992 .unwrap()
9993 .clone()
9994 });
9995
9996 server_fs
9997 .add_linked_worktree_for_repo(
9998 Path::new("/project/.git"),
9999 true,
10000 git::repository::Worktree {
10001 path: PathBuf::from("/project-wt-1"),
10002 ref_name: Some("refs/heads/feature-wt".into()),
10003 sha: "abc123".into(),
10004 is_main: false,
10005 is_bare: false,
10006 },
10007 )
10008 .await;
10009
10010 server_cx.run_until_parked();
10011 cx.run_until_parked();
10012 server_cx.run_until_parked();
10013 cx.run_until_parked();
10014
10015 let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10016 let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10017 workspace.project().read(cx).project_group_key(cx)
10018 });
10019
10020 assert_eq!(
10021 group_after_update,
10022 project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10023 "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10024 final sidebar entries: {:?}",
10025 entries_after_update,
10026 );
10027
10028 sidebar.update(cx, |sidebar, _cx| {
10029 assert_remote_project_integration_sidebar_state(
10030 sidebar,
10031 &main_thread_id,
10032 &remote_thread_id,
10033 );
10034 });
10035
10036 assert!(
10037 !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10038 "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10039 final group: {:?}; final sidebar entries: {:?}",
10040 group_after_update,
10041 entries_after_update,
10042 );
10043}
10044
10045#[gpui::test]
10046async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10047 // When the thread's folder_paths don't exactly match any workspace's
10048 // root paths (e.g. because a folder was added to the workspace after
10049 // the thread was created), workspace_to_remove is None. But the linked
10050 // worktree workspace still needs to be removed so that its worktree
10051 // entities are released, allowing git worktree removal to proceed.
10052 //
10053 // With the fix, archive_thread scans roots_to_archive for any linked
10054 // worktree workspaces and includes them in the removal set, even when
10055 // the thread's folder_paths don't match the workspace's root paths.
10056 init_test(cx);
10057 let fs = FakeFs::new(cx.executor());
10058
10059 fs.insert_tree(
10060 "/project",
10061 serde_json::json!({
10062 ".git": {
10063 "worktrees": {
10064 "feature-a": {
10065 "commondir": "../../",
10066 "HEAD": "ref: refs/heads/feature-a",
10067 },
10068 },
10069 },
10070 "src": {},
10071 }),
10072 )
10073 .await;
10074
10075 fs.insert_tree(
10076 "/worktrees/project/feature-a/project",
10077 serde_json::json!({
10078 ".git": "gitdir: /project/.git/worktrees/feature-a",
10079 "src": {
10080 "main.rs": "fn main() {}",
10081 },
10082 }),
10083 )
10084 .await;
10085
10086 fs.add_linked_worktree_for_repo(
10087 Path::new("/project/.git"),
10088 false,
10089 git::repository::Worktree {
10090 path: PathBuf::from("/worktrees/project/feature-a/project"),
10091 ref_name: Some("refs/heads/feature-a".into()),
10092 sha: "abc".into(),
10093 is_main: false,
10094 is_bare: false,
10095 },
10096 )
10097 .await;
10098
10099 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10100
10101 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10102 let worktree_project = project::Project::test(
10103 fs.clone(),
10104 ["/worktrees/project/feature-a/project".as_ref()],
10105 cx,
10106 )
10107 .await;
10108
10109 main_project
10110 .update(cx, |p, cx| p.git_scans_complete(cx))
10111 .await;
10112 worktree_project
10113 .update(cx, |p, cx| p.git_scans_complete(cx))
10114 .await;
10115
10116 let (multi_workspace, cx) =
10117 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10118 let sidebar = setup_sidebar(&multi_workspace, cx);
10119
10120 multi_workspace.update_in(cx, |mw, window, cx| {
10121 mw.test_add_workspace(worktree_project.clone(), window, cx)
10122 });
10123
10124 // Save thread metadata using folder_paths that DON'T match the
10125 // workspace's root paths. This simulates the case where the workspace's
10126 // paths diverged (e.g. a folder was added after thread creation).
10127 // This causes workspace_to_remove to be None because
10128 // workspace_for_paths can't find a workspace with these exact paths.
10129 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10130 save_thread_metadata_with_main_paths(
10131 "worktree-thread",
10132 "Worktree Thread",
10133 PathList::new(&[
10134 PathBuf::from("/worktrees/project/feature-a/project"),
10135 PathBuf::from("/nonexistent"),
10136 ]),
10137 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10138 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10139 cx,
10140 );
10141
10142 // Also save a main thread so the sidebar has something to show.
10143 save_thread_metadata(
10144 acp::SessionId::new(Arc::from("main-thread")),
10145 Some("Main Thread".into()),
10146 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10147 None,
10148 None,
10149 &main_project,
10150 cx,
10151 );
10152 cx.run_until_parked();
10153
10154 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10155 cx.run_until_parked();
10156
10157 assert_eq!(
10158 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10159 2,
10160 "should start with 2 workspaces (main + linked worktree)"
10161 );
10162
10163 // Archive the worktree thread.
10164 sidebar.update_in(cx, |sidebar, window, cx| {
10165 sidebar.archive_thread(&wt_thread_id, window, cx);
10166 });
10167
10168 cx.run_until_parked();
10169
10170 // The linked worktree workspace should have been removed, even though
10171 // workspace_to_remove was None (paths didn't match).
10172 assert_eq!(
10173 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10174 1,
10175 "linked worktree workspace should be removed after archiving, \
10176 even when folder_paths don't match workspace root paths"
10177 );
10178
10179 // The thread should still be archived (not unarchived due to an error).
10180 let still_archived = cx.update(|_, cx| {
10181 ThreadMetadataStore::global(cx)
10182 .read(cx)
10183 .entry_by_session(&wt_thread_id)
10184 .map(|t| t.archived)
10185 });
10186 assert_eq!(
10187 still_archived,
10188 Some(true),
10189 "thread should still be archived (not rolled back due to error)"
10190 );
10191
10192 // The linked worktree directory should be removed from disk.
10193 assert!(
10194 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
10195 .await,
10196 "linked worktree directory should be removed from disk"
10197 );
10198}
10199
10200#[gpui::test]
10201async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10202 // When a workspace contains both a worktree being archived and other
10203 // worktrees that should remain, only the editor items referencing the
10204 // archived worktree should be closed — the workspace itself must be
10205 // preserved.
10206 init_test(cx);
10207 let fs = FakeFs::new(cx.executor());
10208
10209 fs.insert_tree(
10210 "/main-repo",
10211 serde_json::json!({
10212 ".git": {
10213 "worktrees": {
10214 "feature-b": {
10215 "commondir": "../../",
10216 "HEAD": "ref: refs/heads/feature-b",
10217 },
10218 },
10219 },
10220 "src": {
10221 "lib.rs": "pub fn hello() {}",
10222 },
10223 }),
10224 )
10225 .await;
10226
10227 fs.insert_tree(
10228 "/worktrees/main-repo/feature-b/main-repo",
10229 serde_json::json!({
10230 ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10231 "src": {
10232 "main.rs": "fn main() { hello(); }",
10233 },
10234 }),
10235 )
10236 .await;
10237
10238 fs.add_linked_worktree_for_repo(
10239 Path::new("/main-repo/.git"),
10240 false,
10241 git::repository::Worktree {
10242 path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10243 ref_name: Some("refs/heads/feature-b".into()),
10244 sha: "def".into(),
10245 is_main: false,
10246 is_bare: false,
10247 },
10248 )
10249 .await;
10250
10251 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10252
10253 // Create a single project that contains BOTH the main repo and the
10254 // linked worktree — this makes it a "mixed" workspace.
10255 let mixed_project = project::Project::test(
10256 fs.clone(),
10257 [
10258 "/main-repo".as_ref(),
10259 "/worktrees/main-repo/feature-b/main-repo".as_ref(),
10260 ],
10261 cx,
10262 )
10263 .await;
10264
10265 mixed_project
10266 .update(cx, |p, cx| p.git_scans_complete(cx))
10267 .await;
10268
10269 let (multi_workspace, cx) = cx
10270 .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10271 let sidebar = setup_sidebar(&multi_workspace, cx);
10272
10273 // Open editor items in both worktrees so we can verify which ones
10274 // get closed.
10275 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10276
10277 let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10278 ws.project()
10279 .read(cx)
10280 .visible_worktrees(cx)
10281 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10282 .collect()
10283 });
10284
10285 let main_repo_wt_id = worktree_ids
10286 .iter()
10287 .find(|(_, path)| path.as_ref() == Path::new("/main-repo"))
10288 .map(|(id, _)| *id)
10289 .expect("should find main-repo worktree");
10290
10291 let feature_b_wt_id = worktree_ids
10292 .iter()
10293 .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo"))
10294 .map(|(id, _)| *id)
10295 .expect("should find feature-b worktree");
10296
10297 // Open files from both worktrees.
10298 let main_repo_path = project::ProjectPath {
10299 worktree_id: main_repo_wt_id,
10300 path: Arc::from(rel_path("src/lib.rs")),
10301 };
10302 let feature_b_path = project::ProjectPath {
10303 worktree_id: feature_b_wt_id,
10304 path: Arc::from(rel_path("src/main.rs")),
10305 };
10306
10307 workspace
10308 .update_in(cx, |ws, window, cx| {
10309 ws.open_path(main_repo_path.clone(), None, true, window, cx)
10310 })
10311 .await
10312 .expect("should open main-repo file");
10313 workspace
10314 .update_in(cx, |ws, window, cx| {
10315 ws.open_path(feature_b_path.clone(), None, true, window, cx)
10316 })
10317 .await
10318 .expect("should open feature-b file");
10319
10320 cx.run_until_parked();
10321
10322 // Verify both items are open.
10323 let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10324 ws.panes()
10325 .iter()
10326 .flat_map(|pane| {
10327 pane.read(cx)
10328 .items()
10329 .filter_map(|item| item.project_path(cx))
10330 })
10331 .collect()
10332 });
10333 assert!(
10334 open_paths_before
10335 .iter()
10336 .any(|pp| pp.worktree_id == main_repo_wt_id),
10337 "main-repo file should be open"
10338 );
10339 assert!(
10340 open_paths_before
10341 .iter()
10342 .any(|pp| pp.worktree_id == feature_b_wt_id),
10343 "feature-b file should be open"
10344 );
10345
10346 // Save thread metadata for the linked worktree with deliberately
10347 // mismatched folder_paths to trigger the scan-based detection.
10348 save_thread_metadata_with_main_paths(
10349 "feature-b-thread",
10350 "Feature B Thread",
10351 PathList::new(&[
10352 PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10353 PathBuf::from("/nonexistent"),
10354 ]),
10355 PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10356 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10357 cx,
10358 );
10359
10360 // Save another thread that references only the main repo (not the
10361 // linked worktree) so archiving the feature-b thread's worktree isn't
10362 // blocked by another unarchived thread referencing the same path.
10363 save_thread_metadata_with_main_paths(
10364 "other-thread",
10365 "Other Thread",
10366 PathList::new(&[PathBuf::from("/main-repo")]),
10367 PathList::new(&[PathBuf::from("/main-repo")]),
10368 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10369 cx,
10370 );
10371 cx.run_until_parked();
10372
10373 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10374 cx.run_until_parked();
10375
10376 // There should still be exactly 1 workspace.
10377 assert_eq!(
10378 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10379 1,
10380 "should have 1 workspace (the mixed workspace)"
10381 );
10382
10383 // Archive the feature-b thread.
10384 let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10385 sidebar.update_in(cx, |sidebar, window, cx| {
10386 sidebar.archive_thread(&fb_session_id, window, cx);
10387 });
10388
10389 cx.run_until_parked();
10390
10391 // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10392 assert_eq!(
10393 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10394 1,
10395 "mixed workspace should be preserved"
10396 );
10397
10398 // Only the feature-b editor item should have been closed.
10399 let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10400 ws.panes()
10401 .iter()
10402 .flat_map(|pane| {
10403 pane.read(cx)
10404 .items()
10405 .filter_map(|item| item.project_path(cx))
10406 })
10407 .collect()
10408 });
10409 assert!(
10410 open_paths_after
10411 .iter()
10412 .any(|pp| pp.worktree_id == main_repo_wt_id),
10413 "main-repo file should still be open"
10414 );
10415 assert!(
10416 !open_paths_after
10417 .iter()
10418 .any(|pp| pp.worktree_id == feature_b_wt_id),
10419 "feature-b file should have been closed"
10420 );
10421}
10422
10423#[test]
10424fn test_worktree_info_branch_names_for_main_worktrees() {
10425 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10426 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10427
10428 let branch_by_path: HashMap<PathBuf, SharedString> =
10429 [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10430 .into_iter()
10431 .collect();
10432
10433 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10434 assert_eq!(infos.len(), 1);
10435 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10436 assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10437 assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10438}
10439
10440#[test]
10441fn test_worktree_info_branch_names_for_linked_worktrees() {
10442 let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10443 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10444 let worktree_paths =
10445 WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10446
10447 let branch_by_path: HashMap<PathBuf, SharedString> = [(
10448 PathBuf::from("/projects/myapp-feature"),
10449 "feature-branch".into(),
10450 )]
10451 .into_iter()
10452 .collect();
10453
10454 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10455 assert_eq!(infos.len(), 1);
10456 assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10457 assert_eq!(
10458 infos[0].branch_name,
10459 Some(SharedString::from("feature-branch"))
10460 );
10461}
10462
10463#[test]
10464fn test_worktree_info_missing_branch_returns_none() {
10465 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10466 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10467
10468 let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10469
10470 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10471 assert_eq!(infos.len(), 1);
10472 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10473 assert_eq!(infos[0].branch_name, None);
10474 assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10475}
10476
10477#[gpui::test]
10478async fn test_remote_archive_thread_with_active_connection(
10479 cx: &mut TestAppContext,
10480 server_cx: &mut TestAppContext,
10481) {
10482 // End-to-end test of archiving a remote thread tied to a linked git
10483 // worktree. Archival should:
10484 // 1. Persist the worktree's git state via the remote repository RPCs
10485 // (head_sha / create_archive_checkpoint / update_ref).
10486 // 2. Remove the linked worktree directory from the *remote* filesystem
10487 // via the GitRemoveWorktree RPC.
10488 // 3. Mark the thread metadata archived and hide it from the sidebar.
10489 //
10490 // The mock remote transport only supports one live `RemoteClient` per
10491 // connection at a time (each client's `start_proxy` replaces the
10492 // previous server channel), so we can't split the main repo and the
10493 // linked worktree across two remote projects the way Zed does in
10494 // production. Opening both as visible worktrees of a single remote
10495 // project still exercises every interesting path of the archive flow
10496 // while staying within the mock's multiplexing limits.
10497 init_test(cx);
10498
10499 cx.update(|cx| {
10500 release_channel::init(semver::Version::new(0, 0, 0), cx);
10501 });
10502
10503 let app_state = cx.update(|cx| {
10504 let app_state = workspace::AppState::test(cx);
10505 workspace::init(app_state.clone(), cx);
10506 app_state
10507 });
10508
10509 server_cx.update(|cx| {
10510 release_channel::init(semver::Version::new(0, 0, 0), cx);
10511 });
10512
10513 // Set up the remote filesystem with a main repo and one linked worktree.
10514 let server_fs = FakeFs::new(server_cx.executor());
10515 server_fs
10516 .insert_tree(
10517 "/project",
10518 serde_json::json!({
10519 ".git": {
10520 "worktrees": {
10521 "feature-a": {
10522 "commondir": "../../",
10523 "HEAD": "ref: refs/heads/feature-a",
10524 },
10525 },
10526 },
10527 "src": { "main.rs": "fn main() {}" },
10528 }),
10529 )
10530 .await;
10531 server_fs
10532 .insert_tree(
10533 "/worktrees/project/feature-a/project",
10534 serde_json::json!({
10535 ".git": "gitdir: /project/.git/worktrees/feature-a",
10536 "src": { "lib.rs": "// feature" },
10537 }),
10538 )
10539 .await;
10540 server_fs
10541 .add_linked_worktree_for_repo(
10542 Path::new("/project/.git"),
10543 false,
10544 git::repository::Worktree {
10545 path: PathBuf::from("/worktrees/project/feature-a/project"),
10546 ref_name: Some("refs/heads/feature-a".into()),
10547 sha: "abc".into(),
10548 is_main: false,
10549 is_bare: false,
10550 },
10551 )
10552 .await;
10553 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10554 server_fs.set_head_for_repo(
10555 Path::new("/project/.git"),
10556 &[("src/main.rs", "fn main() {}".into())],
10557 "head-sha",
10558 );
10559
10560 // Open a single remote project with both the main repo and the linked
10561 // worktree as visible worktrees. The mock transport doesn't multiplex
10562 // multiple `RemoteClient`s over one pooled connection cleanly (each
10563 // client's `start_proxy` clobbers the previous one's server channel),
10564 // so we can't build two separate `Project::remote` instances in this
10565 // test. Folding both worktrees into one project still exercises the
10566 // archive flow's interesting paths: `build_root_plan` classifies the
10567 // linked worktree correctly, and `find_or_create_repository` finds
10568 // the main repo live on that same project — avoiding the temp-project
10569 // fallback that would also run into the multiplexing limitation.
10570 let (project, _headless, _opts) = start_remote_project(
10571 &server_fs,
10572 Path::new("/project"),
10573 &app_state,
10574 None,
10575 cx,
10576 server_cx,
10577 )
10578 .await;
10579 project
10580 .update(cx, |project, cx| {
10581 project.find_or_create_worktree(
10582 Path::new("/worktrees/project/feature-a/project"),
10583 true,
10584 cx,
10585 )
10586 })
10587 .await
10588 .expect("should open linked worktree on remote");
10589 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10590 cx.run_until_parked();
10591
10592 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10593
10594 let (multi_workspace, cx) =
10595 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10596 let sidebar = setup_sidebar(&multi_workspace, cx);
10597
10598 // The worktree thread's (main_worktree_path, folder_path) pair points
10599 // the folder at the linked worktree checkout and the main at the
10600 // parent repo, so `build_root_plan` targets the linked worktree
10601 // specifically and knows which main repo owns it.
10602 let remote_connection = project.read_with(cx, |p, cx| p.remote_connection_options(cx));
10603 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10604 cx.update(|_window, cx| {
10605 let metadata = ThreadMetadata {
10606 thread_id: ThreadId::new(),
10607 session_id: Some(wt_thread_id.clone()),
10608 agent_id: agent::ZED_AGENT_ID.clone(),
10609 title: Some("Worktree Thread".into()),
10610 updated_at: chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
10611 .unwrap(),
10612 created_at: None,
10613 interacted_at: None,
10614 worktree_paths: WorktreePaths::from_path_lists(
10615 PathList::new(&[PathBuf::from("/project")]),
10616 PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]),
10617 )
10618 .unwrap(),
10619 archived: false,
10620 remote_connection,
10621 };
10622 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10623 });
10624 cx.run_until_parked();
10625
10626 assert!(
10627 server_fs
10628 .is_dir(Path::new("/worktrees/project/feature-a/project"))
10629 .await,
10630 "linked worktree directory should exist on remote before archiving"
10631 );
10632
10633 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
10634 sidebar.archive_thread(&wt_thread_id, window, cx);
10635 });
10636 cx.run_until_parked();
10637 server_cx.run_until_parked();
10638
10639 let is_archived = cx.update(|_window, cx| {
10640 ThreadMetadataStore::global(cx)
10641 .read(cx)
10642 .entry_by_session(&wt_thread_id)
10643 .map(|t| t.archived)
10644 .unwrap_or(false)
10645 });
10646 assert!(is_archived, "worktree thread should be archived");
10647
10648 assert!(
10649 !server_fs
10650 .is_dir(Path::new("/worktrees/project/feature-a/project"))
10651 .await,
10652 "linked worktree directory should be removed from remote fs \
10653 (the GitRemoveWorktree RPC runs `Repository::remove_worktree` \
10654 on the headless server, which deletes the directory via `Fs::remove_dir` \
10655 before running `git worktree remove --force`)"
10656 );
10657
10658 let entries = visible_entries_as_strings(&sidebar, cx);
10659 assert!(
10660 !entries.iter().any(|e| e.contains("Worktree Thread")),
10661 "archived worktree thread should be hidden from sidebar: {entries:?}"
10662 );
10663}
10664
10665#[gpui::test]
10666async fn test_remote_archive_thread_with_disconnected_remote(
10667 cx: &mut TestAppContext,
10668 server_cx: &mut TestAppContext,
10669) {
10670 // When a remote thread has no linked-worktree state to archive (only
10671 // a main worktree), archival is a pure metadata operation: no RPCs
10672 // are issued against the remote server. This must succeed even when
10673 // the connection has dropped out, because losing connectivity should
10674 // not block users from cleaning up their thread list.
10675 //
10676 // Threads that *do* have linked-worktree state require a live
10677 // connection to run the git worktree removal on the server; that
10678 // path is covered by `test_remote_archive_thread_with_active_connection`.
10679 init_test(cx);
10680
10681 cx.update(|cx| {
10682 release_channel::init(semver::Version::new(0, 0, 0), cx);
10683 });
10684
10685 let app_state = cx.update(|cx| {
10686 let app_state = workspace::AppState::test(cx);
10687 workspace::init(app_state.clone(), cx);
10688 app_state
10689 });
10690
10691 server_cx.update(|cx| {
10692 release_channel::init(semver::Version::new(0, 0, 0), cx);
10693 });
10694
10695 let server_fs = FakeFs::new(server_cx.executor());
10696 server_fs
10697 .insert_tree(
10698 "/project",
10699 serde_json::json!({
10700 ".git": {},
10701 "src": { "main.rs": "fn main() {}" },
10702 }),
10703 )
10704 .await;
10705 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10706
10707 let (project, _headless, _opts) = start_remote_project(
10708 &server_fs,
10709 Path::new("/project"),
10710 &app_state,
10711 None,
10712 cx,
10713 server_cx,
10714 )
10715 .await;
10716 let remote_client = project
10717 .read_with(cx, |project, _cx| project.remote_client())
10718 .expect("remote project should expose its client");
10719
10720 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10721
10722 let (multi_workspace, cx) =
10723 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10724 let sidebar = setup_sidebar(&multi_workspace, cx);
10725
10726 let thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10727 save_thread_metadata(
10728 thread_id.clone(),
10729 Some("Remote Thread".into()),
10730 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10731 None,
10732 None,
10733 &project,
10734 cx,
10735 );
10736 cx.run_until_parked();
10737
10738 // Sanity-check: there is nothing on the remote fs outside the main
10739 // repo, so archival should not need to touch the server.
10740 assert!(
10741 !server_fs.is_dir(Path::new("/worktrees")).await,
10742 "no linked worktrees on the server before archiving"
10743 );
10744
10745 // Disconnect the remote connection before archiving. We don't
10746 // `run_until_parked` here because the disconnect itself triggers
10747 // reconnection work that can't complete in the test environment.
10748 remote_client.update_in(cx, |client, _window, cx| {
10749 client.simulate_disconnect(cx).detach();
10750 });
10751
10752 sidebar.update_in(cx, |sidebar, window, cx| {
10753 sidebar.archive_thread(&thread_id, window, cx);
10754 });
10755 cx.run_until_parked();
10756
10757 let is_archived = cx.update(|_window, cx| {
10758 ThreadMetadataStore::global(cx)
10759 .read(cx)
10760 .entry_by_session(&thread_id)
10761 .map(|t| t.archived)
10762 .unwrap_or(false)
10763 });
10764 assert!(
10765 is_archived,
10766 "thread should be archived even when remote is disconnected"
10767 );
10768
10769 let entries = visible_entries_as_strings(&sidebar, cx);
10770 assert!(
10771 !entries.iter().any(|e| e.contains("Remote Thread")),
10772 "archived thread should be hidden from sidebar: {entries:?}"
10773 );
10774}
10775
10776#[gpui::test]
10777async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) {
10778 init_test(cx);
10779 let fs = FakeFs::new(cx.executor());
10780 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
10781 .await;
10782 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
10783 .await;
10784 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10785 let project = project::Project::test(fs, ["/project-a".as_ref()], cx).await;
10786
10787 let (multi_workspace, cx) =
10788 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10789
10790 // Set up the sidebar while the project is local. This registers the
10791 // WorktreePathsChanged subscription for the project.
10792 let _sidebar = setup_sidebar(&multi_workspace, cx);
10793
10794 let session_id = acp::SessionId::new(Arc::from("test-thread"));
10795 save_named_thread_metadata("test-thread", "My Thread", &project, cx).await;
10796
10797 let thread_id = cx.update(|_window, cx| {
10798 ThreadMetadataStore::global(cx)
10799 .read(cx)
10800 .entry_by_session(&session_id)
10801 .map(|e| e.thread_id)
10802 .expect("thread must be in the store")
10803 });
10804
10805 cx.update(|_window, cx| {
10806 let store = ThreadMetadataStore::global(cx);
10807 let entry = store.read(cx).entry(thread_id).unwrap();
10808 assert_eq!(
10809 entry.folder_paths().paths(),
10810 &[PathBuf::from("/project-a")],
10811 "thread must be saved with /project-a before collab"
10812 );
10813 });
10814
10815 // Transition the project into collab mode. The sidebar's subscription is
10816 // still active from when the project was local.
10817 project.update(cx, |project, _cx| {
10818 project.mark_as_collab_for_testing();
10819 });
10820
10821 // Adding a worktree fires WorktreePathsChanged with old_paths = {/project-a}.
10822 // The sidebar's subscription is still active, so move_thread_paths is called.
10823 // Without the is_via_collab() guard inside move_thread_paths, this would
10824 // update the stored thread paths from {/project-a} to {/project-a, /project-b}.
10825 project
10826 .update(cx, |project, cx| {
10827 project.find_or_create_worktree("/project-b", true, cx)
10828 })
10829 .await
10830 .expect("should add worktree");
10831 cx.run_until_parked();
10832
10833 cx.update(|_window, cx| {
10834 let store = ThreadMetadataStore::global(cx);
10835 let entry = store
10836 .read(cx)
10837 .entry(thread_id)
10838 .expect("thread must still exist");
10839 assert_eq!(
10840 entry.folder_paths().paths(),
10841 &[PathBuf::from("/project-a")],
10842 "thread path must not change when project is via collab"
10843 );
10844 });
10845}
10846
10847#[gpui::test]
10848async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace(
10849 cx: &mut TestAppContext,
10850) {
10851 // Regression test for: cmd-clicking a project group header should return
10852 // the user to the workspace they most recently had active in that group,
10853 // including workspaces rooted at a linked worktree.
10854 init_test(cx);
10855 let fs = FakeFs::new(cx.executor());
10856
10857 fs.insert_tree(
10858 "/project-a",
10859 serde_json::json!({
10860 ".git": {},
10861 "src": {},
10862 }),
10863 )
10864 .await;
10865 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
10866 .await;
10867
10868 fs.add_linked_worktree_for_repo(
10869 Path::new("/project-a/.git"),
10870 false,
10871 git::repository::Worktree {
10872 path: std::path::PathBuf::from("/wt-feature-a"),
10873 ref_name: Some("refs/heads/feature-a".into()),
10874 sha: "aaa".into(),
10875 is_main: false,
10876 is_bare: false,
10877 },
10878 )
10879 .await;
10880
10881 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10882
10883 let main_project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
10884 let worktree_project_a =
10885 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
10886 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
10887
10888 main_project_a
10889 .update(cx, |p, cx| p.git_scans_complete(cx))
10890 .await;
10891 worktree_project_a
10892 .update(cx, |p, cx| p.git_scans_complete(cx))
10893 .await;
10894
10895 // The multi-workspace starts with the main-paths workspace of group A
10896 // as the initially active workspace.
10897 let (multi_workspace, cx) = cx
10898 .add_window_view(|window, cx| MultiWorkspace::test_new(main_project_a.clone(), window, cx));
10899
10900 let sidebar = setup_sidebar(&multi_workspace, cx);
10901
10902 // Capture the initially active workspace (group A's main-paths workspace)
10903 // *before* registering additional workspaces, since `workspaces()` returns
10904 // retained workspaces in registration order — not activation order — and
10905 // the multi-workspace's starting workspace may not be retained yet.
10906 let main_workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10907
10908 // Register the linked-worktree workspace (group A) and the group-B
10909 // workspace. Both get retained by the multi-workspace.
10910 let worktree_workspace_a = multi_workspace.update_in(cx, |mw, window, cx| {
10911 mw.test_add_workspace(worktree_project_a.clone(), window, cx)
10912 });
10913 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
10914 mw.test_add_workspace(project_b.clone(), window, cx)
10915 });
10916
10917 cx.run_until_parked();
10918
10919 // Step 1: activate the linked-worktree workspace. The MultiWorkspace
10920 // records this as the last-active workspace for group A on its
10921 // ProjectGroupState. (We don't assert on the initial active workspace
10922 // because `test_add_workspace` may auto-activate newly registered
10923 // workspaces — what matters for this test is the explicit sequence of
10924 // activations below.)
10925 multi_workspace.update_in(cx, |mw, window, cx| {
10926 mw.activate(worktree_workspace_a.clone(), None, window, cx);
10927 });
10928 cx.run_until_parked();
10929 assert_eq!(
10930 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
10931 worktree_workspace_a,
10932 "linked-worktree workspace should be active after step 1"
10933 );
10934
10935 // Step 2: switch to the workspace for group B. Group A's last-active
10936 // workspace remains the linked-worktree one (group B getting activated
10937 // records *its own* last-active workspace, not group A's).
10938 multi_workspace.update_in(cx, |mw, window, cx| {
10939 mw.activate(workspace_b.clone(), None, window, cx);
10940 });
10941 cx.run_until_parked();
10942 assert_eq!(
10943 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
10944 workspace_b,
10945 "group B's workspace should be active after step 2"
10946 );
10947
10948 // Step 3: simulate cmd-click on group A's header. The project group key
10949 // for group A is derived from the *main-paths* workspace (linked-worktree
10950 // workspaces share the same key because it normalizes to main-worktree
10951 // paths).
10952 let group_a_key = main_workspace_a.read_with(cx, |ws, cx| ws.project_group_key(cx));
10953 sidebar.update_in(cx, |sidebar, window, cx| {
10954 sidebar.activate_or_open_workspace_for_group(&group_a_key, window, cx);
10955 });
10956 cx.run_until_parked();
10957
10958 // Expected: we're back in the linked-worktree workspace, not the
10959 // main-paths one.
10960 let active_after_cmd_click = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10961 assert_eq!(
10962 active_after_cmd_click, worktree_workspace_a,
10963 "cmd-click on group A's header should return to the last-active \
10964 linked-worktree workspace, not the main-paths workspace"
10965 );
10966 assert_ne!(
10967 active_after_cmd_click, main_workspace_a,
10968 "cmd-click must not fall back to the main-paths workspace when a \
10969 linked-worktree workspace was the last-active one for the group"
10970 );
10971}