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_from_search_focuses_first_thread(cx: &mut TestAppContext) {
1683 // Scenario: A user searches, finds what they need, then presses Escape
1684 // in the search field to hand keyboard control back to the thread list.
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 // First Escape clears the search text, restoring the full list.
1727 // Focus stays on the filter editor.
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",
1736 " Beta thread",
1737 ]
1738 );
1739 sidebar.update_in(cx, |sidebar, window, cx| {
1740 assert!(sidebar.filter_editor.read(cx).is_focused(window));
1741 assert!(!sidebar.focus_handle.is_focused(window));
1742 });
1743
1744 // Second Escape moves focus from the empty search field to the thread list.
1745 cx.dispatch_action(Cancel);
1746 cx.run_until_parked();
1747 sidebar.update_in(cx, |sidebar, window, cx| {
1748 assert_eq!(sidebar.selection, Some(1));
1749 assert!(sidebar.focus_handle.is_focused(window));
1750 assert!(!sidebar.filter_editor.read(cx).is_focused(window));
1751 });
1752}
1753
1754#[gpui::test]
1755async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
1756 let project_a = init_test_project("/project-a", cx).await;
1757 let (multi_workspace, cx) =
1758 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1759 let sidebar = setup_sidebar(&multi_workspace, cx);
1760
1761 for (id, title, hour) in [
1762 ("a1", "Fix bug in sidebar", 2),
1763 ("a2", "Add tests for editor", 1),
1764 ] {
1765 save_thread_metadata(
1766 acp::SessionId::new(Arc::from(id)),
1767 Some(title.into()),
1768 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1769 None,
1770 None,
1771 &project_a,
1772 cx,
1773 )
1774 }
1775
1776 // Add a second workspace.
1777 multi_workspace.update_in(cx, |mw, window, cx| {
1778 mw.create_test_workspace(window, cx).detach();
1779 });
1780 cx.run_until_parked();
1781
1782 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1783 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1784 });
1785
1786 for (id, title, hour) in [
1787 ("b1", "Refactor sidebar layout", 3),
1788 ("b2", "Fix typo in README", 1),
1789 ] {
1790 save_thread_metadata(
1791 acp::SessionId::new(Arc::from(id)),
1792 Some(title.into()),
1793 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1794 None,
1795 None,
1796 &project_b,
1797 cx,
1798 )
1799 }
1800 cx.run_until_parked();
1801
1802 assert_eq!(
1803 visible_entries_as_strings(&sidebar, cx),
1804 vec![
1805 //
1806 "v [project-a]",
1807 " Fix bug in sidebar",
1808 " Add tests for editor",
1809 ]
1810 );
1811
1812 // "sidebar" matches a thread in each workspace — both headers stay visible.
1813 type_in_search(&sidebar, "sidebar", cx);
1814 assert_eq!(
1815 visible_entries_as_strings(&sidebar, cx),
1816 vec![
1817 //
1818 "v [project-a]",
1819 " Fix bug in sidebar <== selected",
1820 ]
1821 );
1822
1823 // "typo" only matches in the second workspace — the first header disappears.
1824 type_in_search(&sidebar, "typo", cx);
1825 assert_eq!(
1826 visible_entries_as_strings(&sidebar, cx),
1827 Vec::<String>::new()
1828 );
1829
1830 // "project-a" matches the first workspace name — the header appears
1831 // with all child threads included.
1832 type_in_search(&sidebar, "project-a", cx);
1833 assert_eq!(
1834 visible_entries_as_strings(&sidebar, cx),
1835 vec![
1836 //
1837 "v [project-a]",
1838 " Fix bug in sidebar <== selected",
1839 " Add tests for editor",
1840 ]
1841 );
1842}
1843
1844#[gpui::test]
1845async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
1846 let project_a = init_test_project("/alpha-project", cx).await;
1847 let (multi_workspace, cx) =
1848 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1849 let sidebar = setup_sidebar(&multi_workspace, cx);
1850
1851 for (id, title, hour) in [
1852 ("a1", "Fix bug in sidebar", 2),
1853 ("a2", "Add tests for editor", 1),
1854 ] {
1855 save_thread_metadata(
1856 acp::SessionId::new(Arc::from(id)),
1857 Some(title.into()),
1858 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1859 None,
1860 None,
1861 &project_a,
1862 cx,
1863 )
1864 }
1865
1866 // Add a second workspace.
1867 multi_workspace.update_in(cx, |mw, window, cx| {
1868 mw.create_test_workspace(window, cx).detach();
1869 });
1870 cx.run_until_parked();
1871
1872 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1873 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1874 });
1875
1876 for (id, title, hour) in [
1877 ("b1", "Refactor sidebar layout", 3),
1878 ("b2", "Fix typo in README", 1),
1879 ] {
1880 save_thread_metadata(
1881 acp::SessionId::new(Arc::from(id)),
1882 Some(title.into()),
1883 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1884 None,
1885 None,
1886 &project_b,
1887 cx,
1888 )
1889 }
1890 cx.run_until_parked();
1891
1892 // "alpha" matches the workspace name "alpha-project" but no thread titles.
1893 // The workspace header should appear with all child threads included.
1894 type_in_search(&sidebar, "alpha", 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 " Add tests for editor",
1902 ]
1903 );
1904
1905 // "sidebar" matches thread titles in both workspaces but not workspace names.
1906 // Both headers appear with their matching threads.
1907 type_in_search(&sidebar, "sidebar", cx);
1908 assert_eq!(
1909 visible_entries_as_strings(&sidebar, cx),
1910 vec![
1911 //
1912 "v [alpha-project]",
1913 " Fix bug in sidebar <== selected",
1914 ]
1915 );
1916
1917 // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
1918 // doesn't match) — but does not match either workspace name or any thread.
1919 // Actually let's test something simpler: a query that matches both a workspace
1920 // name AND some threads in that workspace. Matching threads should still appear.
1921 type_in_search(&sidebar, "fix", cx);
1922 assert_eq!(
1923 visible_entries_as_strings(&sidebar, cx),
1924 vec![
1925 //
1926 "v [alpha-project]",
1927 " Fix bug in sidebar <== selected",
1928 ]
1929 );
1930
1931 // A query that matches a workspace name AND a thread in that same workspace.
1932 // Both the header (highlighted) and all child threads should appear.
1933 type_in_search(&sidebar, "alpha", cx);
1934 assert_eq!(
1935 visible_entries_as_strings(&sidebar, cx),
1936 vec![
1937 //
1938 "v [alpha-project]",
1939 " Fix bug in sidebar <== selected",
1940 " Add tests for editor",
1941 ]
1942 );
1943
1944 // Now search for something that matches only a workspace name when there
1945 // are also threads with matching titles — the non-matching workspace's
1946 // threads should still appear if their titles match.
1947 type_in_search(&sidebar, "alp", cx);
1948 assert_eq!(
1949 visible_entries_as_strings(&sidebar, cx),
1950 vec![
1951 //
1952 "v [alpha-project]",
1953 " Fix bug in sidebar <== selected",
1954 " Add tests for editor",
1955 ]
1956 );
1957}
1958
1959#[gpui::test]
1960async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
1961 let project = init_test_project("/my-project", cx).await;
1962 let (multi_workspace, cx) =
1963 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1964 let sidebar = setup_sidebar(&multi_workspace, cx);
1965
1966 save_thread_metadata(
1967 acp::SessionId::new(Arc::from("thread-1")),
1968 Some("Important thread".into()),
1969 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1970 None,
1971 None,
1972 &project,
1973 cx,
1974 );
1975 cx.run_until_parked();
1976
1977 // User focuses the sidebar and collapses the group using keyboard:
1978 // manually select the header, then press SelectParent to collapse.
1979 focus_sidebar(&sidebar, cx);
1980 sidebar.update_in(cx, |sidebar, _window, _cx| {
1981 sidebar.selection = Some(0);
1982 });
1983 cx.dispatch_action(SelectParent);
1984 cx.run_until_parked();
1985
1986 assert_eq!(
1987 visible_entries_as_strings(&sidebar, cx),
1988 vec![
1989 //
1990 "> [my-project] <== selected",
1991 ]
1992 );
1993
1994 // User types a search — the thread appears even though its group is collapsed.
1995 type_in_search(&sidebar, "important", cx);
1996 assert_eq!(
1997 visible_entries_as_strings(&sidebar, cx),
1998 vec![
1999 //
2000 "> [my-project]",
2001 " Important thread <== selected",
2002 ]
2003 );
2004}
2005
2006#[gpui::test]
2007async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
2008 let project = init_test_project("/my-project", cx).await;
2009 let (multi_workspace, cx) =
2010 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2011 let sidebar = setup_sidebar(&multi_workspace, cx);
2012
2013 for (id, title, hour) in [
2014 ("t-1", "Fix crash in panel", 3),
2015 ("t-2", "Fix lint warnings", 2),
2016 ("t-3", "Add new feature", 1),
2017 ] {
2018 save_thread_metadata(
2019 acp::SessionId::new(Arc::from(id)),
2020 Some(title.into()),
2021 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2022 None,
2023 None,
2024 &project,
2025 cx,
2026 )
2027 }
2028 cx.run_until_parked();
2029
2030 focus_sidebar(&sidebar, cx);
2031
2032 // User types "fix" — two threads match.
2033 type_in_search(&sidebar, "fix", cx);
2034 assert_eq!(
2035 visible_entries_as_strings(&sidebar, cx),
2036 vec![
2037 //
2038 "v [my-project]",
2039 " Fix crash in panel <== selected",
2040 " Fix lint warnings",
2041 ]
2042 );
2043
2044 // Selection starts on the first matching thread. User presses
2045 // SelectNext to move to the second match.
2046 cx.dispatch_action(SelectNext);
2047 assert_eq!(
2048 visible_entries_as_strings(&sidebar, cx),
2049 vec![
2050 //
2051 "v [my-project]",
2052 " Fix crash in panel",
2053 " Fix lint warnings <== selected",
2054 ]
2055 );
2056
2057 // User can also jump back with SelectPrevious.
2058 cx.dispatch_action(SelectPrevious);
2059 assert_eq!(
2060 visible_entries_as_strings(&sidebar, cx),
2061 vec![
2062 //
2063 "v [my-project]",
2064 " Fix crash in panel <== selected",
2065 " Fix lint warnings",
2066 ]
2067 );
2068}
2069
2070#[gpui::test]
2071async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
2072 let project = init_test_project("/my-project", cx).await;
2073 let (multi_workspace, cx) =
2074 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2075 let sidebar = setup_sidebar(&multi_workspace, cx);
2076
2077 multi_workspace.update_in(cx, |mw, window, cx| {
2078 mw.create_test_workspace(window, cx).detach();
2079 });
2080 cx.run_until_parked();
2081
2082 let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
2083 (
2084 mw.workspaces().next().unwrap().clone(),
2085 mw.workspaces().nth(1).unwrap().clone(),
2086 )
2087 });
2088
2089 save_thread_metadata(
2090 acp::SessionId::new(Arc::from("hist-1")),
2091 Some("Historical Thread".into()),
2092 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2093 None,
2094 None,
2095 &project,
2096 cx,
2097 );
2098 cx.run_until_parked();
2099 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2100 cx.run_until_parked();
2101
2102 assert_eq!(
2103 visible_entries_as_strings(&sidebar, cx),
2104 vec![
2105 //
2106 "v [my-project]",
2107 " Historical Thread",
2108 ]
2109 );
2110
2111 // Switch to workspace 1 so we can verify the confirm switches back.
2112 multi_workspace.update_in(cx, |mw, window, cx| {
2113 let workspace = mw.workspaces().nth(1).unwrap().clone();
2114 mw.activate(workspace, None, window, cx);
2115 });
2116 cx.run_until_parked();
2117 assert_eq!(
2118 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2119 workspace_1
2120 );
2121
2122 // Confirm on the historical (non-live) thread at index 1.
2123 // Before a previous fix, the workspace field was Option<usize> and
2124 // historical threads had None, so activate_thread early-returned
2125 // without switching the workspace.
2126 sidebar.update_in(cx, |sidebar, window, cx| {
2127 sidebar.selection = Some(1);
2128 sidebar.confirm(&Confirm, window, cx);
2129 });
2130 cx.run_until_parked();
2131
2132 assert_eq!(
2133 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2134 workspace_0
2135 );
2136}
2137
2138#[gpui::test]
2139async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
2140 cx: &mut TestAppContext,
2141) {
2142 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2143 let (multi_workspace, cx) =
2144 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2145 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2146
2147 let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
2148 let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
2149 save_thread_metadata(
2150 newer_session_id,
2151 Some("Newer Historical Thread".into()),
2152 newer_timestamp,
2153 Some(newer_timestamp),
2154 None,
2155 &project,
2156 cx,
2157 );
2158
2159 let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
2160 let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
2161 save_thread_metadata(
2162 older_session_id.clone(),
2163 Some("Older Historical Thread".into()),
2164 older_timestamp,
2165 Some(older_timestamp),
2166 None,
2167 &project,
2168 cx,
2169 );
2170
2171 cx.run_until_parked();
2172 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2173 cx.run_until_parked();
2174
2175 let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2176 .into_iter()
2177 .filter(|entry| entry.contains("Historical Thread"))
2178 .collect();
2179 assert_eq!(
2180 historical_entries_before,
2181 vec![
2182 " Newer Historical Thread".to_string(),
2183 " Older Historical Thread".to_string(),
2184 ],
2185 "expected the sidebar to sort historical threads by their saved timestamp before activation"
2186 );
2187
2188 let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
2189 sidebar
2190 .contents
2191 .entries
2192 .iter()
2193 .position(|entry| {
2194 matches!(entry, ListEntry::Thread(thread)
2195 if thread.metadata.session_id.as_ref() == Some(&older_session_id))
2196 })
2197 .expect("expected Older Historical Thread to appear in the sidebar")
2198 });
2199
2200 sidebar.update_in(cx, |sidebar, window, cx| {
2201 sidebar.selection = Some(older_entry_index);
2202 sidebar.confirm(&Confirm, window, cx);
2203 });
2204 cx.run_until_parked();
2205
2206 let older_metadata = cx.update(|_, cx| {
2207 ThreadMetadataStore::global(cx)
2208 .read(cx)
2209 .entry_by_session(&older_session_id)
2210 .cloned()
2211 .expect("expected metadata for Older Historical Thread after activation")
2212 });
2213 assert_eq!(
2214 older_metadata.created_at,
2215 Some(older_timestamp),
2216 "activating a historical thread should not rewrite its saved created_at timestamp"
2217 );
2218
2219 let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2220 .into_iter()
2221 .filter(|entry| entry.contains("Historical Thread"))
2222 .collect();
2223 assert_eq!(
2224 historical_entries_after,
2225 vec![
2226 " Newer Historical Thread".to_string(),
2227 " Older Historical Thread <== selected".to_string(),
2228 ],
2229 "activating an older historical thread should not reorder it ahead of a newer historical thread"
2230 );
2231}
2232
2233#[gpui::test]
2234async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
2235 cx: &mut TestAppContext,
2236) {
2237 use workspace::ProjectGroup;
2238
2239 agent_ui::test_support::init_test(cx);
2240 cx.update(|cx| {
2241 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
2242 ThreadStore::init_global(cx);
2243 ThreadMetadataStore::init_global(cx);
2244 language_model::LanguageModelRegistry::test(cx);
2245 prompt_store::init(cx);
2246 });
2247
2248 let fs = FakeFs::new(cx.executor());
2249 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
2250 .await;
2251 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
2252 .await;
2253 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2254
2255 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
2256 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
2257
2258 let (multi_workspace, cx) =
2259 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2260 let sidebar = setup_sidebar(&multi_workspace, cx);
2261
2262 let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
2263 multi_workspace.update(cx, |mw, _cx| {
2264 mw.test_add_project_group(ProjectGroup {
2265 key: project_b_key.clone(),
2266 workspaces: Vec::new(),
2267 expanded: true,
2268 });
2269 });
2270
2271 let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
2272 save_thread_metadata(
2273 session_id.clone(),
2274 Some("Historical Thread in New Group".into()),
2275 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2276 None,
2277 None,
2278 &project_b,
2279 cx,
2280 );
2281 cx.run_until_parked();
2282
2283 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2284 cx.run_until_parked();
2285
2286 let entries_before = visible_entries_as_strings(&sidebar, cx);
2287 assert_eq!(
2288 entries_before,
2289 vec![
2290 "v [project-a]",
2291 "v [project-b]",
2292 " Historical Thread in New Group",
2293 ],
2294 "expected the closed project group to show the historical thread before first open"
2295 );
2296
2297 assert_eq!(
2298 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2299 1,
2300 "should start without an open workspace for the new project group"
2301 );
2302
2303 sidebar.update_in(cx, |sidebar, window, cx| {
2304 sidebar.selection = Some(2);
2305 sidebar.confirm(&Confirm, window, cx);
2306 });
2307
2308 cx.run_until_parked();
2309
2310 assert_eq!(
2311 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2312 2,
2313 "confirming the historical thread should open a workspace for the new project group"
2314 );
2315
2316 let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
2317 mw.workspaces()
2318 .find(|workspace| {
2319 PathList::new(&workspace.read(cx).root_paths(cx))
2320 == project_b_key.path_list().clone()
2321 })
2322 .cloned()
2323 .expect("expected workspace for project-b after opening the historical thread")
2324 });
2325
2326 assert_eq!(
2327 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2328 workspace_b,
2329 "opening the historical thread should activate the new project's workspace"
2330 );
2331
2332 let panel = workspace_b.read_with(cx, |workspace, cx| {
2333 workspace
2334 .panel::<AgentPanel>(cx)
2335 .expect("expected first-open activation to bootstrap the agent panel")
2336 });
2337
2338 let expected_thread_id = cx.update(|_, cx| {
2339 ThreadMetadataStore::global(cx)
2340 .read(cx)
2341 .entries()
2342 .find(|e| e.session_id.as_ref() == Some(&session_id))
2343 .map(|e| e.thread_id)
2344 .expect("metadata should still map session id to thread id")
2345 });
2346
2347 assert_eq!(
2348 panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
2349 Some(expected_thread_id),
2350 "expected the agent panel to activate the real historical thread rather than a draft"
2351 );
2352
2353 let entries_after = visible_entries_as_strings(&sidebar, cx);
2354 let matching_rows: Vec<_> = entries_after
2355 .iter()
2356 .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
2357 .cloned()
2358 .collect();
2359 assert_eq!(
2360 matching_rows.len(),
2361 1,
2362 "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
2363 );
2364 assert!(
2365 matching_rows[0].contains("Historical Thread in New Group"),
2366 "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
2367 );
2368 assert!(
2369 !matching_rows[0].contains("Draft"),
2370 "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
2371 );
2372}
2373
2374#[gpui::test]
2375async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
2376 let project = init_test_project("/my-project", cx).await;
2377 let (multi_workspace, cx) =
2378 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2379 let sidebar = setup_sidebar(&multi_workspace, cx);
2380
2381 save_thread_metadata(
2382 acp::SessionId::new(Arc::from("t-1")),
2383 Some("Thread A".into()),
2384 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2385 None,
2386 None,
2387 &project,
2388 cx,
2389 );
2390
2391 save_thread_metadata(
2392 acp::SessionId::new(Arc::from("t-2")),
2393 Some("Thread B".into()),
2394 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2395 None,
2396 None,
2397 &project,
2398 cx,
2399 );
2400
2401 cx.run_until_parked();
2402 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2403 cx.run_until_parked();
2404
2405 assert_eq!(
2406 visible_entries_as_strings(&sidebar, cx),
2407 vec![
2408 //
2409 "v [my-project]",
2410 " Thread A",
2411 " Thread B",
2412 ]
2413 );
2414
2415 // Keyboard confirm preserves selection.
2416 sidebar.update_in(cx, |sidebar, window, cx| {
2417 sidebar.selection = Some(1);
2418 sidebar.confirm(&Confirm, window, cx);
2419 });
2420 assert_eq!(
2421 sidebar.read_with(cx, |sidebar, _| sidebar.selection),
2422 Some(1)
2423 );
2424
2425 // Click handlers clear selection to None so no highlight lingers
2426 // after a click regardless of focus state. The hover style provides
2427 // visual feedback during mouse interaction instead.
2428 sidebar.update_in(cx, |sidebar, window, cx| {
2429 sidebar.selection = None;
2430 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2431 let project_group_key = ProjectGroupKey::new(None, path_list);
2432 sidebar.toggle_collapse(&project_group_key, window, cx);
2433 });
2434 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2435
2436 // When the user tabs back into the sidebar, focus_in no longer
2437 // restores selection — it stays None.
2438 sidebar.update_in(cx, |sidebar, window, cx| {
2439 sidebar.focus_in(window, cx);
2440 });
2441 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2442}
2443
2444#[gpui::test]
2445async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
2446 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2447 let (multi_workspace, cx) =
2448 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2449 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2450
2451 let connection = StubAgentConnection::new();
2452 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2453 acp::ContentChunk::new("Hi there!".into()),
2454 )]);
2455 open_thread_with_connection(&panel, connection, cx);
2456 send_message(&panel, cx);
2457
2458 let session_id = active_session_id(&panel, cx);
2459 save_test_thread_metadata(&session_id, &project, cx).await;
2460 cx.run_until_parked();
2461
2462 assert_eq!(
2463 visible_entries_as_strings(&sidebar, cx),
2464 vec![
2465 //
2466 "v [my-project]",
2467 " Hello *",
2468 ]
2469 );
2470
2471 // Simulate the agent generating a title. The notification chain is:
2472 // AcpThread::set_title emits TitleUpdated →
2473 // ConnectionView::handle_thread_event calls cx.notify() →
2474 // AgentPanel observer fires and emits AgentPanelEvent →
2475 // Sidebar subscription calls update_entries / rebuild_contents.
2476 //
2477 // Before the fix, handle_thread_event did NOT call cx.notify() for
2478 // TitleUpdated, so the AgentPanel observer never fired and the
2479 // sidebar kept showing the old title.
2480 let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2481 thread.update(cx, |thread, cx| {
2482 thread
2483 .set_title("Friendly Greeting with AI".into(), cx)
2484 .detach();
2485 });
2486 cx.run_until_parked();
2487
2488 assert_eq!(
2489 visible_entries_as_strings(&sidebar, cx),
2490 vec![
2491 //
2492 "v [my-project]",
2493 " Friendly Greeting with AI *",
2494 ]
2495 );
2496}
2497
2498#[gpui::test]
2499async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
2500 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2501 let (multi_workspace, cx) =
2502 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2503 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2504
2505 // Save a thread so it appears in the list.
2506 let connection_a = StubAgentConnection::new();
2507 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2508 acp::ContentChunk::new("Done".into()),
2509 )]);
2510 open_thread_with_connection(&panel_a, connection_a, cx);
2511 send_message(&panel_a, cx);
2512 let session_id_a = active_session_id(&panel_a, cx);
2513 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
2514
2515 // Add a second workspace with its own agent panel.
2516 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2517 fs.as_fake()
2518 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2519 .await;
2520 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2521 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2522 mw.test_add_workspace(project_b.clone(), window, cx)
2523 });
2524 let panel_b = add_agent_panel(&workspace_b, cx);
2525 cx.run_until_parked();
2526
2527 let workspace_a =
2528 multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2529
2530 // ── 1. Initial state: focused thread derived from active panel ─────
2531 sidebar.read_with(cx, |sidebar, _cx| {
2532 assert_active_thread(
2533 sidebar,
2534 &session_id_a,
2535 "The active panel's thread should be focused on startup",
2536 );
2537 });
2538
2539 let thread_metadata_a = cx.update(|_window, cx| {
2540 ThreadMetadataStore::global(cx)
2541 .read(cx)
2542 .entry_by_session(&session_id_a)
2543 .cloned()
2544 .expect("session_id_a should exist in metadata store")
2545 });
2546 sidebar.update_in(cx, |sidebar, window, cx| {
2547 sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
2548 });
2549 cx.run_until_parked();
2550
2551 sidebar.read_with(cx, |sidebar, _cx| {
2552 assert_active_thread(
2553 sidebar,
2554 &session_id_a,
2555 "After clicking a thread, it should be the focused thread",
2556 );
2557 assert!(
2558 has_thread_entry(sidebar, &session_id_a),
2559 "The clicked thread should be present in the entries"
2560 );
2561 });
2562
2563 workspace_a.read_with(cx, |workspace, cx| {
2564 assert!(
2565 workspace.panel::<AgentPanel>(cx).is_some(),
2566 "Agent panel should exist"
2567 );
2568 let dock = workspace.left_dock().read(cx);
2569 assert!(
2570 dock.is_open(),
2571 "Clicking a thread should open the agent panel dock"
2572 );
2573 });
2574
2575 let connection_b = StubAgentConnection::new();
2576 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2577 acp::ContentChunk::new("Thread B".into()),
2578 )]);
2579 open_thread_with_connection(&panel_b, connection_b, cx);
2580 send_message(&panel_b, cx);
2581 let session_id_b = active_session_id(&panel_b, cx);
2582 save_test_thread_metadata(&session_id_b, &project_b, cx).await;
2583 cx.run_until_parked();
2584
2585 // Workspace A is currently active. Click a thread in workspace B,
2586 // which also triggers a workspace switch.
2587 let thread_metadata_b = cx.update(|_window, cx| {
2588 ThreadMetadataStore::global(cx)
2589 .read(cx)
2590 .entry_by_session(&session_id_b)
2591 .cloned()
2592 .expect("session_id_b should exist in metadata store")
2593 });
2594 sidebar.update_in(cx, |sidebar, window, cx| {
2595 sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
2596 });
2597 cx.run_until_parked();
2598
2599 sidebar.read_with(cx, |sidebar, _cx| {
2600 assert_active_thread(
2601 sidebar,
2602 &session_id_b,
2603 "Clicking a thread in another workspace should focus that thread",
2604 );
2605 assert!(
2606 has_thread_entry(sidebar, &session_id_b),
2607 "The cross-workspace thread should be present in the entries"
2608 );
2609 });
2610
2611 multi_workspace.update_in(cx, |mw, window, cx| {
2612 let workspace = mw.workspaces().next().unwrap().clone();
2613 mw.activate(workspace, None, window, cx);
2614 });
2615 cx.run_until_parked();
2616
2617 sidebar.read_with(cx, |sidebar, _cx| {
2618 assert_active_thread(
2619 sidebar,
2620 &session_id_a,
2621 "Switching workspace should seed focused_thread from the new active panel",
2622 );
2623 assert!(
2624 has_thread_entry(sidebar, &session_id_a),
2625 "The seeded thread should be present in the entries"
2626 );
2627 });
2628
2629 let connection_b2 = StubAgentConnection::new();
2630 connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2631 acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
2632 )]);
2633 open_thread_with_connection(&panel_b, connection_b2, cx);
2634 send_message(&panel_b, cx);
2635 let session_id_b2 = active_session_id(&panel_b, cx);
2636 save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
2637 cx.run_until_parked();
2638
2639 // Panel B is not the active workspace's panel (workspace A is
2640 // active), so opening a thread there should not change focused_thread.
2641 // This prevents running threads in background workspaces from causing
2642 // the selection highlight to jump around.
2643 sidebar.read_with(cx, |sidebar, _cx| {
2644 assert_active_thread(
2645 sidebar,
2646 &session_id_a,
2647 "Opening a thread in a non-active panel should not change focused_thread",
2648 );
2649 });
2650
2651 workspace_b.update_in(cx, |workspace, window, cx| {
2652 workspace.focus_handle(cx).focus(window, cx);
2653 });
2654 cx.run_until_parked();
2655
2656 sidebar.read_with(cx, |sidebar, _cx| {
2657 assert_active_thread(
2658 sidebar,
2659 &session_id_a,
2660 "Defocusing the sidebar should not change focused_thread",
2661 );
2662 });
2663
2664 // Switching workspaces via the multi_workspace (simulates clicking
2665 // a workspace header) should clear focused_thread.
2666 multi_workspace.update_in(cx, |mw, window, cx| {
2667 let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
2668 if let Some(workspace) = workspace {
2669 mw.activate(workspace, None, window, cx);
2670 }
2671 });
2672 cx.run_until_parked();
2673
2674 sidebar.read_with(cx, |sidebar, _cx| {
2675 assert_active_thread(
2676 sidebar,
2677 &session_id_b2,
2678 "Switching workspace should seed focused_thread from the new active panel",
2679 );
2680 assert!(
2681 has_thread_entry(sidebar, &session_id_b2),
2682 "The seeded thread should be present in the entries"
2683 );
2684 });
2685
2686 // ── 8. Focusing the agent panel thread keeps focused_thread ────
2687 // Workspace B still has session_id_b2 loaded in the agent panel.
2688 // Clicking into the thread (simulated by focusing its view) should
2689 // keep focused_thread since it was already seeded on workspace switch.
2690 panel_b.update_in(cx, |panel, window, cx| {
2691 if let Some(thread_view) = panel.active_conversation_view() {
2692 thread_view.read(cx).focus_handle(cx).focus(window, cx);
2693 }
2694 });
2695 cx.run_until_parked();
2696
2697 sidebar.read_with(cx, |sidebar, _cx| {
2698 assert_active_thread(
2699 sidebar,
2700 &session_id_b2,
2701 "Focusing the agent panel thread should set focused_thread",
2702 );
2703 assert!(
2704 has_thread_entry(sidebar, &session_id_b2),
2705 "The focused thread should be present in the entries"
2706 );
2707 });
2708}
2709
2710#[gpui::test]
2711async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
2712 let project = init_test_project_with_agent_panel("/project-a", cx).await;
2713 let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
2714 let (multi_workspace, cx) =
2715 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2716 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2717
2718 // Start a thread and send a message so it has history.
2719 let connection = StubAgentConnection::new();
2720 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2721 acp::ContentChunk::new("Done".into()),
2722 )]);
2723 open_thread_with_connection(&panel, connection, cx);
2724 send_message(&panel, cx);
2725 let session_id = active_session_id(&panel, cx);
2726 save_test_thread_metadata(&session_id, &project, cx).await;
2727 cx.run_until_parked();
2728
2729 // Verify the thread appears in the sidebar.
2730 assert_eq!(
2731 visible_entries_as_strings(&sidebar, cx),
2732 vec![
2733 //
2734 "v [project-a]",
2735 " Hello *",
2736 ]
2737 );
2738
2739 // The "New Thread" button should NOT be in "active/draft" state
2740 // because the panel has a thread with messages.
2741 sidebar.read_with(cx, |sidebar, _cx| {
2742 assert!(
2743 matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
2744 "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
2745 sidebar.active_entry,
2746 );
2747 });
2748
2749 // Now add a second folder to the workspace, changing the path_list.
2750 fs.as_fake()
2751 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2752 .await;
2753 project
2754 .update(cx, |project, cx| {
2755 project.find_or_create_worktree("/project-b", true, cx)
2756 })
2757 .await
2758 .expect("should add worktree");
2759 cx.run_until_parked();
2760
2761 // The workspace path_list is now [project-a, project-b]. The active
2762 // thread's metadata was re-saved with the new paths by the agent panel's
2763 // project subscription. The old [project-a] key is replaced by the new
2764 // key since no other workspace claims it.
2765 let entries = visible_entries_as_strings(&sidebar, cx);
2766 // After adding a worktree, the thread migrates to the new group key.
2767 // A reconciliation draft may appear during the transition.
2768 assert!(
2769 entries.contains(&" Hello *".to_string()),
2770 "thread should still be present after adding folder: {entries:?}"
2771 );
2772 assert_eq!(entries[0], "v [project-a, project-b]");
2773
2774 // The "New Thread" button must still be clickable (not stuck in
2775 // "active/draft" state). Verify that `active_thread_is_draft` is
2776 // false — the panel still has the old thread with messages.
2777 sidebar.read_with(cx, |sidebar, _cx| {
2778 assert!(
2779 matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
2780 "After adding a folder the panel still has a thread with messages, \
2781 so active_entry should be Thread, got {:?}",
2782 sidebar.active_entry,
2783 );
2784 });
2785
2786 // Actually click "New Thread" by calling create_new_thread and
2787 // verify a new draft is created.
2788 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2789 sidebar.update_in(cx, |sidebar, window, cx| {
2790 sidebar.create_new_thread(&workspace, window, cx);
2791 });
2792 cx.run_until_parked();
2793
2794 // After creating a new thread, the panel should now be in draft
2795 // state (no messages on the new thread).
2796 sidebar.read_with(cx, |sidebar, _cx| {
2797 assert_active_draft(
2798 sidebar,
2799 &workspace,
2800 "After creating a new thread active_entry should be Draft",
2801 );
2802 });
2803}
2804#[gpui::test]
2805async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
2806 // When the user presses Cmd-N (NewThread action) while viewing a
2807 // non-empty thread, the panel should switch to the draft thread.
2808 // Drafts are not shown as sidebar rows.
2809 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2810 let (multi_workspace, cx) =
2811 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2812 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2813
2814 // Create a non-empty thread (has messages).
2815 let connection = StubAgentConnection::new();
2816 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2817 acp::ContentChunk::new("Done".into()),
2818 )]);
2819 open_thread_with_connection(&panel, connection, cx);
2820 send_message(&panel, cx);
2821
2822 let session_id = active_session_id(&panel, cx);
2823 save_test_thread_metadata(&session_id, &project, cx).await;
2824 cx.run_until_parked();
2825
2826 assert_eq!(
2827 visible_entries_as_strings(&sidebar, cx),
2828 vec![
2829 //
2830 "v [my-project]",
2831 " Hello *",
2832 ]
2833 );
2834
2835 // Simulate cmd-n
2836 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2837 panel.update_in(cx, |panel, window, cx| {
2838 panel.new_thread(&NewThread, window, cx);
2839 });
2840 workspace.update_in(cx, |workspace, window, cx| {
2841 workspace.focus_panel::<AgentPanel>(window, cx);
2842 });
2843 cx.run_until_parked();
2844
2845 // Drafts are not shown as sidebar rows, so entries stay the same.
2846 assert_eq!(
2847 visible_entries_as_strings(&sidebar, cx),
2848 vec!["v [my-project]", " Hello *"],
2849 "After Cmd-N the sidebar should not show a Draft entry"
2850 );
2851
2852 // The panel should be on the draft and active_entry should track it.
2853 panel.read_with(cx, |panel, cx| {
2854 assert!(
2855 panel.active_thread_is_draft(cx),
2856 "panel should be showing the draft after Cmd-N",
2857 );
2858 });
2859 sidebar.read_with(cx, |sidebar, _cx| {
2860 assert_active_draft(
2861 sidebar,
2862 &workspace,
2863 "active_entry should be Draft after Cmd-N",
2864 );
2865 });
2866}
2867
2868#[gpui::test]
2869async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
2870 // When the active workspace is an absorbed git worktree, cmd-n
2871 // should activate the draft thread in the panel. Drafts are not
2872 // shown as sidebar rows.
2873 agent_ui::test_support::init_test(cx);
2874 cx.update(|cx| {
2875 ThreadStore::init_global(cx);
2876 ThreadMetadataStore::init_global(cx);
2877 language_model::LanguageModelRegistry::test(cx);
2878 prompt_store::init(cx);
2879 });
2880
2881 let fs = FakeFs::new(cx.executor());
2882
2883 // Main repo with a linked worktree.
2884 fs.insert_tree(
2885 "/project",
2886 serde_json::json!({
2887 ".git": {},
2888 "src": {},
2889 }),
2890 )
2891 .await;
2892
2893 // Worktree checkout pointing back to the main repo.
2894 fs.add_linked_worktree_for_repo(
2895 Path::new("/project/.git"),
2896 false,
2897 git::repository::Worktree {
2898 path: std::path::PathBuf::from("/wt-feature-a"),
2899 ref_name: Some("refs/heads/feature-a".into()),
2900 sha: "aaa".into(),
2901 is_main: false,
2902 is_bare: false,
2903 },
2904 )
2905 .await;
2906
2907 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2908
2909 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2910 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2911
2912 main_project
2913 .update(cx, |p, cx| p.git_scans_complete(cx))
2914 .await;
2915 worktree_project
2916 .update(cx, |p, cx| p.git_scans_complete(cx))
2917 .await;
2918
2919 let (multi_workspace, cx) =
2920 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
2921
2922 let sidebar = setup_sidebar(&multi_workspace, cx);
2923
2924 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
2925 mw.test_add_workspace(worktree_project.clone(), window, cx)
2926 });
2927
2928 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
2929
2930 // Switch to the worktree workspace.
2931 multi_workspace.update_in(cx, |mw, window, cx| {
2932 let workspace = mw.workspaces().nth(1).unwrap().clone();
2933 mw.activate(workspace, None, window, cx);
2934 });
2935
2936 // Create a non-empty thread in the worktree workspace.
2937 let connection = StubAgentConnection::new();
2938 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2939 acp::ContentChunk::new("Done".into()),
2940 )]);
2941 open_thread_with_connection(&worktree_panel, connection, cx);
2942 send_message(&worktree_panel, cx);
2943
2944 let session_id = active_session_id(&worktree_panel, cx);
2945 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
2946 cx.run_until_parked();
2947
2948 assert_eq!(
2949 visible_entries_as_strings(&sidebar, cx),
2950 vec![
2951 //
2952 "v [project]",
2953 " Hello {wt-feature-a} *",
2954 ]
2955 );
2956
2957 // Simulate Cmd-N in the worktree workspace.
2958 worktree_panel.update_in(cx, |panel, window, cx| {
2959 panel.new_thread(&NewThread, window, cx);
2960 });
2961 worktree_workspace.update_in(cx, |workspace, window, cx| {
2962 workspace.focus_panel::<AgentPanel>(window, cx);
2963 });
2964 cx.run_until_parked();
2965
2966 // Drafts are not shown as sidebar rows, so entries stay the same.
2967 assert_eq!(
2968 visible_entries_as_strings(&sidebar, cx),
2969 vec![
2970 //
2971 "v [project]",
2972 " Hello {wt-feature-a} *"
2973 ],
2974 "After Cmd-N the sidebar should not show a Draft entry"
2975 );
2976
2977 // The panel should be on the draft and active_entry should track it.
2978 worktree_panel.read_with(cx, |panel, cx| {
2979 assert!(
2980 panel.active_thread_is_draft(cx),
2981 "panel should be showing the draft after Cmd-N",
2982 );
2983 });
2984 sidebar.read_with(cx, |sidebar, _cx| {
2985 assert_active_draft(
2986 sidebar,
2987 &worktree_workspace,
2988 "active_entry should be Draft after Cmd-N",
2989 );
2990 });
2991}
2992
2993async fn init_test_project_with_git(
2994 worktree_path: &str,
2995 cx: &mut TestAppContext,
2996) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
2997 init_test(cx);
2998 let fs = FakeFs::new(cx.executor());
2999 fs.insert_tree(
3000 worktree_path,
3001 serde_json::json!({
3002 ".git": {},
3003 "src": {},
3004 }),
3005 )
3006 .await;
3007 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3008 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
3009 (project, fs)
3010}
3011
3012#[gpui::test]
3013async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
3014 let (project, fs) = init_test_project_with_git("/project", cx).await;
3015
3016 fs.as_fake()
3017 .add_linked_worktree_for_repo(
3018 Path::new("/project/.git"),
3019 false,
3020 git::repository::Worktree {
3021 path: std::path::PathBuf::from("/wt/rosewood"),
3022 ref_name: Some("refs/heads/rosewood".into()),
3023 sha: "abc".into(),
3024 is_main: false,
3025 is_bare: false,
3026 },
3027 )
3028 .await;
3029
3030 project
3031 .update(cx, |project, cx| project.git_scans_complete(cx))
3032 .await;
3033
3034 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3035 worktree_project
3036 .update(cx, |p, cx| p.git_scans_complete(cx))
3037 .await;
3038
3039 let (multi_workspace, cx) =
3040 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3041 let sidebar = setup_sidebar(&multi_workspace, cx);
3042
3043 save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
3044 save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
3045
3046 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3047 cx.run_until_parked();
3048
3049 // Search for "rosewood" — should match the worktree name, not the title.
3050 type_in_search(&sidebar, "rosewood", cx);
3051
3052 assert_eq!(
3053 visible_entries_as_strings(&sidebar, cx),
3054 vec![
3055 //
3056 "v [project]",
3057 " Fix Bug {rosewood} <== selected",
3058 ],
3059 );
3060}
3061
3062#[gpui::test]
3063async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
3064 let (project, fs) = init_test_project_with_git("/project", cx).await;
3065
3066 project
3067 .update(cx, |project, cx| project.git_scans_complete(cx))
3068 .await;
3069
3070 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3071 worktree_project
3072 .update(cx, |p, cx| p.git_scans_complete(cx))
3073 .await;
3074
3075 let (multi_workspace, cx) =
3076 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3077 let sidebar = setup_sidebar(&multi_workspace, cx);
3078
3079 // Save a thread against a worktree path with the correct main
3080 // worktree association (as if the git state had been resolved).
3081 save_thread_metadata_with_main_paths(
3082 "wt-thread",
3083 "Worktree Thread",
3084 PathList::new(&[PathBuf::from("/wt/rosewood")]),
3085 PathList::new(&[PathBuf::from("/project")]),
3086 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3087 cx,
3088 );
3089
3090 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3091 cx.run_until_parked();
3092
3093 // Thread is visible because its main_worktree_paths match the group.
3094 // The chip name is derived from the path even before git discovery.
3095 assert_eq!(
3096 visible_entries_as_strings(&sidebar, cx),
3097 vec!["v [project]", " Worktree Thread {rosewood}"]
3098 );
3099
3100 // Now add the worktree to the git state and trigger a rescan.
3101 fs.as_fake()
3102 .add_linked_worktree_for_repo(
3103 Path::new("/project/.git"),
3104 true,
3105 git::repository::Worktree {
3106 path: std::path::PathBuf::from("/wt/rosewood"),
3107 ref_name: Some("refs/heads/rosewood".into()),
3108 sha: "abc".into(),
3109 is_main: false,
3110 is_bare: false,
3111 },
3112 )
3113 .await;
3114
3115 cx.run_until_parked();
3116
3117 assert_eq!(
3118 visible_entries_as_strings(&sidebar, cx),
3119 vec![
3120 //
3121 "v [project]",
3122 " Worktree Thread {rosewood}",
3123 ]
3124 );
3125}
3126
3127#[gpui::test]
3128async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
3129 init_test(cx);
3130 let fs = FakeFs::new(cx.executor());
3131
3132 // Create the main repo directory (not opened as a workspace yet).
3133 fs.insert_tree(
3134 "/project",
3135 serde_json::json!({
3136 ".git": {
3137 },
3138 "src": {},
3139 }),
3140 )
3141 .await;
3142
3143 // Two worktree checkouts whose .git files point back to the main repo.
3144 fs.add_linked_worktree_for_repo(
3145 Path::new("/project/.git"),
3146 false,
3147 git::repository::Worktree {
3148 path: std::path::PathBuf::from("/wt-feature-a"),
3149 ref_name: Some("refs/heads/feature-a".into()),
3150 sha: "aaa".into(),
3151 is_main: false,
3152 is_bare: false,
3153 },
3154 )
3155 .await;
3156 fs.add_linked_worktree_for_repo(
3157 Path::new("/project/.git"),
3158 false,
3159 git::repository::Worktree {
3160 path: std::path::PathBuf::from("/wt-feature-b"),
3161 ref_name: Some("refs/heads/feature-b".into()),
3162 sha: "bbb".into(),
3163 is_main: false,
3164 is_bare: false,
3165 },
3166 )
3167 .await;
3168
3169 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3170
3171 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3172 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3173
3174 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3175 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3176
3177 // Open both worktrees as workspaces — no main repo yet.
3178 let (multi_workspace, cx) =
3179 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3180 multi_workspace.update_in(cx, |mw, window, cx| {
3181 mw.test_add_workspace(project_b.clone(), window, cx);
3182 });
3183 let sidebar = setup_sidebar(&multi_workspace, cx);
3184
3185 save_thread_metadata(
3186 acp::SessionId::new(Arc::from("thread-a")),
3187 Some("Thread A".into()),
3188 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3189 None,
3190 None,
3191 &project_a,
3192 cx,
3193 );
3194 save_thread_metadata(
3195 acp::SessionId::new(Arc::from("thread-b")),
3196 Some("Thread B".into()),
3197 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
3198 None,
3199 None,
3200 &project_b,
3201 cx,
3202 );
3203
3204 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3205 cx.run_until_parked();
3206
3207 // Without the main repo, each worktree has its own header.
3208 assert_eq!(
3209 visible_entries_as_strings(&sidebar, cx),
3210 vec![
3211 //
3212 "v [project]",
3213 " Thread B {wt-feature-b}",
3214 " Thread A {wt-feature-a}",
3215 ]
3216 );
3217
3218 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3219 main_project
3220 .update(cx, |p, cx| p.git_scans_complete(cx))
3221 .await;
3222
3223 multi_workspace.update_in(cx, |mw, window, cx| {
3224 mw.test_add_workspace(main_project.clone(), window, cx);
3225 });
3226 cx.run_until_parked();
3227
3228 // Both worktree workspaces should now be absorbed under the main
3229 // repo header, with worktree chips.
3230 assert_eq!(
3231 visible_entries_as_strings(&sidebar, cx),
3232 vec![
3233 //
3234 "v [project]",
3235 " Thread B {wt-feature-b}",
3236 " Thread A {wt-feature-a}",
3237 ]
3238 );
3239}
3240
3241#[gpui::test]
3242async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
3243 // When a group has two workspaces — one with threads and one
3244 // without — the threadless workspace should appear as a
3245 // "New Thread" button with its worktree chip.
3246 init_test(cx);
3247 let fs = FakeFs::new(cx.executor());
3248
3249 // Main repo with two linked worktrees.
3250 fs.insert_tree(
3251 "/project",
3252 serde_json::json!({
3253 ".git": {},
3254 "src": {},
3255 }),
3256 )
3257 .await;
3258 fs.add_linked_worktree_for_repo(
3259 Path::new("/project/.git"),
3260 false,
3261 git::repository::Worktree {
3262 path: std::path::PathBuf::from("/wt-feature-a"),
3263 ref_name: Some("refs/heads/feature-a".into()),
3264 sha: "aaa".into(),
3265 is_main: false,
3266 is_bare: false,
3267 },
3268 )
3269 .await;
3270 fs.add_linked_worktree_for_repo(
3271 Path::new("/project/.git"),
3272 false,
3273 git::repository::Worktree {
3274 path: std::path::PathBuf::from("/wt-feature-b"),
3275 ref_name: Some("refs/heads/feature-b".into()),
3276 sha: "bbb".into(),
3277 is_main: false,
3278 is_bare: false,
3279 },
3280 )
3281 .await;
3282
3283 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3284
3285 // Workspace A: worktree feature-a (has threads).
3286 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3287 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3288
3289 // Workspace B: worktree feature-b (no threads).
3290 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3291 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3292
3293 let (multi_workspace, cx) =
3294 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3295 multi_workspace.update_in(cx, |mw, window, cx| {
3296 mw.test_add_workspace(project_b.clone(), window, cx);
3297 });
3298 let sidebar = setup_sidebar(&multi_workspace, cx);
3299
3300 // Only save a thread for workspace A.
3301 save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
3302
3303 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3304 cx.run_until_parked();
3305
3306 // Workspace A's thread appears normally. Workspace B (threadless)
3307 // appears as a "New Thread" button with its worktree chip.
3308 assert_eq!(
3309 visible_entries_as_strings(&sidebar, cx),
3310 vec!["v [project]", " Thread A {wt-feature-a}",]
3311 );
3312}
3313
3314#[gpui::test]
3315async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
3316 // A thread created in a workspace with roots from different git
3317 // worktrees should show a chip for each distinct worktree name.
3318 init_test(cx);
3319 let fs = FakeFs::new(cx.executor());
3320
3321 // Two main repos.
3322 fs.insert_tree(
3323 "/project_a",
3324 serde_json::json!({
3325 ".git": {},
3326 "src": {},
3327 }),
3328 )
3329 .await;
3330 fs.insert_tree(
3331 "/project_b",
3332 serde_json::json!({
3333 ".git": {},
3334 "src": {},
3335 }),
3336 )
3337 .await;
3338
3339 // Worktree checkouts.
3340 for repo in &["project_a", "project_b"] {
3341 let git_path = format!("/{repo}/.git");
3342 for branch in &["olivetti", "selectric"] {
3343 fs.add_linked_worktree_for_repo(
3344 Path::new(&git_path),
3345 false,
3346 git::repository::Worktree {
3347 path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
3348 ref_name: Some(format!("refs/heads/{branch}").into()),
3349 sha: "aaa".into(),
3350 is_main: false,
3351 is_bare: false,
3352 },
3353 )
3354 .await;
3355 }
3356 }
3357
3358 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3359
3360 // Open a workspace with the worktree checkout paths as roots
3361 // (this is the workspace the thread was created in).
3362 let project = project::Project::test(
3363 fs.clone(),
3364 [
3365 "/worktrees/project_a/olivetti/project_a".as_ref(),
3366 "/worktrees/project_b/selectric/project_b".as_ref(),
3367 ],
3368 cx,
3369 )
3370 .await;
3371 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3372
3373 let (multi_workspace, cx) =
3374 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3375 let sidebar = setup_sidebar(&multi_workspace, cx);
3376
3377 // Save a thread under the same paths as the workspace roots.
3378 save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
3379
3380 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3381 cx.run_until_parked();
3382
3383 // Should show two distinct worktree chips.
3384 assert_eq!(
3385 visible_entries_as_strings(&sidebar, cx),
3386 vec![
3387 //
3388 "v [project_a, project_b]",
3389 " Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
3390 ]
3391 );
3392}
3393
3394#[gpui::test]
3395async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
3396 // When a thread's roots span multiple repos but share the same
3397 // worktree name (e.g. both in "olivetti"), only one chip should
3398 // appear.
3399 init_test(cx);
3400 let fs = FakeFs::new(cx.executor());
3401
3402 fs.insert_tree(
3403 "/project_a",
3404 serde_json::json!({
3405 ".git": {},
3406 "src": {},
3407 }),
3408 )
3409 .await;
3410 fs.insert_tree(
3411 "/project_b",
3412 serde_json::json!({
3413 ".git": {},
3414 "src": {},
3415 }),
3416 )
3417 .await;
3418
3419 for repo in &["project_a", "project_b"] {
3420 let git_path = format!("/{repo}/.git");
3421 fs.add_linked_worktree_for_repo(
3422 Path::new(&git_path),
3423 false,
3424 git::repository::Worktree {
3425 path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
3426 ref_name: Some("refs/heads/olivetti".into()),
3427 sha: "aaa".into(),
3428 is_main: false,
3429 is_bare: false,
3430 },
3431 )
3432 .await;
3433 }
3434
3435 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3436
3437 let project = project::Project::test(
3438 fs.clone(),
3439 [
3440 "/worktrees/project_a/olivetti/project_a".as_ref(),
3441 "/worktrees/project_b/olivetti/project_b".as_ref(),
3442 ],
3443 cx,
3444 )
3445 .await;
3446 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3447
3448 let (multi_workspace, cx) =
3449 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3450 let sidebar = setup_sidebar(&multi_workspace, cx);
3451
3452 // Thread with roots in both repos' "olivetti" worktrees.
3453 save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
3454
3455 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3456 cx.run_until_parked();
3457
3458 // Both worktree paths have the name "olivetti", so only one chip.
3459 assert_eq!(
3460 visible_entries_as_strings(&sidebar, cx),
3461 vec![
3462 //
3463 "v [project_a, project_b]",
3464 " Same Branch Thread {olivetti}",
3465 ]
3466 );
3467}
3468
3469#[gpui::test]
3470async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
3471 // When a worktree workspace is absorbed under the main repo, a
3472 // running thread in the worktree's agent panel should still show
3473 // live status (spinner + "(running)") in the sidebar.
3474 agent_ui::test_support::init_test(cx);
3475 cx.update(|cx| {
3476 ThreadStore::init_global(cx);
3477 ThreadMetadataStore::init_global(cx);
3478 language_model::LanguageModelRegistry::test(cx);
3479 prompt_store::init(cx);
3480 });
3481
3482 let fs = FakeFs::new(cx.executor());
3483
3484 // Main repo with a linked worktree.
3485 fs.insert_tree(
3486 "/project",
3487 serde_json::json!({
3488 ".git": {},
3489 "src": {},
3490 }),
3491 )
3492 .await;
3493
3494 // Worktree checkout pointing back to the main repo.
3495 fs.add_linked_worktree_for_repo(
3496 Path::new("/project/.git"),
3497 false,
3498 git::repository::Worktree {
3499 path: std::path::PathBuf::from("/wt-feature-a"),
3500 ref_name: Some("refs/heads/feature-a".into()),
3501 sha: "aaa".into(),
3502 is_main: false,
3503 is_bare: false,
3504 },
3505 )
3506 .await;
3507
3508 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3509
3510 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3511 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3512
3513 main_project
3514 .update(cx, |p, cx| p.git_scans_complete(cx))
3515 .await;
3516 worktree_project
3517 .update(cx, |p, cx| p.git_scans_complete(cx))
3518 .await;
3519
3520 // Create the MultiWorkspace with both projects.
3521 let (multi_workspace, cx) =
3522 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3523
3524 let sidebar = setup_sidebar(&multi_workspace, cx);
3525
3526 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3527 mw.test_add_workspace(worktree_project.clone(), window, cx)
3528 });
3529
3530 // Add an agent panel to the worktree workspace so we can run a
3531 // thread inside it.
3532 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3533
3534 // Switch back to the main workspace before setting up the sidebar.
3535 multi_workspace.update_in(cx, |mw, window, cx| {
3536 let workspace = mw.workspaces().next().unwrap().clone();
3537 mw.activate(workspace, None, window, cx);
3538 });
3539
3540 // Start a thread in the worktree workspace's panel and keep it
3541 // generating (don't resolve it).
3542 let connection = StubAgentConnection::new();
3543 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3544 send_message(&worktree_panel, cx);
3545
3546 let session_id = active_session_id(&worktree_panel, cx);
3547
3548 // Save metadata so the sidebar knows about this thread.
3549 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3550
3551 // Keep the thread generating by sending a chunk without ending
3552 // the turn.
3553 cx.update(|_, cx| {
3554 connection.send_update(
3555 session_id.clone(),
3556 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3557 cx,
3558 );
3559 });
3560 cx.run_until_parked();
3561
3562 // The worktree thread should be absorbed under the main project
3563 // and show live running status.
3564 let entries = visible_entries_as_strings(&sidebar, cx);
3565 assert_eq!(
3566 entries,
3567 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
3568 );
3569}
3570
3571#[gpui::test]
3572async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
3573 agent_ui::test_support::init_test(cx);
3574 cx.update(|cx| {
3575 ThreadStore::init_global(cx);
3576 ThreadMetadataStore::init_global(cx);
3577 language_model::LanguageModelRegistry::test(cx);
3578 prompt_store::init(cx);
3579 });
3580
3581 let fs = FakeFs::new(cx.executor());
3582
3583 fs.insert_tree(
3584 "/project",
3585 serde_json::json!({
3586 ".git": {},
3587 "src": {},
3588 }),
3589 )
3590 .await;
3591
3592 fs.add_linked_worktree_for_repo(
3593 Path::new("/project/.git"),
3594 false,
3595 git::repository::Worktree {
3596 path: std::path::PathBuf::from("/wt-feature-a"),
3597 ref_name: Some("refs/heads/feature-a".into()),
3598 sha: "aaa".into(),
3599 is_main: false,
3600 is_bare: false,
3601 },
3602 )
3603 .await;
3604
3605 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3606
3607 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3608 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3609
3610 main_project
3611 .update(cx, |p, cx| p.git_scans_complete(cx))
3612 .await;
3613 worktree_project
3614 .update(cx, |p, cx| p.git_scans_complete(cx))
3615 .await;
3616
3617 let (multi_workspace, cx) =
3618 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3619
3620 let sidebar = setup_sidebar(&multi_workspace, cx);
3621
3622 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3623 mw.test_add_workspace(worktree_project.clone(), window, cx)
3624 });
3625
3626 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3627
3628 multi_workspace.update_in(cx, |mw, window, cx| {
3629 let workspace = mw.workspaces().next().unwrap().clone();
3630 mw.activate(workspace, None, window, cx);
3631 });
3632
3633 let connection = StubAgentConnection::new();
3634 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3635 send_message(&worktree_panel, cx);
3636
3637 let session_id = active_session_id(&worktree_panel, cx);
3638 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3639
3640 cx.update(|_, cx| {
3641 connection.send_update(
3642 session_id.clone(),
3643 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3644 cx,
3645 );
3646 });
3647 cx.run_until_parked();
3648
3649 assert_eq!(
3650 visible_entries_as_strings(&sidebar, cx),
3651 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
3652 );
3653
3654 connection.end_turn(session_id, acp::StopReason::EndTurn);
3655 cx.run_until_parked();
3656
3657 assert_eq!(
3658 visible_entries_as_strings(&sidebar, cx),
3659 vec!["v [project]", " Hello {wt-feature-a} * (!)",]
3660 );
3661}
3662
3663#[gpui::test]
3664async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
3665 init_test(cx);
3666 let fs = FakeFs::new(cx.executor());
3667
3668 fs.insert_tree(
3669 "/project",
3670 serde_json::json!({
3671 ".git": {},
3672 "src": {},
3673 }),
3674 )
3675 .await;
3676
3677 fs.add_linked_worktree_for_repo(
3678 Path::new("/project/.git"),
3679 false,
3680 git::repository::Worktree {
3681 path: std::path::PathBuf::from("/wt-feature-a"),
3682 ref_name: Some("refs/heads/feature-a".into()),
3683 sha: "aaa".into(),
3684 is_main: false,
3685 is_bare: false,
3686 },
3687 )
3688 .await;
3689
3690 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3691
3692 // Only open the main repo — no workspace for the worktree.
3693 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3694 main_project
3695 .update(cx, |p, cx| p.git_scans_complete(cx))
3696 .await;
3697
3698 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3699 worktree_project
3700 .update(cx, |p, cx| p.git_scans_complete(cx))
3701 .await;
3702
3703 let (multi_workspace, cx) =
3704 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3705 let sidebar = setup_sidebar(&multi_workspace, cx);
3706
3707 // Save a thread for the worktree path (no workspace for it).
3708 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3709
3710 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3711 cx.run_until_parked();
3712
3713 // Thread should appear under the main repo with a worktree chip.
3714 assert_eq!(
3715 visible_entries_as_strings(&sidebar, cx),
3716 vec![
3717 //
3718 "v [project]",
3719 " WT Thread {wt-feature-a}",
3720 ],
3721 );
3722
3723 // Only 1 workspace should exist.
3724 assert_eq!(
3725 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3726 1,
3727 );
3728
3729 // Focus the sidebar and select the worktree thread.
3730 focus_sidebar(&sidebar, cx);
3731 sidebar.update_in(cx, |sidebar, _window, _cx| {
3732 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3733 });
3734
3735 // Confirm to open the worktree thread.
3736 cx.dispatch_action(Confirm);
3737 cx.run_until_parked();
3738
3739 // A new workspace should have been created for the worktree path.
3740 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
3741 assert_eq!(
3742 mw.workspaces().count(),
3743 2,
3744 "confirming a worktree thread without a workspace should open one",
3745 );
3746 mw.workspaces().nth(1).unwrap().clone()
3747 });
3748
3749 let new_path_list =
3750 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
3751 assert_eq!(
3752 new_path_list,
3753 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
3754 "the new workspace should have been opened for the worktree path",
3755 );
3756}
3757
3758#[gpui::test]
3759async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
3760 cx: &mut TestAppContext,
3761) {
3762 init_test(cx);
3763 let fs = FakeFs::new(cx.executor());
3764
3765 fs.insert_tree(
3766 "/project",
3767 serde_json::json!({
3768 ".git": {},
3769 "src": {},
3770 }),
3771 )
3772 .await;
3773
3774 fs.add_linked_worktree_for_repo(
3775 Path::new("/project/.git"),
3776 false,
3777 git::repository::Worktree {
3778 path: std::path::PathBuf::from("/wt-feature-a"),
3779 ref_name: Some("refs/heads/feature-a".into()),
3780 sha: "aaa".into(),
3781 is_main: false,
3782 is_bare: false,
3783 },
3784 )
3785 .await;
3786
3787 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3788
3789 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3790 main_project
3791 .update(cx, |p, cx| p.git_scans_complete(cx))
3792 .await;
3793
3794 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3795 worktree_project
3796 .update(cx, |p, cx| p.git_scans_complete(cx))
3797 .await;
3798
3799 let (multi_workspace, cx) =
3800 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3801 let sidebar = setup_sidebar(&multi_workspace, cx);
3802
3803 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3804
3805 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3806 cx.run_until_parked();
3807
3808 assert_eq!(
3809 visible_entries_as_strings(&sidebar, cx),
3810 vec![
3811 //
3812 "v [project]",
3813 " WT Thread {wt-feature-a}",
3814 ],
3815 );
3816
3817 focus_sidebar(&sidebar, cx);
3818 sidebar.update_in(cx, |sidebar, _window, _cx| {
3819 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3820 });
3821
3822 let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
3823 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
3824 if let ListEntry::ProjectHeader { label, .. } = entry {
3825 Some(label.as_ref())
3826 } else {
3827 None
3828 }
3829 });
3830
3831 let Some(project_header) = project_headers.next() else {
3832 panic!("expected exactly one sidebar project header named `project`, found none");
3833 };
3834 assert_eq!(
3835 project_header, "project",
3836 "expected the only sidebar project header to be `project`"
3837 );
3838 if let Some(unexpected_header) = project_headers.next() {
3839 panic!(
3840 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
3841 );
3842 }
3843
3844 let mut saw_expected_thread = false;
3845 for entry in &sidebar.contents.entries {
3846 match entry {
3847 ListEntry::ProjectHeader { label, .. } => {
3848 assert_eq!(
3849 label.as_ref(),
3850 "project",
3851 "expected the only sidebar project header to be `project`"
3852 );
3853 }
3854 ListEntry::Thread(thread)
3855 if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
3856 && thread
3857 .worktrees
3858 .first()
3859 .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
3860 == Some("wt-feature-a") =>
3861 {
3862 saw_expected_thread = true;
3863 }
3864 ListEntry::Thread(thread) => {
3865 let title = thread.metadata.display_title();
3866 let worktree_name = thread
3867 .worktrees
3868 .first()
3869 .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
3870 .unwrap_or("<none>");
3871 panic!(
3872 "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
3873 title, worktree_name
3874 );
3875 }
3876 }
3877 }
3878
3879 assert!(
3880 saw_expected_thread,
3881 "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
3882 );
3883 };
3884
3885 sidebar
3886 .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
3887 .detach();
3888
3889 let window = cx.windows()[0];
3890 cx.update_window(window, |_, window, cx| {
3891 window.dispatch_action(Confirm.boxed_clone(), cx);
3892 })
3893 .unwrap();
3894
3895 cx.run_until_parked();
3896
3897 sidebar.update(cx, assert_sidebar_state);
3898}
3899
3900#[gpui::test]
3901async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
3902 cx: &mut TestAppContext,
3903) {
3904 init_test(cx);
3905 let fs = FakeFs::new(cx.executor());
3906
3907 fs.insert_tree(
3908 "/project",
3909 serde_json::json!({
3910 ".git": {},
3911 "src": {},
3912 }),
3913 )
3914 .await;
3915
3916 fs.add_linked_worktree_for_repo(
3917 Path::new("/project/.git"),
3918 false,
3919 git::repository::Worktree {
3920 path: std::path::PathBuf::from("/wt-feature-a"),
3921 ref_name: Some("refs/heads/feature-a".into()),
3922 sha: "aaa".into(),
3923 is_main: false,
3924 is_bare: false,
3925 },
3926 )
3927 .await;
3928
3929 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3930
3931 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3932 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3933
3934 main_project
3935 .update(cx, |p, cx| p.git_scans_complete(cx))
3936 .await;
3937 worktree_project
3938 .update(cx, |p, cx| p.git_scans_complete(cx))
3939 .await;
3940
3941 let (multi_workspace, cx) =
3942 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3943
3944 let sidebar = setup_sidebar(&multi_workspace, cx);
3945
3946 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3947 mw.test_add_workspace(worktree_project.clone(), window, cx)
3948 });
3949
3950 // Activate the main workspace before setting up the sidebar.
3951 let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3952 let workspace = mw.workspaces().next().unwrap().clone();
3953 mw.activate(workspace.clone(), None, window, cx);
3954 workspace
3955 });
3956
3957 save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
3958 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3959
3960 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3961 cx.run_until_parked();
3962
3963 // The worktree workspace should be absorbed under the main repo.
3964 let entries = visible_entries_as_strings(&sidebar, cx);
3965 assert_eq!(entries.len(), 3);
3966 assert_eq!(entries[0], "v [project]");
3967 assert!(entries.contains(&" Main Thread".to_string()));
3968 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
3969
3970 let wt_thread_index = entries
3971 .iter()
3972 .position(|e| e.contains("WT Thread"))
3973 .expect("should find the worktree thread entry");
3974
3975 assert_eq!(
3976 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3977 main_workspace,
3978 "main workspace should be active initially"
3979 );
3980
3981 // Focus the sidebar and select the absorbed worktree thread.
3982 focus_sidebar(&sidebar, cx);
3983 sidebar.update_in(cx, |sidebar, _window, _cx| {
3984 sidebar.selection = Some(wt_thread_index);
3985 });
3986
3987 // Confirm to activate the worktree thread.
3988 cx.dispatch_action(Confirm);
3989 cx.run_until_parked();
3990
3991 // The worktree workspace should now be active, not the main one.
3992 let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3993 assert_eq!(
3994 active_workspace, worktree_workspace,
3995 "clicking an absorbed worktree thread should activate the worktree workspace"
3996 );
3997}
3998
3999// Reproduces the core of the user-reported bug: a thread belonging to
4000// a multi-root workspace that mixes a standalone project and a linked
4001// git worktree can become invisible in the sidebar when its stored
4002// `main_worktree_paths` don't match the workspace's project group
4003// key. The metadata still exists and Thread History still shows it,
4004// but the sidebar rebuild's lookups all miss.
4005//
4006// Real-world setup: a single multi-root workspace whose roots are
4007// `[/cloud, /worktrees/zed/wt_a/zed]`, where:
4008// - `/cloud` is a standalone git repo (main == folder).
4009// - `/worktrees/zed/wt_a/zed` is a linked worktree of `/zed`.
4010//
4011// Once git scans complete the project group key is
4012// `[/cloud, /zed]` — the main paths of the two roots. A thread
4013// created in this workspace is written with
4014// `main=[/cloud, /zed], folder=[/cloud, /worktrees/zed/wt_a/zed]`
4015// and the sidebar finds it via `entries_for_main_worktree_path`.
4016//
4017// If some other code path (stale data on reload, a path-less archive
4018// restored via the project picker, a legacy write …) persists the
4019// thread with `main == folder` instead, the stored
4020// `main_worktree_paths` is
4021// `[/cloud, /worktrees/zed/wt_a/zed]` ≠ `[/cloud, /zed]`. The three
4022// lookups in `rebuild_contents` all miss:
4023//
4024// 1. `entries_for_main_worktree_path([/cloud, /zed])` — the
4025// thread's stored main doesn't equal the group key.
4026// 2. `entries_for_path([/cloud, /zed])` — the thread's folder paths
4027// don't equal the group key either.
4028// 3. The linked-worktree fallback iterates the group's workspaces'
4029// `linked_worktrees()` snapshots. Those yield *sibling* linked
4030// worktrees of the repo, not the workspace's own roots, so the
4031// thread's folder `/worktrees/zed/wt_a/zed` doesn't match.
4032//
4033// The row falls out of the sidebar entirely — matching the user's
4034// symptom of a thread visible in the agent panel but missing from
4035// the sidebar. It only reappears once something re-writes the
4036// thread's metadata in the good shape (e.g. `handle_conversation_event`
4037// firing after the user sends a message).
4038//
4039// We directly persist the bad shape via `store.save(...)` rather
4040// than trying to reproduce the original writer. The bug is
4041// ultimately about the sidebar's tolerance for any stale row whose
4042// folder paths correspond to an open workspace's roots, regardless
4043// of how that row came to be in the store.
4044#[gpui::test]
4045async fn test_sidebar_keeps_multi_root_thread_with_stale_main_paths(cx: &mut TestAppContext) {
4046 agent_ui::test_support::init_test(cx);
4047 cx.update(|cx| {
4048 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
4049 ThreadStore::init_global(cx);
4050 ThreadMetadataStore::init_global(cx);
4051 language_model::LanguageModelRegistry::test(cx);
4052 prompt_store::init(cx);
4053 });
4054
4055 let fs = FakeFs::new(cx.executor());
4056
4057 // Standalone repo — one of the workspace's two roots, main
4058 // worktree of its own .git.
4059 fs.insert_tree(
4060 "/cloud",
4061 serde_json::json!({
4062 ".git": {},
4063 "src": {},
4064 }),
4065 )
4066 .await;
4067
4068 // Separate /zed repo whose linked worktree will form the second
4069 // workspace root. /zed itself is NOT opened as a workspace root.
4070 fs.insert_tree(
4071 "/zed",
4072 serde_json::json!({
4073 ".git": {},
4074 "src": {},
4075 }),
4076 )
4077 .await;
4078 fs.insert_tree(
4079 "/worktrees/zed/wt_a/zed",
4080 serde_json::json!({
4081 ".git": "gitdir: /zed/.git/worktrees/wt_a",
4082 "src": {},
4083 }),
4084 )
4085 .await;
4086 fs.add_linked_worktree_for_repo(
4087 Path::new("/zed/.git"),
4088 false,
4089 git::repository::Worktree {
4090 path: std::path::PathBuf::from("/worktrees/zed/wt_a/zed"),
4091 ref_name: Some("refs/heads/wt_a".into()),
4092 sha: "aaa".into(),
4093 is_main: false,
4094 is_bare: false,
4095 },
4096 )
4097 .await;
4098
4099 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4100
4101 // Single multi-root project with both /cloud and the linked
4102 // worktree of /zed.
4103 let project = project::Project::test(
4104 fs.clone(),
4105 ["/cloud".as_ref(), "/worktrees/zed/wt_a/zed".as_ref()],
4106 cx,
4107 )
4108 .await;
4109 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4110
4111 let (multi_workspace, cx) =
4112 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4113 let sidebar = setup_sidebar(&multi_workspace, cx);
4114 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4115 let _panel = add_agent_panel(&workspace, cx);
4116 cx.run_until_parked();
4117
4118 // Sanity-check the shapes the rest of the test depends on.
4119 let group_key = workspace.read_with(cx, |ws, cx| ws.project_group_key(cx));
4120 let expected_main_paths = PathList::new(&[PathBuf::from("/cloud"), PathBuf::from("/zed")]);
4121 assert_eq!(
4122 group_key.path_list(),
4123 &expected_main_paths,
4124 "expected the multi-root workspace's project group key to normalize to \
4125 [/cloud, /zed] (main of the standalone repo + main of the linked worktree)"
4126 );
4127
4128 let folder_paths = PathList::new(&[
4129 PathBuf::from("/cloud"),
4130 PathBuf::from("/worktrees/zed/wt_a/zed"),
4131 ]);
4132 let workspace_root_paths = workspace.read_with(cx, |ws, cx| PathList::new(&ws.root_paths(cx)));
4133 assert_eq!(
4134 workspace_root_paths, folder_paths,
4135 "expected the workspace's root paths to equal [/cloud, /worktrees/zed/wt_a/zed]"
4136 );
4137
4138 let session_id = acp::SessionId::new(Arc::from("multi-root-stale-paths"));
4139 let thread_id = ThreadId::new();
4140
4141 // Persist the thread in the "bad" shape that the bug manifests as:
4142 // main == folder for every root. Any stale row where
4143 // `main_worktree_paths` no longer equals the group key produces
4144 // the same user-visible symptom; this is the concrete shape
4145 // produced by `WorktreePaths::from_folder_paths` on the workspace
4146 // roots.
4147 cx.update(|_, cx| {
4148 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4149 store.save(
4150 ThreadMetadata {
4151 thread_id,
4152 session_id: Some(session_id.clone()),
4153 agent_id: agent::ZED_AGENT_ID.clone(),
4154 title: Some("Stale Multi-Root Thread".into()),
4155 updated_at: Utc::now(),
4156 created_at: None,
4157 interacted_at: None,
4158 worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
4159 archived: false,
4160 remote_connection: None,
4161 },
4162 cx,
4163 )
4164 });
4165 });
4166 cx.run_until_parked();
4167
4168 let entries = visible_entries_as_strings(&sidebar, cx);
4169 let visible = sidebar.read_with(cx, |sidebar, _cx| has_thread_entry(sidebar, &session_id));
4170
4171 // If this assert fails, we've reproduced the bug: the sidebar's
4172 // rebuild queries can't locate the thread under the current
4173 // project group, even though the metadata is intact and the
4174 // thread's folder paths exactly equal the open workspace's roots.
4175 assert!(
4176 visible,
4177 "thread disappeared from the sidebar when its main_worktree_paths \
4178 ({folder_paths:?}) diverged from the project group key ({expected_main_paths:?}); \
4179 sidebar entries: {entries:?}"
4180 );
4181}
4182
4183#[gpui::test]
4184async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
4185 cx: &mut TestAppContext,
4186) {
4187 // Thread has saved metadata in ThreadStore. A matching workspace is
4188 // already open. Expected: activates the matching workspace.
4189 init_test(cx);
4190 let fs = FakeFs::new(cx.executor());
4191 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4192 .await;
4193 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4194 .await;
4195 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4196
4197 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4198 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4199
4200 let (multi_workspace, cx) =
4201 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4202
4203 let sidebar = setup_sidebar(&multi_workspace, cx);
4204
4205 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4206 mw.test_add_workspace(project_b.clone(), window, cx)
4207 });
4208 let workspace_a =
4209 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4210
4211 // Save a thread with path_list pointing to project-b.
4212 let session_id = acp::SessionId::new(Arc::from("archived-1"));
4213 save_test_thread_metadata(&session_id, &project_b, cx).await;
4214
4215 // Ensure workspace A is active.
4216 multi_workspace.update_in(cx, |mw, window, cx| {
4217 let workspace = mw.workspaces().next().unwrap().clone();
4218 mw.activate(workspace, None, window, cx);
4219 });
4220 cx.run_until_parked();
4221 assert_eq!(
4222 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4223 workspace_a
4224 );
4225
4226 // Call activate_archived_thread – should resolve saved paths and
4227 // switch to the workspace for project-b.
4228 sidebar.update_in(cx, |sidebar, window, cx| {
4229 sidebar.open_thread_from_archive(
4230 ThreadMetadata {
4231 thread_id: ThreadId::new(),
4232 session_id: Some(session_id.clone()),
4233 agent_id: agent::ZED_AGENT_ID.clone(),
4234 title: Some("Archived Thread".into()),
4235 updated_at: Utc::now(),
4236 created_at: None,
4237 interacted_at: None,
4238 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4239 "/project-b",
4240 )])),
4241 archived: false,
4242 remote_connection: None,
4243 },
4244 window,
4245 cx,
4246 );
4247 });
4248 cx.run_until_parked();
4249
4250 assert_eq!(
4251 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4252 workspace_b,
4253 "should have switched to the workspace matching the saved paths"
4254 );
4255}
4256
4257#[gpui::test]
4258async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
4259 cx: &mut TestAppContext,
4260) {
4261 // Thread has no saved metadata but session_info has cwd. A matching
4262 // workspace is open. Expected: uses cwd to find and activate it.
4263 init_test(cx);
4264 let fs = FakeFs::new(cx.executor());
4265 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4266 .await;
4267 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4268 .await;
4269 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4270
4271 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4272 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4273
4274 let (multi_workspace, cx) =
4275 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4276
4277 let sidebar = setup_sidebar(&multi_workspace, cx);
4278
4279 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4280 mw.test_add_workspace(project_b, window, cx)
4281 });
4282 let workspace_a =
4283 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4284
4285 // Start with workspace A active.
4286 multi_workspace.update_in(cx, |mw, window, cx| {
4287 let workspace = mw.workspaces().next().unwrap().clone();
4288 mw.activate(workspace, None, window, cx);
4289 });
4290 cx.run_until_parked();
4291 assert_eq!(
4292 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4293 workspace_a
4294 );
4295
4296 // No thread saved to the store – cwd is the only path hint.
4297 sidebar.update_in(cx, |sidebar, window, cx| {
4298 sidebar.open_thread_from_archive(
4299 ThreadMetadata {
4300 thread_id: ThreadId::new(),
4301 session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
4302 agent_id: agent::ZED_AGENT_ID.clone(),
4303 title: Some("CWD Thread".into()),
4304 updated_at: Utc::now(),
4305 created_at: None,
4306 interacted_at: None,
4307 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
4308 std::path::PathBuf::from("/project-b"),
4309 ])),
4310 archived: false,
4311 remote_connection: None,
4312 },
4313 window,
4314 cx,
4315 );
4316 });
4317 cx.run_until_parked();
4318
4319 assert_eq!(
4320 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4321 workspace_b,
4322 "should have activated the workspace matching the cwd"
4323 );
4324}
4325
4326#[gpui::test]
4327async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4328 cx: &mut TestAppContext,
4329) {
4330 // Thread has no saved metadata and no cwd. Expected: falls back to
4331 // the currently active workspace.
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, cx) =
4344 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4345
4346 let sidebar = setup_sidebar(&multi_workspace, cx);
4347
4348 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4349 mw.test_add_workspace(project_b, window, cx)
4350 });
4351
4352 // Activate workspace B (index 1) to make it the active one.
4353 multi_workspace.update_in(cx, |mw, window, cx| {
4354 let workspace = mw.workspaces().nth(1).unwrap().clone();
4355 mw.activate(workspace, None, window, cx);
4356 });
4357 cx.run_until_parked();
4358 assert_eq!(
4359 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4360 workspace_b
4361 );
4362
4363 // No saved thread, no cwd – should fall back to the active workspace.
4364 sidebar.update_in(cx, |sidebar, window, cx| {
4365 sidebar.open_thread_from_archive(
4366 ThreadMetadata {
4367 thread_id: ThreadId::new(),
4368 session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
4369 agent_id: agent::ZED_AGENT_ID.clone(),
4370 title: Some("Contextless Thread".into()),
4371 updated_at: Utc::now(),
4372 created_at: None,
4373 interacted_at: None,
4374 worktree_paths: WorktreePaths::default(),
4375 archived: false,
4376 remote_connection: None,
4377 },
4378 window,
4379 cx,
4380 );
4381 });
4382 cx.run_until_parked();
4383
4384 assert_eq!(
4385 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4386 workspace_b,
4387 "should have stayed on the active workspace when no path info is available"
4388 );
4389}
4390
4391#[gpui::test]
4392async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
4393 // Thread has saved metadata pointing to a path with no open workspace.
4394 // Expected: opens a new workspace for that path.
4395 init_test(cx);
4396 let fs = FakeFs::new(cx.executor());
4397 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4398 .await;
4399 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4400 .await;
4401 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4402
4403 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4404
4405 let (multi_workspace, cx) =
4406 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4407
4408 let sidebar = setup_sidebar(&multi_workspace, cx);
4409
4410 // Save a thread with path_list pointing to project-b – which has no
4411 // open workspace.
4412 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4413 let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4414
4415 assert_eq!(
4416 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4417 1,
4418 "should start with one workspace"
4419 );
4420
4421 sidebar.update_in(cx, |sidebar, window, cx| {
4422 sidebar.open_thread_from_archive(
4423 ThreadMetadata {
4424 thread_id: ThreadId::new(),
4425 session_id: Some(session_id.clone()),
4426 agent_id: agent::ZED_AGENT_ID.clone(),
4427 title: Some("New WS Thread".into()),
4428 updated_at: Utc::now(),
4429 created_at: None,
4430 interacted_at: None,
4431 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
4432 archived: false,
4433 remote_connection: None,
4434 },
4435 window,
4436 cx,
4437 );
4438 });
4439 cx.run_until_parked();
4440
4441 assert_eq!(
4442 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4443 2,
4444 "should have opened a second workspace for the archived thread's saved paths"
4445 );
4446}
4447
4448#[gpui::test]
4449async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
4450 init_test(cx);
4451 let fs = FakeFs::new(cx.executor());
4452 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4453 .await;
4454 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4455 .await;
4456 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4457
4458 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4459 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4460
4461 let multi_workspace_a =
4462 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4463 let multi_workspace_b =
4464 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4465
4466 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4467 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4468
4469 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4470 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4471
4472 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4473 let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
4474
4475 let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
4476
4477 sidebar.update_in(cx_a, |sidebar, window, cx| {
4478 sidebar.open_thread_from_archive(
4479 ThreadMetadata {
4480 thread_id: ThreadId::new(),
4481 session_id: Some(session_id.clone()),
4482 agent_id: agent::ZED_AGENT_ID.clone(),
4483 title: Some("Cross Window Thread".into()),
4484 updated_at: Utc::now(),
4485 created_at: None,
4486 interacted_at: None,
4487 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4488 "/project-b",
4489 )])),
4490 archived: false,
4491 remote_connection: None,
4492 },
4493 window,
4494 cx,
4495 );
4496 });
4497 cx_a.run_until_parked();
4498
4499 assert_eq!(
4500 multi_workspace_a
4501 .read_with(cx_a, |mw, _| mw.workspaces().count())
4502 .unwrap(),
4503 1,
4504 "should not add the other window's workspace into the current window"
4505 );
4506 assert_eq!(
4507 multi_workspace_b
4508 .read_with(cx_a, |mw, _| mw.workspaces().count())
4509 .unwrap(),
4510 1,
4511 "should reuse the existing workspace in the other window"
4512 );
4513 assert!(
4514 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4515 "should activate the window that already owns the matching workspace"
4516 );
4517 sidebar.read_with(cx_a, |sidebar, _| {
4518 assert!(
4519 !is_active_session(&sidebar, &session_id),
4520 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4521 );
4522 });
4523}
4524
4525#[gpui::test]
4526async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
4527 cx: &mut TestAppContext,
4528) {
4529 init_test(cx);
4530 let fs = FakeFs::new(cx.executor());
4531 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4532 .await;
4533 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4534 .await;
4535 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4536
4537 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4538 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4539
4540 let multi_workspace_a =
4541 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4542 let multi_workspace_b =
4543 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
4544
4545 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4546 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4547
4548 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4549 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4550
4551 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4552 let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4553 let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
4554 let _panel_b = add_agent_panel(&workspace_b, cx_b);
4555
4556 let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
4557
4558 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4559 sidebar.open_thread_from_archive(
4560 ThreadMetadata {
4561 thread_id: ThreadId::new(),
4562 session_id: Some(session_id.clone()),
4563 agent_id: agent::ZED_AGENT_ID.clone(),
4564 title: Some("Cross Window Thread".into()),
4565 updated_at: Utc::now(),
4566 created_at: None,
4567 interacted_at: None,
4568 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4569 "/project-b",
4570 )])),
4571 archived: false,
4572 remote_connection: None,
4573 },
4574 window,
4575 cx,
4576 );
4577 });
4578 cx_a.run_until_parked();
4579
4580 assert_eq!(
4581 multi_workspace_a
4582 .read_with(cx_a, |mw, _| mw.workspaces().count())
4583 .unwrap(),
4584 1,
4585 "should not add the other window's workspace into the current window"
4586 );
4587 assert_eq!(
4588 multi_workspace_b
4589 .read_with(cx_a, |mw, _| mw.workspaces().count())
4590 .unwrap(),
4591 1,
4592 "should reuse the existing workspace in the other window"
4593 );
4594 assert!(
4595 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4596 "should activate the window that already owns the matching workspace"
4597 );
4598 sidebar_a.read_with(cx_a, |sidebar, _| {
4599 assert!(
4600 !is_active_session(&sidebar, &session_id),
4601 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4602 );
4603 });
4604 sidebar_b.read_with(cx_b, |sidebar, _| {
4605 assert_active_thread(
4606 sidebar,
4607 &session_id,
4608 "target window's sidebar should eagerly focus the activated archived thread",
4609 );
4610 });
4611}
4612
4613#[gpui::test]
4614async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
4615 cx: &mut TestAppContext,
4616) {
4617 init_test(cx);
4618 let fs = FakeFs::new(cx.executor());
4619 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4620 .await;
4621 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4622
4623 let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4624 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4625
4626 let multi_workspace_b =
4627 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4628 let multi_workspace_a =
4629 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4630
4631 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4632 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4633
4634 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4635 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4636
4637 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4638 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4639
4640 let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
4641
4642 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4643 sidebar.open_thread_from_archive(
4644 ThreadMetadata {
4645 thread_id: ThreadId::new(),
4646 session_id: Some(session_id.clone()),
4647 agent_id: agent::ZED_AGENT_ID.clone(),
4648 title: Some("Current Window Thread".into()),
4649 updated_at: Utc::now(),
4650 created_at: None,
4651 interacted_at: None,
4652 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4653 "/project-a",
4654 )])),
4655 archived: false,
4656 remote_connection: None,
4657 },
4658 window,
4659 cx,
4660 );
4661 });
4662 cx_a.run_until_parked();
4663
4664 assert!(
4665 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
4666 "should keep activation in the current window when it already has a matching workspace"
4667 );
4668 sidebar_a.read_with(cx_a, |sidebar, _| {
4669 assert_active_thread(
4670 sidebar,
4671 &session_id,
4672 "current window's sidebar should eagerly focus the activated archived thread",
4673 );
4674 });
4675 assert_eq!(
4676 multi_workspace_a
4677 .read_with(cx_a, |mw, _| mw.workspaces().count())
4678 .unwrap(),
4679 1,
4680 "current window should continue reusing its existing workspace"
4681 );
4682 assert_eq!(
4683 multi_workspace_b
4684 .read_with(cx_a, |mw, _| mw.workspaces().count())
4685 .unwrap(),
4686 1,
4687 "other windows should not be activated just because they also match the saved paths"
4688 );
4689}
4690
4691#[gpui::test]
4692async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
4693 // Regression test: archive_thread previously always loaded the next thread
4694 // through group_workspace (the main workspace's ProjectHeader), even when
4695 // the next thread belonged to an absorbed linked-worktree workspace. That
4696 // caused the worktree thread to be loaded in the main panel, which bound it
4697 // to the main project and corrupted its stored folder_paths.
4698 //
4699 // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
4700 // falling back to group_workspace only for Closed workspaces.
4701 agent_ui::test_support::init_test(cx);
4702 cx.update(|cx| {
4703 ThreadStore::init_global(cx);
4704 ThreadMetadataStore::init_global(cx);
4705 language_model::LanguageModelRegistry::test(cx);
4706 prompt_store::init(cx);
4707 });
4708
4709 let fs = FakeFs::new(cx.executor());
4710
4711 fs.insert_tree(
4712 "/project",
4713 serde_json::json!({
4714 ".git": {},
4715 "src": {},
4716 }),
4717 )
4718 .await;
4719
4720 fs.add_linked_worktree_for_repo(
4721 Path::new("/project/.git"),
4722 false,
4723 git::repository::Worktree {
4724 path: std::path::PathBuf::from("/wt-feature-a"),
4725 ref_name: Some("refs/heads/feature-a".into()),
4726 sha: "aaa".into(),
4727 is_main: false,
4728 is_bare: false,
4729 },
4730 )
4731 .await;
4732
4733 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4734
4735 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4736 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4737
4738 main_project
4739 .update(cx, |p, cx| p.git_scans_complete(cx))
4740 .await;
4741 worktree_project
4742 .update(cx, |p, cx| p.git_scans_complete(cx))
4743 .await;
4744
4745 let (multi_workspace, cx) =
4746 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4747
4748 let sidebar = setup_sidebar(&multi_workspace, cx);
4749
4750 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4751 mw.test_add_workspace(worktree_project.clone(), window, cx)
4752 });
4753
4754 // Activate main workspace so the sidebar tracks the main panel.
4755 multi_workspace.update_in(cx, |mw, window, cx| {
4756 let workspace = mw.workspaces().next().unwrap().clone();
4757 mw.activate(workspace, None, window, cx);
4758 });
4759
4760 let main_workspace =
4761 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4762 let main_panel = add_agent_panel(&main_workspace, cx);
4763 let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
4764
4765 // Open Thread 2 in the main panel and keep it running.
4766 let connection = StubAgentConnection::new();
4767 open_thread_with_connection(&main_panel, connection.clone(), cx);
4768 send_message(&main_panel, cx);
4769
4770 let thread2_session_id = active_session_id(&main_panel, cx);
4771
4772 cx.update(|_, cx| {
4773 connection.send_update(
4774 thread2_session_id.clone(),
4775 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4776 cx,
4777 );
4778 });
4779
4780 // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
4781 save_thread_metadata(
4782 thread2_session_id.clone(),
4783 Some("Thread 2".into()),
4784 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4785 None,
4786 None,
4787 &main_project,
4788 cx,
4789 );
4790
4791 // Save thread 1's metadata with the worktree path and an older timestamp so
4792 // it sorts below thread 2. archive_thread will find it as the "next" candidate.
4793 let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
4794 save_thread_metadata(
4795 thread1_session_id,
4796 Some("Thread 1".into()),
4797 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4798 None,
4799 None,
4800 &worktree_project,
4801 cx,
4802 );
4803
4804 cx.run_until_parked();
4805
4806 // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
4807 let entries_before = visible_entries_as_strings(&sidebar, cx);
4808 assert!(
4809 entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
4810 "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
4811 entries_before
4812 );
4813
4814 // The sidebar should track T2 as the focused thread (derived from the
4815 // main panel's active view).
4816 sidebar.read_with(cx, |s, _| {
4817 assert_active_thread(
4818 s,
4819 &thread2_session_id,
4820 "focused thread should be Thread 2 before archiving",
4821 );
4822 });
4823
4824 // Archive thread 2.
4825 sidebar.update_in(cx, |sidebar, window, cx| {
4826 sidebar.archive_thread(&thread2_session_id, window, cx);
4827 });
4828
4829 cx.run_until_parked();
4830
4831 // The main panel's active thread must still be thread 2.
4832 let main_active = main_panel.read_with(cx, |panel, cx| {
4833 panel
4834 .active_agent_thread(cx)
4835 .map(|t| t.read(cx).session_id().clone())
4836 });
4837 assert_eq!(
4838 main_active,
4839 Some(thread2_session_id.clone()),
4840 "main panel should not have been taken over by loading the linked-worktree thread T1; \
4841 before the fix, archive_thread used group_workspace instead of next.workspace, \
4842 causing T1 to be loaded in the wrong panel"
4843 );
4844
4845 // Thread 1 should still appear in the sidebar with its worktree chip
4846 // (Thread 2 was archived so it is gone from the list).
4847 let entries_after = visible_entries_as_strings(&sidebar, cx);
4848 assert!(
4849 entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
4850 "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
4851 entries_after
4852 );
4853}
4854
4855#[gpui::test]
4856async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
4857 // When the last non-archived thread for a linked worktree is archived,
4858 // the linked worktree workspace should be removed from the multi-workspace.
4859 // The main worktree workspace should remain (it's always reachable via
4860 // the project header).
4861 init_test(cx);
4862 let fs = FakeFs::new(cx.executor());
4863
4864 fs.insert_tree(
4865 "/project",
4866 serde_json::json!({
4867 ".git": {
4868 "worktrees": {
4869 "feature-a": {
4870 "commondir": "../../",
4871 "HEAD": "ref: refs/heads/feature-a",
4872 },
4873 },
4874 },
4875 "src": {},
4876 }),
4877 )
4878 .await;
4879
4880 fs.insert_tree(
4881 "/worktrees/project/feature-a/project",
4882 serde_json::json!({
4883 ".git": "gitdir: /project/.git/worktrees/feature-a",
4884 "src": {},
4885 }),
4886 )
4887 .await;
4888
4889 fs.add_linked_worktree_for_repo(
4890 Path::new("/project/.git"),
4891 false,
4892 git::repository::Worktree {
4893 path: PathBuf::from("/worktrees/project/feature-a/project"),
4894 ref_name: Some("refs/heads/feature-a".into()),
4895 sha: "abc".into(),
4896 is_main: false,
4897 is_bare: false,
4898 },
4899 )
4900 .await;
4901
4902 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4903
4904 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4905 let worktree_project = project::Project::test(
4906 fs.clone(),
4907 ["/worktrees/project/feature-a/project".as_ref()],
4908 cx,
4909 )
4910 .await;
4911
4912 main_project
4913 .update(cx, |p, cx| p.git_scans_complete(cx))
4914 .await;
4915 worktree_project
4916 .update(cx, |p, cx| p.git_scans_complete(cx))
4917 .await;
4918
4919 let (multi_workspace, cx) =
4920 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4921 let sidebar = setup_sidebar(&multi_workspace, cx);
4922
4923 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4924 mw.test_add_workspace(worktree_project.clone(), window, cx)
4925 });
4926
4927 // Save a thread for the main project.
4928 save_thread_metadata(
4929 acp::SessionId::new(Arc::from("main-thread")),
4930 Some("Main Thread".into()),
4931 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4932 None,
4933 None,
4934 &main_project,
4935 cx,
4936 );
4937
4938 // Save a thread for the linked worktree.
4939 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
4940 save_thread_metadata(
4941 wt_thread_id.clone(),
4942 Some("Worktree Thread".into()),
4943 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4944 None,
4945 None,
4946 &worktree_project,
4947 cx,
4948 );
4949 cx.run_until_parked();
4950
4951 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4952 cx.run_until_parked();
4953
4954 // Should have 2 workspaces.
4955 assert_eq!(
4956 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4957 2,
4958 "should start with 2 workspaces (main + linked worktree)"
4959 );
4960
4961 // Archive the worktree thread (the only thread for /wt-feature-a).
4962 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
4963 sidebar.archive_thread(&wt_thread_id, window, cx);
4964 });
4965
4966 // archive_thread spawns a multi-layered chain of tasks (workspace
4967 // removal → git persist → disk removal), each of which may spawn
4968 // further background work. Each run_until_parked() call drives one
4969 // layer of pending work.
4970
4971 cx.run_until_parked();
4972
4973 // The linked worktree workspace should have been removed.
4974 assert_eq!(
4975 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4976 1,
4977 "linked worktree workspace should be removed after archiving its last thread"
4978 );
4979
4980 // The linked worktree checkout directory should also be removed from disk.
4981 assert!(
4982 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
4983 .await,
4984 "linked worktree directory should be removed from disk after archiving its last thread"
4985 );
4986
4987 // The main thread should still be visible.
4988 let entries = visible_entries_as_strings(&sidebar, cx);
4989 assert!(
4990 entries.iter().any(|e| e.contains("Main Thread")),
4991 "main thread should still be visible: {entries:?}"
4992 );
4993 assert!(
4994 !entries.iter().any(|e| e.contains("Worktree Thread")),
4995 "archived worktree thread should not be visible: {entries:?}"
4996 );
4997
4998 // The archived thread must retain its folder_paths so it can be
4999 // restored to the correct workspace later.
5000 let wt_thread_id = cx.update(|_window, cx| {
5001 ThreadMetadataStore::global(cx)
5002 .read(cx)
5003 .entry_by_session(&wt_thread_id)
5004 .unwrap()
5005 .thread_id
5006 });
5007 let archived_paths = cx.update(|_window, cx| {
5008 ThreadMetadataStore::global(cx)
5009 .read(cx)
5010 .entry(wt_thread_id)
5011 .unwrap()
5012 .folder_paths()
5013 .clone()
5014 });
5015 assert_eq!(
5016 archived_paths.paths(),
5017 &[PathBuf::from("/worktrees/project/feature-a/project")],
5018 "archived thread must retain its folder_paths for restore"
5019 );
5020}
5021
5022#[gpui::test]
5023async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
5024 // restore_worktree_via_git should succeed when the branch has moved
5025 // to a different SHA since archival. The worktree stays in detached
5026 // HEAD and the moved branch is left untouched.
5027 init_test(cx);
5028 let fs = FakeFs::new(cx.executor());
5029
5030 fs.insert_tree(
5031 "/project",
5032 serde_json::json!({
5033 ".git": {
5034 "worktrees": {
5035 "feature-a": {
5036 "commondir": "../../",
5037 "HEAD": "ref: refs/heads/feature-a",
5038 },
5039 },
5040 },
5041 "src": {},
5042 }),
5043 )
5044 .await;
5045 fs.insert_tree(
5046 "/wt-feature-a",
5047 serde_json::json!({
5048 ".git": "gitdir: /project/.git/worktrees/feature-a",
5049 "src": {},
5050 }),
5051 )
5052 .await;
5053 fs.add_linked_worktree_for_repo(
5054 Path::new("/project/.git"),
5055 false,
5056 git::repository::Worktree {
5057 path: PathBuf::from("/wt-feature-a"),
5058 ref_name: Some("refs/heads/feature-a".into()),
5059 sha: "original-sha".into(),
5060 is_main: false,
5061 is_bare: false,
5062 },
5063 )
5064 .await;
5065 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5066
5067 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5068 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5069 main_project
5070 .update(cx, |p, cx| p.git_scans_complete(cx))
5071 .await;
5072 worktree_project
5073 .update(cx, |p, cx| p.git_scans_complete(cx))
5074 .await;
5075
5076 let (multi_workspace, _cx) =
5077 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5078 multi_workspace.update_in(_cx, |mw, window, cx| {
5079 mw.test_add_workspace(worktree_project.clone(), window, cx)
5080 });
5081
5082 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5083 project.repositories(cx).values().next().unwrap().clone()
5084 });
5085 let (staged_hash, unstaged_hash) = cx
5086 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5087 .await
5088 .unwrap()
5089 .unwrap();
5090
5091 // Move the branch to a different SHA.
5092 fs.with_git_state(Path::new("/project/.git"), false, |state| {
5093 state
5094 .refs
5095 .insert("refs/heads/feature-a".into(), "moved-sha".into());
5096 })
5097 .unwrap();
5098
5099 let result = cx
5100 .spawn(|mut cx| async move {
5101 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5102 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5103 id: 1,
5104 worktree_path: PathBuf::from("/wt-feature-a"),
5105 main_repo_path: PathBuf::from("/project"),
5106 branch_name: Some("feature-a".to_string()),
5107 staged_commit_hash: staged_hash,
5108 unstaged_commit_hash: unstaged_hash,
5109 original_commit_hash: "original-sha".to_string(),
5110 },
5111 None,
5112 &mut cx,
5113 )
5114 .await
5115 })
5116 .await;
5117
5118 assert!(
5119 result.is_ok(),
5120 "restore should succeed even when branch has moved: {:?}",
5121 result.err()
5122 );
5123
5124 // The moved branch ref should be completely untouched.
5125 let branch_sha = fs
5126 .with_git_state(Path::new("/project/.git"), false, |state| {
5127 state.refs.get("refs/heads/feature-a").cloned()
5128 })
5129 .unwrap();
5130 assert_eq!(
5131 branch_sha.as_deref(),
5132 Some("moved-sha"),
5133 "the moved branch ref should not be modified by the restore"
5134 );
5135}
5136
5137#[gpui::test]
5138async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
5139 // restore_worktree_via_git should succeed when the branch still
5140 // points at the same SHA as at archive time.
5141 init_test(cx);
5142 let fs = FakeFs::new(cx.executor());
5143
5144 fs.insert_tree(
5145 "/project",
5146 serde_json::json!({
5147 ".git": {
5148 "worktrees": {
5149 "feature-b": {
5150 "commondir": "../../",
5151 "HEAD": "ref: refs/heads/feature-b",
5152 },
5153 },
5154 },
5155 "src": {},
5156 }),
5157 )
5158 .await;
5159 fs.insert_tree(
5160 "/wt-feature-b",
5161 serde_json::json!({
5162 ".git": "gitdir: /project/.git/worktrees/feature-b",
5163 "src": {},
5164 }),
5165 )
5166 .await;
5167 fs.add_linked_worktree_for_repo(
5168 Path::new("/project/.git"),
5169 false,
5170 git::repository::Worktree {
5171 path: PathBuf::from("/wt-feature-b"),
5172 ref_name: Some("refs/heads/feature-b".into()),
5173 sha: "original-sha".into(),
5174 is_main: false,
5175 is_bare: false,
5176 },
5177 )
5178 .await;
5179 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5180
5181 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5182 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
5183 main_project
5184 .update(cx, |p, cx| p.git_scans_complete(cx))
5185 .await;
5186 worktree_project
5187 .update(cx, |p, cx| p.git_scans_complete(cx))
5188 .await;
5189
5190 let (multi_workspace, _cx) =
5191 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5192 multi_workspace.update_in(_cx, |mw, window, cx| {
5193 mw.test_add_workspace(worktree_project.clone(), window, cx)
5194 });
5195
5196 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5197 project.repositories(cx).values().next().unwrap().clone()
5198 });
5199 let (staged_hash, unstaged_hash) = cx
5200 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5201 .await
5202 .unwrap()
5203 .unwrap();
5204
5205 // refs/heads/feature-b already points at "original-sha" (set by
5206 // add_linked_worktree_for_repo), matching original_commit_hash.
5207
5208 let result = cx
5209 .spawn(|mut cx| async move {
5210 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5211 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5212 id: 1,
5213 worktree_path: PathBuf::from("/wt-feature-b"),
5214 main_repo_path: PathBuf::from("/project"),
5215 branch_name: Some("feature-b".to_string()),
5216 staged_commit_hash: staged_hash,
5217 unstaged_commit_hash: unstaged_hash,
5218 original_commit_hash: "original-sha".to_string(),
5219 },
5220 None,
5221 &mut cx,
5222 )
5223 .await
5224 })
5225 .await;
5226
5227 assert!(
5228 result.is_ok(),
5229 "restore should succeed when branch has not moved: {:?}",
5230 result.err()
5231 );
5232}
5233
5234#[gpui::test]
5235async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
5236 // restore_worktree_via_git should succeed when the branch no longer
5237 // exists (e.g. it was deleted while the thread was archived). The
5238 // code should attempt to recreate the branch.
5239 init_test(cx);
5240 let fs = FakeFs::new(cx.executor());
5241
5242 fs.insert_tree(
5243 "/project",
5244 serde_json::json!({
5245 ".git": {
5246 "worktrees": {
5247 "feature-d": {
5248 "commondir": "../../",
5249 "HEAD": "ref: refs/heads/feature-d",
5250 },
5251 },
5252 },
5253 "src": {},
5254 }),
5255 )
5256 .await;
5257 fs.insert_tree(
5258 "/wt-feature-d",
5259 serde_json::json!({
5260 ".git": "gitdir: /project/.git/worktrees/feature-d",
5261 "src": {},
5262 }),
5263 )
5264 .await;
5265 fs.add_linked_worktree_for_repo(
5266 Path::new("/project/.git"),
5267 false,
5268 git::repository::Worktree {
5269 path: PathBuf::from("/wt-feature-d"),
5270 ref_name: Some("refs/heads/feature-d".into()),
5271 sha: "original-sha".into(),
5272 is_main: false,
5273 is_bare: false,
5274 },
5275 )
5276 .await;
5277 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5278
5279 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5280 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
5281 main_project
5282 .update(cx, |p, cx| p.git_scans_complete(cx))
5283 .await;
5284 worktree_project
5285 .update(cx, |p, cx| p.git_scans_complete(cx))
5286 .await;
5287
5288 let (multi_workspace, _cx) =
5289 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5290 multi_workspace.update_in(_cx, |mw, window, cx| {
5291 mw.test_add_workspace(worktree_project.clone(), window, cx)
5292 });
5293
5294 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5295 project.repositories(cx).values().next().unwrap().clone()
5296 });
5297 let (staged_hash, unstaged_hash) = cx
5298 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5299 .await
5300 .unwrap()
5301 .unwrap();
5302
5303 // Remove the branch ref so change_branch will fail.
5304 fs.with_git_state(Path::new("/project/.git"), false, |state| {
5305 state.refs.remove("refs/heads/feature-d");
5306 })
5307 .unwrap();
5308
5309 let result = cx
5310 .spawn(|mut cx| async move {
5311 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5312 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5313 id: 1,
5314 worktree_path: PathBuf::from("/wt-feature-d"),
5315 main_repo_path: PathBuf::from("/project"),
5316 branch_name: Some("feature-d".to_string()),
5317 staged_commit_hash: staged_hash,
5318 unstaged_commit_hash: unstaged_hash,
5319 original_commit_hash: "original-sha".to_string(),
5320 },
5321 None,
5322 &mut cx,
5323 )
5324 .await
5325 })
5326 .await;
5327
5328 assert!(
5329 result.is_ok(),
5330 "restore should succeed when branch does not exist: {:?}",
5331 result.err()
5332 );
5333}
5334
5335#[gpui::test]
5336async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
5337 // Activating an archived linked worktree thread whose directory has
5338 // been deleted should reuse the existing main repo workspace, not
5339 // create a new one. The provisional ProjectGroupKey must be derived
5340 // from main_worktree_paths so that find_or_create_local_workspace
5341 // matches the main repo workspace when the worktree path is absent.
5342 init_test(cx);
5343 let fs = FakeFs::new(cx.executor());
5344
5345 fs.insert_tree(
5346 "/project",
5347 serde_json::json!({
5348 ".git": {
5349 "worktrees": {
5350 "feature-c": {
5351 "commondir": "../../",
5352 "HEAD": "ref: refs/heads/feature-c",
5353 },
5354 },
5355 },
5356 "src": {},
5357 }),
5358 )
5359 .await;
5360
5361 fs.insert_tree(
5362 "/wt-feature-c",
5363 serde_json::json!({
5364 ".git": "gitdir: /project/.git/worktrees/feature-c",
5365 "src": {},
5366 }),
5367 )
5368 .await;
5369
5370 fs.add_linked_worktree_for_repo(
5371 Path::new("/project/.git"),
5372 false,
5373 git::repository::Worktree {
5374 path: PathBuf::from("/wt-feature-c"),
5375 ref_name: Some("refs/heads/feature-c".into()),
5376 sha: "original-sha".into(),
5377 is_main: false,
5378 is_bare: false,
5379 },
5380 )
5381 .await;
5382
5383 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5384
5385 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5386 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
5387
5388 main_project
5389 .update(cx, |p, cx| p.git_scans_complete(cx))
5390 .await;
5391 worktree_project
5392 .update(cx, |p, cx| p.git_scans_complete(cx))
5393 .await;
5394
5395 let (multi_workspace, cx) =
5396 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5397 let sidebar = setup_sidebar(&multi_workspace, cx);
5398
5399 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5400 mw.test_add_workspace(worktree_project.clone(), window, cx)
5401 });
5402
5403 // Save thread metadata for the linked worktree.
5404 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
5405 save_thread_metadata(
5406 wt_session_id.clone(),
5407 Some("Worktree Thread C".into()),
5408 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5409 None,
5410 None,
5411 &worktree_project,
5412 cx,
5413 );
5414 cx.run_until_parked();
5415
5416 let thread_id = cx.update(|_window, cx| {
5417 ThreadMetadataStore::global(cx)
5418 .read(cx)
5419 .entry_by_session(&wt_session_id)
5420 .unwrap()
5421 .thread_id
5422 });
5423
5424 // Archive the thread without creating ArchivedGitWorktree records.
5425 let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
5426 cx.update(|_window, cx| {
5427 store.update(cx, |store, cx| store.archive(thread_id, None, cx));
5428 });
5429 cx.run_until_parked();
5430
5431 // Remove the worktree workspace and delete the worktree from disk.
5432 let main_workspace =
5433 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5434 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
5435 mw.remove(
5436 vec![worktree_workspace],
5437 move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
5438 window,
5439 cx,
5440 )
5441 });
5442 remove_task.await.ok();
5443 cx.run_until_parked();
5444 cx.run_until_parked();
5445 fs.remove_dir(
5446 Path::new("/wt-feature-c"),
5447 fs::RemoveOptions {
5448 recursive: true,
5449 ignore_if_not_exists: true,
5450 },
5451 )
5452 .await
5453 .unwrap();
5454
5455 let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5456 assert_eq!(
5457 workspace_count_before, 1,
5458 "should have only the main workspace"
5459 );
5460
5461 // Activate the archived thread. The worktree path is missing from
5462 // disk, so find_or_create_local_workspace falls back to the
5463 // provisional ProjectGroupKey to find a matching workspace.
5464 let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
5465 sidebar.update_in(cx, |sidebar, window, cx| {
5466 sidebar.open_thread_from_archive(metadata, window, cx);
5467 });
5468 cx.run_until_parked();
5469
5470 // The provisional key should use [/project] (the main repo),
5471 // which matches the existing main workspace. If it incorrectly
5472 // used [/wt-feature-c] (the linked worktree path), no workspace
5473 // would match and a spurious new one would be created.
5474 let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5475 assert_eq!(
5476 workspace_count_after, 1,
5477 "restoring a linked worktree thread should reuse the main repo workspace, \
5478 not create a new one (workspace count went from {workspace_count_before} to \
5479 {workspace_count_after})"
5480 );
5481}
5482
5483#[gpui::test]
5484async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
5485 cx: &mut TestAppContext,
5486) {
5487 // A remote thread at the same path as a local linked worktree thread
5488 // should not prevent the local workspace from being removed when the
5489 // local thread is archived (the last local thread for that worktree).
5490 init_test(cx);
5491 let fs = FakeFs::new(cx.executor());
5492
5493 fs.insert_tree(
5494 "/project",
5495 serde_json::json!({
5496 ".git": {
5497 "worktrees": {
5498 "feature-a": {
5499 "commondir": "../../",
5500 "HEAD": "ref: refs/heads/feature-a",
5501 },
5502 },
5503 },
5504 "src": {},
5505 }),
5506 )
5507 .await;
5508
5509 fs.insert_tree(
5510 "/wt-feature-a",
5511 serde_json::json!({
5512 ".git": "gitdir: /project/.git/worktrees/feature-a",
5513 "src": {},
5514 }),
5515 )
5516 .await;
5517
5518 fs.add_linked_worktree_for_repo(
5519 Path::new("/project/.git"),
5520 false,
5521 git::repository::Worktree {
5522 path: PathBuf::from("/wt-feature-a"),
5523 ref_name: Some("refs/heads/feature-a".into()),
5524 sha: "abc".into(),
5525 is_main: false,
5526 is_bare: false,
5527 },
5528 )
5529 .await;
5530
5531 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5532
5533 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5534 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5535
5536 main_project
5537 .update(cx, |p, cx| p.git_scans_complete(cx))
5538 .await;
5539 worktree_project
5540 .update(cx, |p, cx| p.git_scans_complete(cx))
5541 .await;
5542
5543 let (multi_workspace, cx) =
5544 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5545 let sidebar = setup_sidebar(&multi_workspace, cx);
5546
5547 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5548 mw.test_add_workspace(worktree_project.clone(), window, cx)
5549 });
5550
5551 // Save a thread for the main project.
5552 save_thread_metadata(
5553 acp::SessionId::new(Arc::from("main-thread")),
5554 Some("Main Thread".into()),
5555 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5556 None,
5557 None,
5558 &main_project,
5559 cx,
5560 );
5561
5562 // Save a local thread for the linked worktree.
5563 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
5564 save_thread_metadata(
5565 wt_thread_id.clone(),
5566 Some("Local Worktree Thread".into()),
5567 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5568 None,
5569 None,
5570 &worktree_project,
5571 cx,
5572 );
5573
5574 // Save a remote thread at the same /wt-feature-a path but on a
5575 // different host. This should NOT count as a remaining thread for
5576 // the local linked worktree workspace.
5577 let remote_host =
5578 remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
5579 cx.update(|_window, cx| {
5580 let metadata = ThreadMetadata {
5581 thread_id: ThreadId::new(),
5582 session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
5583 agent_id: agent::ZED_AGENT_ID.clone(),
5584 title: Some("Remote Worktree Thread".into()),
5585 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5586 created_at: None,
5587 interacted_at: None,
5588 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
5589 "/wt-feature-a",
5590 )])),
5591 archived: false,
5592 remote_connection: Some(remote_host),
5593 };
5594 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5595 store.save(metadata, cx);
5596 });
5597 });
5598 cx.run_until_parked();
5599
5600 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5601 cx.run_until_parked();
5602
5603 assert_eq!(
5604 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5605 2,
5606 "should start with 2 workspaces (main + linked worktree)"
5607 );
5608
5609 // The remote thread should NOT appear in the sidebar (it belongs
5610 // to a different host and no matching remote project group exists).
5611 let entries_before = visible_entries_as_strings(&sidebar, cx);
5612 assert!(
5613 !entries_before
5614 .iter()
5615 .any(|e| e.contains("Remote Worktree Thread")),
5616 "remote thread should not appear in local sidebar: {entries_before:?}"
5617 );
5618
5619 // Archive the local worktree thread.
5620 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5621 sidebar.archive_thread(&wt_thread_id, window, cx);
5622 });
5623
5624 cx.run_until_parked();
5625
5626 // The linked worktree workspace should be removed because the
5627 // only *local* thread for it was archived. The remote thread at
5628 // the same path should not have prevented removal.
5629 assert_eq!(
5630 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5631 1,
5632 "linked worktree workspace should be removed; the remote thread at the same path \
5633 should not count as a remaining local thread"
5634 );
5635
5636 let entries = visible_entries_as_strings(&sidebar, cx);
5637 assert!(
5638 entries.iter().any(|e| e.contains("Main Thread")),
5639 "main thread should still be visible: {entries:?}"
5640 );
5641 assert!(
5642 !entries.iter().any(|e| e.contains("Local Worktree Thread")),
5643 "archived local worktree thread should not be visible: {entries:?}"
5644 );
5645 assert!(
5646 !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
5647 "remote thread should still not appear in local sidebar: {entries:?}"
5648 );
5649}
5650
5651#[gpui::test]
5652async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
5653 // When a multi-root workspace (e.g. [/other, /project]) shares a
5654 // repo with a single-root workspace (e.g. [/project]), linked
5655 // worktree threads from the shared repo should only appear under
5656 // the dedicated group [project], not under [other, project].
5657 agent_ui::test_support::init_test(cx);
5658 cx.update(|cx| {
5659 ThreadStore::init_global(cx);
5660 ThreadMetadataStore::init_global(cx);
5661 language_model::LanguageModelRegistry::test(cx);
5662 prompt_store::init(cx);
5663 });
5664 let fs = FakeFs::new(cx.executor());
5665
5666 // Two independent repos, each with their own git history.
5667 fs.insert_tree(
5668 "/project",
5669 serde_json::json!({
5670 ".git": {},
5671 "src": {},
5672 }),
5673 )
5674 .await;
5675 fs.insert_tree(
5676 "/other",
5677 serde_json::json!({
5678 ".git": {},
5679 "src": {},
5680 }),
5681 )
5682 .await;
5683
5684 // Register the linked worktree in the main repo.
5685 fs.add_linked_worktree_for_repo(
5686 Path::new("/project/.git"),
5687 false,
5688 git::repository::Worktree {
5689 path: std::path::PathBuf::from("/wt-feature-a"),
5690 ref_name: Some("refs/heads/feature-a".into()),
5691 sha: "aaa".into(),
5692 is_main: false,
5693 is_bare: false,
5694 },
5695 )
5696 .await;
5697
5698 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5699
5700 // Workspace 1: just /project.
5701 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5702 project_only
5703 .update(cx, |p, cx| p.git_scans_complete(cx))
5704 .await;
5705
5706 // Workspace 2: /other and /project together (multi-root).
5707 let multi_root =
5708 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
5709 multi_root
5710 .update(cx, |p, cx| p.git_scans_complete(cx))
5711 .await;
5712
5713 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5714 worktree_project
5715 .update(cx, |p, cx| p.git_scans_complete(cx))
5716 .await;
5717
5718 // Save a thread under the linked worktree path BEFORE setting up
5719 // the sidebar and panels, so that reconciliation sees the [project]
5720 // group as non-empty and doesn't create a spurious draft there.
5721 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
5722 save_thread_metadata(
5723 wt_session_id,
5724 Some("Worktree Thread".into()),
5725 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5726 None,
5727 None,
5728 &worktree_project,
5729 cx,
5730 );
5731
5732 let (multi_workspace, cx) =
5733 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
5734 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5735 let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5736 mw.test_add_workspace(multi_root.clone(), window, cx)
5737 });
5738 add_agent_panel(&multi_root_workspace, cx);
5739 cx.run_until_parked();
5740
5741 // The thread should appear only under [project] (the dedicated
5742 // group for the /project repo), not under [other, project].
5743 assert_eq!(
5744 visible_entries_as_strings(&sidebar, cx),
5745 vec![
5746 //
5747 "v [other, project]",
5748 "v [project]",
5749 " Worktree Thread {wt-feature-a}",
5750 ]
5751 );
5752}
5753
5754fn thread_id_for(session_id: &acp::SessionId, cx: &mut TestAppContext) -> ThreadId {
5755 cx.read(|cx| {
5756 ThreadMetadataStore::global(cx)
5757 .read(cx)
5758 .entry_by_session(session_id)
5759 .map(|m| m.thread_id)
5760 .expect("thread metadata should exist")
5761 })
5762}
5763
5764#[gpui::test]
5765async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
5766 let project = init_test_project_with_agent_panel("/my-project", cx).await;
5767 let (multi_workspace, cx) =
5768 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5769 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5770
5771 let switcher_ids =
5772 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<ThreadId> {
5773 sidebar.read_with(cx, |sidebar, cx| {
5774 let switcher = sidebar
5775 .thread_switcher
5776 .as_ref()
5777 .expect("switcher should be open");
5778 switcher
5779 .read(cx)
5780 .entries()
5781 .iter()
5782 .map(|e| e.metadata.thread_id)
5783 .collect()
5784 })
5785 };
5786
5787 let switcher_selected_id =
5788 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> ThreadId {
5789 sidebar.read_with(cx, |sidebar, cx| {
5790 let switcher = sidebar
5791 .thread_switcher
5792 .as_ref()
5793 .expect("switcher should be open");
5794 let s = switcher.read(cx);
5795 s.selected_entry()
5796 .expect("should have selection")
5797 .metadata
5798 .thread_id
5799 })
5800 };
5801
5802 // ── Setup: create three threads with distinct created_at times ──────
5803 // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
5804 // We send messages in each so they also get last_message_sent_or_queued timestamps.
5805 let connection_c = StubAgentConnection::new();
5806 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5807 acp::ContentChunk::new("Done C".into()),
5808 )]);
5809 open_thread_with_connection(&panel, connection_c, cx);
5810 send_message(&panel, cx);
5811 let session_id_c = active_session_id(&panel, cx);
5812 let thread_id_c = active_thread_id(&panel, cx);
5813 save_thread_metadata(
5814 session_id_c.clone(),
5815 Some("Thread C".into()),
5816 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5817 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
5818 None,
5819 &project,
5820 cx,
5821 );
5822
5823 let connection_b = StubAgentConnection::new();
5824 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5825 acp::ContentChunk::new("Done B".into()),
5826 )]);
5827 open_thread_with_connection(&panel, connection_b, cx);
5828 send_message(&panel, cx);
5829 let session_id_b = active_session_id(&panel, cx);
5830 let thread_id_b = active_thread_id(&panel, cx);
5831 save_thread_metadata(
5832 session_id_b.clone(),
5833 Some("Thread B".into()),
5834 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5835 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
5836 None,
5837 &project,
5838 cx,
5839 );
5840
5841 let connection_a = StubAgentConnection::new();
5842 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5843 acp::ContentChunk::new("Done A".into()),
5844 )]);
5845 open_thread_with_connection(&panel, connection_a, cx);
5846 send_message(&panel, cx);
5847 let session_id_a = active_session_id(&panel, cx);
5848 let thread_id_a = active_thread_id(&panel, cx);
5849 save_thread_metadata(
5850 session_id_a.clone(),
5851 Some("Thread A".into()),
5852 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
5853 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
5854 None,
5855 &project,
5856 cx,
5857 );
5858
5859 // All three threads are now live. Thread A was opened last, so it's
5860 // the one being viewed. Opening each thread called record_thread_access,
5861 // so all three have last_accessed_at set.
5862 // Access order is: A (most recent), B, C (oldest).
5863
5864 // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
5865 focus_sidebar(&sidebar, cx);
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 // All three have last_accessed_at, so they sort by access time.
5872 // A was accessed most recently (it's the currently viewed thread),
5873 // then B, then C.
5874 assert_eq!(
5875 switcher_ids(&sidebar, cx),
5876 vec![thread_id_a, thread_id_b, thread_id_c,],
5877 );
5878 // First ctrl-tab selects the second entry (B).
5879 assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_b);
5880
5881 // Dismiss the switcher without confirming.
5882 sidebar.update_in(cx, |sidebar, _window, cx| {
5883 sidebar.dismiss_thread_switcher(cx);
5884 });
5885 cx.run_until_parked();
5886
5887 // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
5888 sidebar.update_in(cx, |sidebar, window, cx| {
5889 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5890 });
5891 cx.run_until_parked();
5892
5893 // Cycle twice to land on Thread C (index 2).
5894 sidebar.read_with(cx, |sidebar, cx| {
5895 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5896 assert_eq!(switcher.read(cx).selected_index(), 1);
5897 });
5898 sidebar.update_in(cx, |sidebar, _window, cx| {
5899 sidebar
5900 .thread_switcher
5901 .as_ref()
5902 .unwrap()
5903 .update(cx, |s, cx| s.cycle_selection(cx));
5904 });
5905 cx.run_until_parked();
5906 assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_c);
5907
5908 assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
5909
5910 // Confirm on Thread C.
5911 sidebar.update_in(cx, |sidebar, window, cx| {
5912 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5913 let focus = switcher.focus_handle(cx);
5914 focus.dispatch_action(&menu::Confirm, window, cx);
5915 });
5916 cx.run_until_parked();
5917
5918 // Switcher should be dismissed after confirm.
5919 sidebar.read_with(cx, |sidebar, _cx| {
5920 assert!(
5921 sidebar.thread_switcher.is_none(),
5922 "switcher should be dismissed"
5923 );
5924 });
5925
5926 sidebar.update(cx, |sidebar, _cx| {
5927 let last_accessed = sidebar
5928 .thread_last_accessed
5929 .keys()
5930 .cloned()
5931 .collect::<Vec<_>>();
5932 assert_eq!(last_accessed.len(), 1);
5933 assert!(last_accessed.contains(&thread_id_c));
5934 assert!(
5935 is_active_session(&sidebar, &session_id_c),
5936 "active_entry should be Thread({session_id_c:?})"
5937 );
5938 });
5939
5940 sidebar.update_in(cx, |sidebar, window, cx| {
5941 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5942 });
5943 cx.run_until_parked();
5944
5945 assert_eq!(
5946 switcher_ids(&sidebar, cx),
5947 vec![thread_id_c, thread_id_a, thread_id_b],
5948 );
5949
5950 // Confirm on Thread A.
5951 sidebar.update_in(cx, |sidebar, window, cx| {
5952 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5953 let focus = switcher.focus_handle(cx);
5954 focus.dispatch_action(&menu::Confirm, window, cx);
5955 });
5956 cx.run_until_parked();
5957
5958 sidebar.update(cx, |sidebar, _cx| {
5959 let last_accessed = sidebar
5960 .thread_last_accessed
5961 .keys()
5962 .cloned()
5963 .collect::<Vec<_>>();
5964 assert_eq!(last_accessed.len(), 2);
5965 assert!(last_accessed.contains(&thread_id_c));
5966 assert!(last_accessed.contains(&thread_id_a));
5967 assert!(
5968 is_active_session(&sidebar, &session_id_a),
5969 "active_entry should be Thread({session_id_a:?})"
5970 );
5971 });
5972
5973 sidebar.update_in(cx, |sidebar, window, cx| {
5974 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5975 });
5976 cx.run_until_parked();
5977
5978 assert_eq!(
5979 switcher_ids(&sidebar, cx),
5980 vec![thread_id_a, thread_id_c, thread_id_b,],
5981 );
5982
5983 sidebar.update_in(cx, |sidebar, _window, cx| {
5984 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5985 switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
5986 });
5987 cx.run_until_parked();
5988
5989 // Confirm on Thread B.
5990 sidebar.update_in(cx, |sidebar, window, cx| {
5991 let switcher = sidebar.thread_switcher.as_ref().unwrap();
5992 let focus = switcher.focus_handle(cx);
5993 focus.dispatch_action(&menu::Confirm, window, cx);
5994 });
5995 cx.run_until_parked();
5996
5997 sidebar.update(cx, |sidebar, _cx| {
5998 let last_accessed = sidebar
5999 .thread_last_accessed
6000 .keys()
6001 .cloned()
6002 .collect::<Vec<_>>();
6003 assert_eq!(last_accessed.len(), 3);
6004 assert!(last_accessed.contains(&thread_id_c));
6005 assert!(last_accessed.contains(&thread_id_a));
6006 assert!(last_accessed.contains(&thread_id_b));
6007 assert!(
6008 is_active_session(&sidebar, &session_id_b),
6009 "active_entry should be Thread({session_id_b:?})"
6010 );
6011 });
6012
6013 // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
6014 // This thread was never opened in a panel — it only exists in metadata.
6015 save_thread_metadata(
6016 acp::SessionId::new(Arc::from("thread-historical")),
6017 Some("Historical Thread".into()),
6018 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
6019 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
6020 None,
6021 &project,
6022 cx,
6023 );
6024
6025 sidebar.update_in(cx, |sidebar, window, cx| {
6026 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6027 });
6028 cx.run_until_parked();
6029
6030 // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
6031 // so it falls to tier 3 (sorted by created_at). It should appear after all
6032 // accessed threads, even though its created_at (June 2024) is much later
6033 // than the others.
6034 //
6035 // But the live threads (A, B, C) each had send_message called which sets
6036 // last_message_sent_or_queued. So for the accessed threads (tier 1) the
6037 // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
6038 let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
6039 let thread_id_hist = thread_id_for(&session_id_hist, cx);
6040
6041 let ids = switcher_ids(&sidebar, cx);
6042 assert_eq!(
6043 ids,
6044 vec![thread_id_b, thread_id_a, thread_id_c, thread_id_hist],
6045 );
6046
6047 sidebar.update_in(cx, |sidebar, _window, cx| {
6048 sidebar.dismiss_thread_switcher(cx);
6049 });
6050 cx.run_until_parked();
6051
6052 // ── 4. Add another historical thread with older created_at ─────────
6053 save_thread_metadata(
6054 acp::SessionId::new(Arc::from("thread-old-historical")),
6055 Some("Old Historical Thread".into()),
6056 chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
6057 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
6058 None,
6059 &project,
6060 cx,
6061 );
6062
6063 sidebar.update_in(cx, |sidebar, window, cx| {
6064 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6065 });
6066 cx.run_until_parked();
6067
6068 // Both historical threads have no access or message times. They should
6069 // appear after accessed threads, sorted by created_at (newest first).
6070 let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
6071 let thread_id_old_hist = thread_id_for(&session_id_old_hist, cx);
6072 let ids = switcher_ids(&sidebar, cx);
6073 assert_eq!(
6074 ids,
6075 vec![
6076 thread_id_b,
6077 thread_id_a,
6078 thread_id_c,
6079 thread_id_hist,
6080 thread_id_old_hist,
6081 ],
6082 );
6083
6084 sidebar.update_in(cx, |sidebar, _window, cx| {
6085 sidebar.dismiss_thread_switcher(cx);
6086 });
6087 cx.run_until_parked();
6088}
6089
6090#[gpui::test]
6091async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
6092 let project = init_test_project("/my-project", cx).await;
6093 let (multi_workspace, cx) =
6094 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6095 let sidebar = setup_sidebar(&multi_workspace, cx);
6096
6097 save_thread_metadata(
6098 acp::SessionId::new(Arc::from("thread-to-archive")),
6099 Some("Thread To Archive".into()),
6100 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6101 None,
6102 None,
6103 &project,
6104 cx,
6105 );
6106 cx.run_until_parked();
6107
6108 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6109 cx.run_until_parked();
6110
6111 let entries = visible_entries_as_strings(&sidebar, cx);
6112 assert!(
6113 entries.iter().any(|e| e.contains("Thread To Archive")),
6114 "expected thread to be visible before archiving, got: {entries:?}"
6115 );
6116
6117 sidebar.update_in(cx, |sidebar, window, cx| {
6118 sidebar.archive_thread(
6119 &acp::SessionId::new(Arc::from("thread-to-archive")),
6120 window,
6121 cx,
6122 );
6123 });
6124 cx.run_until_parked();
6125
6126 let entries = visible_entries_as_strings(&sidebar, cx);
6127 assert!(
6128 !entries.iter().any(|e| e.contains("Thread To Archive")),
6129 "expected thread to be hidden after archiving, got: {entries:?}"
6130 );
6131
6132 cx.update(|_, cx| {
6133 let store = ThreadMetadataStore::global(cx);
6134 let archived: Vec<_> = store.read(cx).archived_entries().collect();
6135 assert_eq!(archived.len(), 1);
6136 assert_eq!(
6137 archived[0].session_id.as_ref().unwrap().0.as_ref(),
6138 "thread-to-archive"
6139 );
6140 assert!(archived[0].archived);
6141 });
6142}
6143
6144#[gpui::test]
6145async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
6146 // Tests two archive scenarios:
6147 // 1. Archiving a thread in a non-active workspace leaves active_entry
6148 // as the current draft.
6149 // 2. Archiving the thread the user is looking at falls back to a draft
6150 // on the same workspace.
6151 agent_ui::test_support::init_test(cx);
6152 cx.update(|cx| {
6153 ThreadStore::init_global(cx);
6154 ThreadMetadataStore::init_global(cx);
6155 language_model::LanguageModelRegistry::test(cx);
6156 prompt_store::init(cx);
6157 });
6158
6159 let fs = FakeFs::new(cx.executor());
6160 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6161 .await;
6162 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6163 .await;
6164 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6165
6166 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6167 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6168
6169 let (multi_workspace, cx) =
6170 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6171 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6172
6173 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6174 mw.test_add_workspace(project_b.clone(), window, cx)
6175 });
6176 let panel_b = add_agent_panel(&workspace_b, cx);
6177 cx.run_until_parked();
6178
6179 // Explicitly create a draft on workspace_b so the sidebar tracks one.
6180 sidebar.update_in(cx, |sidebar, window, cx| {
6181 sidebar.create_new_thread(&workspace_b, window, cx);
6182 });
6183 cx.run_until_parked();
6184
6185 // --- Scenario 1: archive a thread in the non-active workspace ---
6186
6187 // Create a thread in project-a (non-active — project-b is active).
6188 let connection = acp_thread::StubAgentConnection::new();
6189 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6190 acp::ContentChunk::new("Done".into()),
6191 )]);
6192 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
6193 agent_ui::test_support::send_message(&panel_a, cx);
6194 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
6195 cx.run_until_parked();
6196
6197 sidebar.update_in(cx, |sidebar, window, cx| {
6198 sidebar.archive_thread(&thread_a, window, cx);
6199 });
6200 cx.run_until_parked();
6201
6202 // active_entry should still be a draft on workspace_b (the active one).
6203 sidebar.read_with(cx, |sidebar, _| {
6204 assert!(
6205 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
6206 "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
6207 sidebar.active_entry,
6208 );
6209 });
6210
6211 // --- Scenario 2: archive the thread the user is looking at ---
6212
6213 // Create a thread in project-b (the active workspace) and verify it
6214 // becomes the active entry.
6215 let connection = acp_thread::StubAgentConnection::new();
6216 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6217 acp::ContentChunk::new("Done".into()),
6218 )]);
6219 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6220 agent_ui::test_support::send_message(&panel_b, cx);
6221 let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
6222 cx.run_until_parked();
6223
6224 sidebar.read_with(cx, |sidebar, _| {
6225 assert!(
6226 is_active_session(&sidebar, &thread_b),
6227 "expected active_entry to be Thread({thread_b}), got: {:?}",
6228 sidebar.active_entry,
6229 );
6230 });
6231
6232 sidebar.update_in(cx, |sidebar, window, cx| {
6233 sidebar.archive_thread(&thread_b, window, cx);
6234 });
6235 cx.run_until_parked();
6236
6237 // Archiving the active thread activates a draft on the same workspace
6238 // (via clear_base_view → activate_draft). The draft is not shown as a
6239 // sidebar row but active_entry tracks it.
6240 sidebar.read_with(cx, |sidebar, _| {
6241 assert!(
6242 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
6243 "expected draft on workspace_b after archiving active thread, got: {:?}",
6244 sidebar.active_entry,
6245 );
6246 });
6247}
6248
6249#[gpui::test]
6250async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
6251 // Full flow: create a thread, archive it (removing the workspace),
6252 // then unarchive. Only the restored thread should appear — no
6253 // leftover drafts or previously-serialized threads.
6254 let project = init_test_project_with_agent_panel("/my-project", cx).await;
6255 let (multi_workspace, cx) =
6256 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6257 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6258 cx.run_until_parked();
6259
6260 // Create a thread and send a message so it's a real thread.
6261 let connection = acp_thread::StubAgentConnection::new();
6262 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6263 acp::ContentChunk::new("Hello".into()),
6264 )]);
6265 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6266 agent_ui::test_support::send_message(&panel, cx);
6267 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6268 cx.run_until_parked();
6269
6270 // Archive it.
6271 sidebar.update_in(cx, |sidebar, window, cx| {
6272 sidebar.archive_thread(&session_id, window, cx);
6273 });
6274 cx.run_until_parked();
6275
6276 // Grab metadata for unarchive.
6277 let thread_id = cx.update(|_, cx| {
6278 ThreadMetadataStore::global(cx)
6279 .read(cx)
6280 .entries()
6281 .find(|e| e.session_id.as_ref() == Some(&session_id))
6282 .map(|e| e.thread_id)
6283 .expect("thread should exist")
6284 });
6285 let metadata = cx.update(|_, cx| {
6286 ThreadMetadataStore::global(cx)
6287 .read(cx)
6288 .entry(thread_id)
6289 .cloned()
6290 .expect("metadata should exist")
6291 });
6292
6293 // Unarchive it — the draft should be replaced by the restored thread.
6294 sidebar.update_in(cx, |sidebar, window, cx| {
6295 sidebar.open_thread_from_archive(metadata, window, cx);
6296 });
6297 cx.run_until_parked();
6298
6299 // Only the unarchived thread should be visible — no drafts, no other threads.
6300 let entries = visible_entries_as_strings(&sidebar, cx);
6301 let thread_count = entries
6302 .iter()
6303 .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
6304 .count();
6305 assert_eq!(
6306 thread_count, 1,
6307 "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
6308 );
6309 assert!(
6310 !entries.iter().any(|e| e.contains("Draft")),
6311 "expected no drafts after restoring, got entries: {entries:?}"
6312 );
6313}
6314
6315#[gpui::test]
6316async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
6317 cx: &mut TestAppContext,
6318) {
6319 // When a thread is unarchived into a project group that has no open
6320 // workspace, the sidebar opens a new workspace and loads the thread.
6321 // No spurious draft should appear alongside the unarchived thread.
6322 agent_ui::test_support::init_test(cx);
6323 cx.update(|cx| {
6324 ThreadStore::init_global(cx);
6325 ThreadMetadataStore::init_global(cx);
6326 language_model::LanguageModelRegistry::test(cx);
6327 prompt_store::init(cx);
6328 });
6329
6330 let fs = FakeFs::new(cx.executor());
6331 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6332 .await;
6333 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6334 .await;
6335 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6336
6337 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6338 let (multi_workspace, cx) =
6339 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6340 let sidebar = setup_sidebar(&multi_workspace, cx);
6341 cx.run_until_parked();
6342
6343 // Save an archived thread whose folder_paths point to project-b,
6344 // which has no open workspace.
6345 let session_id = acp::SessionId::new(Arc::from("archived-thread"));
6346 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6347 let thread_id = ThreadId::new();
6348 cx.update(|_, cx| {
6349 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6350 store.save(
6351 ThreadMetadata {
6352 thread_id,
6353 session_id: Some(session_id.clone()),
6354 agent_id: agent::ZED_AGENT_ID.clone(),
6355 title: Some("Unarchived Thread".into()),
6356 updated_at: Utc::now(),
6357 created_at: None,
6358 interacted_at: None,
6359 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6360 archived: true,
6361 remote_connection: None,
6362 },
6363 cx,
6364 )
6365 });
6366 });
6367 cx.run_until_parked();
6368
6369 // Verify no workspace for project-b exists yet.
6370 assert_eq!(
6371 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6372 1,
6373 "should start with only the project-a workspace"
6374 );
6375
6376 // Un-archive the thread — should open project-b workspace and load it.
6377 let metadata = cx.update(|_, cx| {
6378 ThreadMetadataStore::global(cx)
6379 .read(cx)
6380 .entry(thread_id)
6381 .cloned()
6382 .expect("metadata should exist")
6383 });
6384
6385 sidebar.update_in(cx, |sidebar, window, cx| {
6386 sidebar.open_thread_from_archive(metadata, window, cx);
6387 });
6388 cx.run_until_parked();
6389
6390 // A second workspace should have been created for project-b.
6391 assert_eq!(
6392 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6393 2,
6394 "should have opened a workspace for the unarchived thread"
6395 );
6396
6397 // The sidebar should show the unarchived thread without a spurious draft
6398 // in the project-b group.
6399 let entries = visible_entries_as_strings(&sidebar, cx);
6400 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6401 // project-a gets a draft (it's the active workspace with no threads),
6402 // but project-b should NOT have one — only the unarchived thread.
6403 assert!(
6404 draft_count <= 1,
6405 "expected at most one draft (for project-a), got entries: {entries:?}"
6406 );
6407 assert!(
6408 entries.iter().any(|e| e.contains("Unarchived Thread")),
6409 "expected unarchived thread to appear, got entries: {entries:?}"
6410 );
6411}
6412
6413#[gpui::test]
6414async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
6415 cx: &mut TestAppContext,
6416) {
6417 agent_ui::test_support::init_test(cx);
6418 cx.update(|cx| {
6419 ThreadStore::init_global(cx);
6420 ThreadMetadataStore::init_global(cx);
6421 language_model::LanguageModelRegistry::test(cx);
6422 prompt_store::init(cx);
6423 });
6424
6425 let fs = FakeFs::new(cx.executor());
6426 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6427 .await;
6428 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6429 .await;
6430 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6431
6432 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6433 let (multi_workspace, cx) =
6434 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6435 let sidebar = setup_sidebar(&multi_workspace, cx);
6436 cx.run_until_parked();
6437
6438 let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
6439 let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
6440 let original_thread_id = ThreadId::new();
6441 cx.update(|_, cx| {
6442 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6443 store.save(
6444 ThreadMetadata {
6445 thread_id: original_thread_id,
6446 session_id: Some(session_id.clone()),
6447 agent_id: agent::ZED_AGENT_ID.clone(),
6448 title: Some("Unarchived Thread".into()),
6449 updated_at: Utc::now(),
6450 created_at: None,
6451 interacted_at: None,
6452 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6453 archived: true,
6454 remote_connection: None,
6455 },
6456 cx,
6457 )
6458 });
6459 });
6460 cx.run_until_parked();
6461
6462 let metadata = cx.update(|_, cx| {
6463 ThreadMetadataStore::global(cx)
6464 .read(cx)
6465 .entry(original_thread_id)
6466 .cloned()
6467 .expect("metadata should exist before unarchive")
6468 });
6469
6470 sidebar.update_in(cx, |sidebar, window, cx| {
6471 sidebar.open_thread_from_archive(metadata, window, cx);
6472 });
6473
6474 cx.run_until_parked();
6475
6476 assert_eq!(
6477 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6478 2,
6479 "expected unarchive to open the target workspace"
6480 );
6481
6482 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6483 mw.workspaces()
6484 .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
6485 .cloned()
6486 .expect("expected restored workspace for unarchived thread")
6487 });
6488 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6489 workspace
6490 .panel::<AgentPanel>(cx)
6491 .expect("expected unarchive to install an agent panel in the new workspace")
6492 });
6493
6494 let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6495 assert_eq!(
6496 restored_thread_id,
6497 Some(original_thread_id),
6498 "expected the new workspace's agent panel to target the restored archived thread id"
6499 );
6500
6501 let session_entries = cx.update(|_, cx| {
6502 ThreadMetadataStore::global(cx)
6503 .read(cx)
6504 .entries()
6505 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6506 .cloned()
6507 .collect::<Vec<_>>()
6508 });
6509 assert_eq!(
6510 session_entries.len(),
6511 1,
6512 "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
6513 );
6514 assert_eq!(
6515 session_entries[0].thread_id, original_thread_id,
6516 "expected restore into a new workspace to reuse the original thread id"
6517 );
6518 assert!(
6519 !session_entries[0].archived,
6520 "expected restored thread metadata to be unarchived, got: {:?}",
6521 session_entries[0]
6522 );
6523
6524 let mapped_thread_id = cx.update(|_, cx| {
6525 ThreadMetadataStore::global(cx)
6526 .read(cx)
6527 .entries()
6528 .find(|e| e.session_id.as_ref() == Some(&session_id))
6529 .map(|e| e.thread_id)
6530 });
6531 assert_eq!(
6532 mapped_thread_id,
6533 Some(original_thread_id),
6534 "expected session mapping to remain stable after opening the new workspace"
6535 );
6536
6537 let entries = visible_entries_as_strings(&sidebar, cx);
6538 let real_thread_rows = entries
6539 .iter()
6540 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6541 .filter(|entry| !entry.contains("Draft"))
6542 .count();
6543 assert_eq!(
6544 real_thread_rows, 1,
6545 "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
6546 );
6547 assert!(
6548 entries
6549 .iter()
6550 .any(|entry| entry.contains("Unarchived Thread")),
6551 "expected restored thread row to be visible, got entries: {entries:?}"
6552 );
6553}
6554
6555#[gpui::test]
6556async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
6557 // When a workspace already exists with an empty draft and a thread
6558 // is unarchived into it, the draft should be replaced — not kept
6559 // alongside the loaded thread.
6560 agent_ui::test_support::init_test(cx);
6561 cx.update(|cx| {
6562 ThreadStore::init_global(cx);
6563 ThreadMetadataStore::init_global(cx);
6564 language_model::LanguageModelRegistry::test(cx);
6565 prompt_store::init(cx);
6566 });
6567
6568 let fs = FakeFs::new(cx.executor());
6569 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6570 .await;
6571 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6572
6573 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6574 let (multi_workspace, cx) =
6575 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6576 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6577 cx.run_until_parked();
6578
6579 // Create a thread and send a message so it's no longer a draft.
6580 let connection = acp_thread::StubAgentConnection::new();
6581 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6582 acp::ContentChunk::new("Done".into()),
6583 )]);
6584 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6585 agent_ui::test_support::send_message(&panel, cx);
6586 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6587 cx.run_until_parked();
6588
6589 // Archive the thread — the group is left empty (no draft created).
6590 sidebar.update_in(cx, |sidebar, window, cx| {
6591 sidebar.archive_thread(&session_id, window, cx);
6592 });
6593 cx.run_until_parked();
6594
6595 // Un-archive the thread.
6596 let thread_id = cx.update(|_, cx| {
6597 ThreadMetadataStore::global(cx)
6598 .read(cx)
6599 .entries()
6600 .find(|e| e.session_id.as_ref() == Some(&session_id))
6601 .map(|e| e.thread_id)
6602 .expect("thread should exist in store")
6603 });
6604 let metadata = cx.update(|_, cx| {
6605 ThreadMetadataStore::global(cx)
6606 .read(cx)
6607 .entry(thread_id)
6608 .cloned()
6609 .expect("metadata should exist")
6610 });
6611
6612 sidebar.update_in(cx, |sidebar, window, cx| {
6613 sidebar.open_thread_from_archive(metadata, window, cx);
6614 });
6615 cx.run_until_parked();
6616
6617 // The draft should be gone — only the unarchived thread remains.
6618 let entries = visible_entries_as_strings(&sidebar, cx);
6619 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6620 assert_eq!(
6621 draft_count, 0,
6622 "expected no drafts after unarchiving, got entries: {entries:?}"
6623 );
6624}
6625
6626#[gpui::test]
6627async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
6628 cx: &mut TestAppContext,
6629) {
6630 agent_ui::test_support::init_test(cx);
6631 cx.update(|cx| {
6632 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6633 ThreadStore::init_global(cx);
6634 ThreadMetadataStore::init_global(cx);
6635 language_model::LanguageModelRegistry::test(cx);
6636 prompt_store::init(cx);
6637 });
6638
6639 let fs = FakeFs::new(cx.executor());
6640 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6641 .await;
6642 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6643 .await;
6644 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6645
6646 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6647 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6648
6649 let (multi_workspace, cx) =
6650 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6651 let sidebar = setup_sidebar(&multi_workspace, cx);
6652
6653 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6654 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6655 mw.test_add_workspace(project_b.clone(), window, cx)
6656 });
6657 let _panel_b = add_agent_panel(&workspace_b, cx);
6658 cx.run_until_parked();
6659
6660 multi_workspace.update_in(cx, |mw, window, cx| {
6661 mw.activate(workspace_a.clone(), None, window, cx);
6662 });
6663 cx.run_until_parked();
6664
6665 let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
6666 let thread_id = ThreadId::new();
6667 cx.update(|_, cx| {
6668 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6669 store.save(
6670 ThreadMetadata {
6671 thread_id,
6672 session_id: Some(session_id.clone()),
6673 agent_id: agent::ZED_AGENT_ID.clone(),
6674 title: Some("Restored In Inactive Workspace".into()),
6675 updated_at: Utc::now(),
6676 created_at: None,
6677 interacted_at: None,
6678 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
6679 PathBuf::from("/project-b"),
6680 ])),
6681 archived: true,
6682 remote_connection: None,
6683 },
6684 cx,
6685 )
6686 });
6687 });
6688 cx.run_until_parked();
6689
6690 let metadata = cx.update(|_, cx| {
6691 ThreadMetadataStore::global(cx)
6692 .read(cx)
6693 .entry(thread_id)
6694 .cloned()
6695 .expect("archived metadata should exist before restore")
6696 });
6697
6698 sidebar.update_in(cx, |sidebar, window, cx| {
6699 sidebar.open_thread_from_archive(metadata, window, cx);
6700 });
6701
6702 let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
6703 workspace.panel::<AgentPanel>(cx).expect(
6704 "target workspace should still have an agent panel immediately after activation",
6705 )
6706 });
6707 let immediate_active_thread_id =
6708 panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6709
6710 cx.run_until_parked();
6711
6712 sidebar.read_with(cx, |sidebar, _cx| {
6713 assert_active_thread(
6714 sidebar,
6715 &session_id,
6716 "unarchiving into an inactive existing workspace should end on the restored thread",
6717 );
6718 });
6719
6720 let panel_b = workspace_b.read_with(cx, |workspace, cx| {
6721 workspace
6722 .panel::<AgentPanel>(cx)
6723 .expect("target workspace should still have an agent panel")
6724 });
6725 assert_eq!(
6726 panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6727 Some(thread_id),
6728 "expected target panel to activate the restored thread id"
6729 );
6730 assert!(
6731 immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
6732 "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
6733 );
6734
6735 let entries = visible_entries_as_strings(&sidebar, cx);
6736 let target_rows: Vec<_> = entries
6737 .iter()
6738 .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
6739 .cloned()
6740 .collect();
6741 assert_eq!(
6742 target_rows.len(),
6743 1,
6744 "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
6745 );
6746 assert!(
6747 target_rows[0].contains("Restored In Inactive Workspace"),
6748 "expected the remaining row to be the restored thread, got entries: {entries:?}"
6749 );
6750 assert!(
6751 !target_rows[0].contains("Draft"),
6752 "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
6753 );
6754}
6755
6756#[gpui::test]
6757async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
6758 cx: &mut TestAppContext,
6759) {
6760 agent_ui::test_support::init_test(cx);
6761 cx.update(|cx| {
6762 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6763 ThreadStore::init_global(cx);
6764 ThreadMetadataStore::init_global(cx);
6765 language_model::LanguageModelRegistry::test(cx);
6766 prompt_store::init(cx);
6767 });
6768
6769 let fs = FakeFs::new(cx.executor());
6770 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6771 .await;
6772 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6773 .await;
6774 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6775
6776 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6777 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6778
6779 let (multi_workspace, cx) =
6780 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6781 let sidebar = setup_sidebar(&multi_workspace, cx);
6782
6783 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6784 mw.test_add_workspace(project_b.clone(), window, cx)
6785 });
6786 let panel_b = add_agent_panel(&workspace_b, cx);
6787 cx.run_until_parked();
6788
6789 let connection = acp_thread::StubAgentConnection::new();
6790 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6791 acp::ContentChunk::new("Done".into()),
6792 )]);
6793 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6794 agent_ui::test_support::send_message(&panel_b, cx);
6795 let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
6796 save_test_thread_metadata(&session_id, &project_b, cx).await;
6797 cx.run_until_parked();
6798
6799 sidebar.update_in(cx, |sidebar, window, cx| {
6800 sidebar.archive_thread(&session_id, window, cx);
6801 });
6802
6803 cx.run_until_parked();
6804
6805 let archived_metadata = cx.update(|_, cx| {
6806 let store = ThreadMetadataStore::global(cx).read(cx);
6807 let thread_id = store
6808 .entries()
6809 .find(|e| e.session_id.as_ref() == Some(&session_id))
6810 .map(|e| e.thread_id)
6811 .expect("archived thread should still exist in metadata store");
6812 let metadata = store
6813 .entry(thread_id)
6814 .cloned()
6815 .expect("archived metadata should still exist after archive");
6816 assert!(
6817 metadata.archived,
6818 "thread should be archived before project removal"
6819 );
6820 metadata
6821 });
6822
6823 let group_key_b =
6824 project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
6825 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
6826 mw.remove_project_group(&group_key_b, window, cx)
6827 });
6828 remove_task
6829 .await
6830 .expect("remove project group task should complete");
6831 cx.run_until_parked();
6832
6833 assert_eq!(
6834 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6835 1,
6836 "removing the archived thread's parent project group should remove its workspace"
6837 );
6838
6839 sidebar.update_in(cx, |sidebar, window, cx| {
6840 sidebar.open_thread_from_archive(archived_metadata.clone(), window, cx);
6841 });
6842 cx.run_until_parked();
6843
6844 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6845 mw.workspaces()
6846 .find(|workspace| {
6847 PathList::new(&workspace.read(cx).root_paths(cx))
6848 == PathList::new(&[PathBuf::from("/project-b")])
6849 })
6850 .cloned()
6851 .expect("expected unarchive to recreate the removed project workspace")
6852 });
6853 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6854 workspace
6855 .panel::<AgentPanel>(cx)
6856 .expect("expected restored workspace to bootstrap an agent panel")
6857 });
6858
6859 let restored_thread_id = cx.update(|_, cx| {
6860 ThreadMetadataStore::global(cx)
6861 .read(cx)
6862 .entries()
6863 .find(|e| e.session_id.as_ref() == Some(&session_id))
6864 .map(|e| e.thread_id)
6865 .expect("session should still map to restored thread id")
6866 });
6867 assert_eq!(
6868 restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6869 Some(restored_thread_id),
6870 "expected unarchive after project removal to activate the restored real thread"
6871 );
6872
6873 sidebar.read_with(cx, |sidebar, _cx| {
6874 assert_active_thread(
6875 sidebar,
6876 &session_id,
6877 "expected sidebar active entry to track the restored thread after project removal",
6878 );
6879 });
6880
6881 let entries = visible_entries_as_strings(&sidebar, cx);
6882 let restored_title = archived_metadata.display_title().to_string();
6883 let matching_rows: Vec<_> = entries
6884 .iter()
6885 .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
6886 .cloned()
6887 .collect();
6888 assert_eq!(
6889 matching_rows.len(),
6890 1,
6891 "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
6892 );
6893 assert!(
6894 !matching_rows[0].contains("Draft"),
6895 "expected no draft row after unarchive following project removal, got entries: {entries:?}"
6896 );
6897}
6898
6899#[gpui::test]
6900async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
6901 agent_ui::test_support::init_test(cx);
6902 cx.update(|cx| {
6903 ThreadStore::init_global(cx);
6904 ThreadMetadataStore::init_global(cx);
6905 language_model::LanguageModelRegistry::test(cx);
6906 prompt_store::init(cx);
6907 });
6908
6909 let fs = FakeFs::new(cx.executor());
6910 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6911 .await;
6912 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6913
6914 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6915 let (multi_workspace, cx) =
6916 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6917 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6918 cx.run_until_parked();
6919
6920 let connection = acp_thread::StubAgentConnection::new();
6921 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6922 acp::ContentChunk::new("Done".into()),
6923 )]);
6924 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6925 agent_ui::test_support::send_message(&panel, cx);
6926 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6927 cx.run_until_parked();
6928
6929 let original_thread_id = cx.update(|_, cx| {
6930 ThreadMetadataStore::global(cx)
6931 .read(cx)
6932 .entries()
6933 .find(|e| e.session_id.as_ref() == Some(&session_id))
6934 .map(|e| e.thread_id)
6935 .expect("thread should exist in store before archiving")
6936 });
6937
6938 sidebar.update_in(cx, |sidebar, window, cx| {
6939 sidebar.archive_thread(&session_id, window, cx);
6940 });
6941 cx.run_until_parked();
6942
6943 let metadata = cx.update(|_, cx| {
6944 ThreadMetadataStore::global(cx)
6945 .read(cx)
6946 .entry(original_thread_id)
6947 .cloned()
6948 .expect("metadata should exist after archiving")
6949 });
6950
6951 sidebar.update_in(cx, |sidebar, window, cx| {
6952 sidebar.open_thread_from_archive(metadata, window, cx);
6953 });
6954 cx.run_until_parked();
6955
6956 let session_entries = cx.update(|_, cx| {
6957 ThreadMetadataStore::global(cx)
6958 .read(cx)
6959 .entries()
6960 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6961 .cloned()
6962 .collect::<Vec<_>>()
6963 });
6964
6965 assert_eq!(
6966 session_entries.len(),
6967 1,
6968 "expected exactly one metadata row for the restored session, got: {session_entries:?}"
6969 );
6970 assert_eq!(
6971 session_entries[0].thread_id, original_thread_id,
6972 "expected unarchive to reuse the original thread id instead of creating a duplicate row"
6973 );
6974 assert!(
6975 session_entries[0].session_id.is_some(),
6976 "expected restored metadata to be a real thread, got: {:?}",
6977 session_entries[0]
6978 );
6979
6980 let entries = visible_entries_as_strings(&sidebar, cx);
6981 let real_thread_rows = entries
6982 .iter()
6983 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6984 .filter(|entry| !entry.contains("Draft"))
6985 .count();
6986 assert_eq!(
6987 real_thread_rows, 1,
6988 "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
6989 );
6990 assert!(
6991 !entries.iter().any(|entry| entry.contains("Draft")),
6992 "expected no draft rows after restoring, got entries: {entries:?}"
6993 );
6994}
6995
6996#[gpui::test]
6997async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
6998 cx: &mut TestAppContext,
6999) {
7000 // When a thread is archived while the user is in a different workspace,
7001 // clear_base_view creates a draft on the archived workspace's panel.
7002 // Switching back to that workspace shows the draft as active_entry.
7003 agent_ui::test_support::init_test(cx);
7004 cx.update(|cx| {
7005 ThreadStore::init_global(cx);
7006 ThreadMetadataStore::init_global(cx);
7007 language_model::LanguageModelRegistry::test(cx);
7008 prompt_store::init(cx);
7009 });
7010
7011 let fs = FakeFs::new(cx.executor());
7012 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
7013 .await;
7014 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
7015 .await;
7016 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7017
7018 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
7019 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
7020
7021 let (multi_workspace, cx) =
7022 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
7023 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
7024
7025 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
7026 mw.test_add_workspace(project_b.clone(), window, cx)
7027 });
7028 let _panel_b = add_agent_panel(&workspace_b, cx);
7029 cx.run_until_parked();
7030
7031 // Create a thread in project-a's panel (currently non-active).
7032 let connection = acp_thread::StubAgentConnection::new();
7033 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7034 acp::ContentChunk::new("Done".into()),
7035 )]);
7036 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
7037 agent_ui::test_support::send_message(&panel_a, cx);
7038 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
7039 cx.run_until_parked();
7040
7041 // Archive it while project-b is active.
7042 sidebar.update_in(cx, |sidebar, window, cx| {
7043 sidebar.archive_thread(&thread_a, window, cx);
7044 });
7045 cx.run_until_parked();
7046
7047 // Switch back to project-a. Its panel was cleared during archiving
7048 // (clear_base_view activated a draft), so active_entry should point
7049 // to the draft on workspace_a.
7050 let workspace_a =
7051 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7052 multi_workspace.update_in(cx, |mw, window, cx| {
7053 mw.activate(workspace_a.clone(), None, window, cx);
7054 });
7055 cx.run_until_parked();
7056
7057 sidebar.update_in(cx, |sidebar, _window, cx| {
7058 sidebar.update_entries(cx);
7059 });
7060 cx.run_until_parked();
7061
7062 sidebar.read_with(cx, |sidebar, _| {
7063 assert_active_draft(
7064 sidebar,
7065 &workspace_a,
7066 "after switching to workspace with archived thread, active_entry should be the draft",
7067 );
7068 });
7069}
7070
7071#[gpui::test]
7072async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
7073 let project = init_test_project("/my-project", cx).await;
7074 let (multi_workspace, cx) =
7075 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7076 let sidebar = setup_sidebar(&multi_workspace, cx);
7077
7078 save_thread_metadata(
7079 acp::SessionId::new(Arc::from("visible-thread")),
7080 Some("Visible Thread".into()),
7081 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7082 None,
7083 None,
7084 &project,
7085 cx,
7086 );
7087
7088 let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
7089 save_thread_metadata(
7090 archived_thread_session_id.clone(),
7091 Some("Archived Thread".into()),
7092 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7093 None,
7094 None,
7095 &project,
7096 cx,
7097 );
7098
7099 cx.update(|_, cx| {
7100 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7101 let thread_id = store
7102 .entries()
7103 .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
7104 .map(|e| e.thread_id)
7105 .unwrap();
7106 store.archive(thread_id, None, cx)
7107 })
7108 });
7109 cx.run_until_parked();
7110
7111 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7112 cx.run_until_parked();
7113
7114 let entries = visible_entries_as_strings(&sidebar, cx);
7115 assert!(
7116 entries.iter().any(|e| e.contains("Visible Thread")),
7117 "expected visible thread in sidebar, got: {entries:?}"
7118 );
7119 assert!(
7120 !entries.iter().any(|e| e.contains("Archived Thread")),
7121 "expected archived thread to be hidden from sidebar, got: {entries:?}"
7122 );
7123
7124 cx.update(|_, cx| {
7125 let store = ThreadMetadataStore::global(cx);
7126 let all: Vec<_> = store.read(cx).entries().collect();
7127 assert_eq!(
7128 all.len(),
7129 2,
7130 "expected 2 total entries in the store, got: {}",
7131 all.len()
7132 );
7133
7134 let archived: Vec<_> = store.read(cx).archived_entries().collect();
7135 assert_eq!(archived.len(), 1);
7136 assert_eq!(
7137 archived[0].session_id.as_ref().unwrap().0.as_ref(),
7138 "archived-thread"
7139 );
7140 });
7141}
7142
7143#[gpui::test]
7144async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
7145 cx: &mut TestAppContext,
7146) {
7147 // When a linked worktree has a single thread and that thread is archived,
7148 // the sidebar must NOT create a new thread on the same worktree (which
7149 // would prevent the worktree from being cleaned up on disk). Instead,
7150 // archive_thread switches to a sibling thread on the main workspace (or
7151 // creates a draft there) before archiving the metadata.
7152 agent_ui::test_support::init_test(cx);
7153 cx.update(|cx| {
7154 ThreadStore::init_global(cx);
7155 ThreadMetadataStore::init_global(cx);
7156 language_model::LanguageModelRegistry::test(cx);
7157 prompt_store::init(cx);
7158 });
7159
7160 let fs = FakeFs::new(cx.executor());
7161
7162 fs.insert_tree(
7163 "/project",
7164 serde_json::json!({
7165 ".git": {},
7166 "src": {},
7167 }),
7168 )
7169 .await;
7170
7171 fs.add_linked_worktree_for_repo(
7172 Path::new("/project/.git"),
7173 false,
7174 git::repository::Worktree {
7175 path: std::path::PathBuf::from("/wt-ochre-drift"),
7176 ref_name: Some("refs/heads/ochre-drift".into()),
7177 sha: "aaa".into(),
7178 is_main: false,
7179 is_bare: false,
7180 },
7181 )
7182 .await;
7183
7184 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7185
7186 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7187 let worktree_project =
7188 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7189
7190 main_project
7191 .update(cx, |p, cx| p.git_scans_complete(cx))
7192 .await;
7193 worktree_project
7194 .update(cx, |p, cx| p.git_scans_complete(cx))
7195 .await;
7196
7197 let (multi_workspace, cx) =
7198 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7199
7200 let sidebar = setup_sidebar(&multi_workspace, cx);
7201
7202 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7203 mw.test_add_workspace(worktree_project.clone(), window, cx)
7204 });
7205
7206 // Set up both workspaces with agent panels.
7207 let main_workspace =
7208 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7209 let _main_panel = add_agent_panel(&main_workspace, cx);
7210 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7211
7212 // Activate the linked worktree workspace so the sidebar tracks it.
7213 multi_workspace.update_in(cx, |mw, window, cx| {
7214 mw.activate(worktree_workspace.clone(), None, window, cx);
7215 });
7216
7217 // Open a thread in the linked worktree panel and send a message
7218 // so it becomes the active thread.
7219 let connection = StubAgentConnection::new();
7220 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7221 send_message(&worktree_panel, cx);
7222
7223 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7224
7225 // Give the thread a response chunk so it has content.
7226 cx.update(|_, cx| {
7227 connection.send_update(
7228 worktree_thread_id.clone(),
7229 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7230 cx,
7231 );
7232 });
7233
7234 // Save the worktree thread's metadata.
7235 save_thread_metadata(
7236 worktree_thread_id.clone(),
7237 Some("Ochre Drift Thread".into()),
7238 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7239 None,
7240 None,
7241 &worktree_project,
7242 cx,
7243 );
7244
7245 // Also save a thread on the main project so there's a sibling in the
7246 // group that can be selected after archiving.
7247 save_thread_metadata(
7248 acp::SessionId::new(Arc::from("main-project-thread")),
7249 Some("Main Project Thread".into()),
7250 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7251 None,
7252 None,
7253 &main_project,
7254 cx,
7255 );
7256
7257 cx.run_until_parked();
7258
7259 // Verify the linked worktree thread appears with its chip.
7260 // The live thread title comes from the message text ("Hello"), not
7261 // the metadata title we saved.
7262 let entries_before = visible_entries_as_strings(&sidebar, cx);
7263 assert!(
7264 entries_before
7265 .iter()
7266 .any(|s| s.contains("{wt-ochre-drift}")),
7267 "expected worktree thread with chip before archiving, got: {entries_before:?}"
7268 );
7269 assert!(
7270 entries_before
7271 .iter()
7272 .any(|s| s.contains("Main Project Thread")),
7273 "expected main project thread before archiving, got: {entries_before:?}"
7274 );
7275
7276 // Confirm the worktree thread is the active entry.
7277 sidebar.read_with(cx, |s, _| {
7278 assert_active_thread(
7279 s,
7280 &worktree_thread_id,
7281 "worktree thread should be active before archiving",
7282 );
7283 });
7284
7285 // Archive the worktree thread — it's the only thread using ochre-drift.
7286 sidebar.update_in(cx, |sidebar, window, cx| {
7287 sidebar.archive_thread(&worktree_thread_id, window, cx);
7288 });
7289
7290 cx.run_until_parked();
7291
7292 // The archived thread should no longer appear in the sidebar.
7293 let entries_after = visible_entries_as_strings(&sidebar, cx);
7294 assert!(
7295 !entries_after
7296 .iter()
7297 .any(|s| s.contains("Ochre Drift Thread")),
7298 "archived thread should be hidden, got: {entries_after:?}"
7299 );
7300
7301 // No "+ New Thread" entry should appear with the ochre-drift worktree
7302 // chip — that would keep the worktree alive and prevent cleanup.
7303 assert!(
7304 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7305 "no entry should reference the archived worktree, got: {entries_after:?}"
7306 );
7307
7308 // The main project thread should still be visible.
7309 assert!(
7310 entries_after
7311 .iter()
7312 .any(|s| s.contains("Main Project Thread")),
7313 "main project thread should still be visible, got: {entries_after:?}"
7314 );
7315}
7316
7317#[gpui::test]
7318async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
7319 cx: &mut TestAppContext,
7320) {
7321 // When a linked worktree thread is the ONLY thread in the project group
7322 // (no threads on the main repo either), archiving it should leave the
7323 // group empty with no active entry.
7324 agent_ui::test_support::init_test(cx);
7325 cx.update(|cx| {
7326 ThreadStore::init_global(cx);
7327 ThreadMetadataStore::init_global(cx);
7328 language_model::LanguageModelRegistry::test(cx);
7329 prompt_store::init(cx);
7330 });
7331
7332 let fs = FakeFs::new(cx.executor());
7333
7334 fs.insert_tree(
7335 "/project",
7336 serde_json::json!({
7337 ".git": {},
7338 "src": {},
7339 }),
7340 )
7341 .await;
7342
7343 fs.add_linked_worktree_for_repo(
7344 Path::new("/project/.git"),
7345 false,
7346 git::repository::Worktree {
7347 path: std::path::PathBuf::from("/wt-ochre-drift"),
7348 ref_name: Some("refs/heads/ochre-drift".into()),
7349 sha: "aaa".into(),
7350 is_main: false,
7351 is_bare: false,
7352 },
7353 )
7354 .await;
7355
7356 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7357
7358 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7359 let worktree_project =
7360 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7361
7362 main_project
7363 .update(cx, |p, cx| p.git_scans_complete(cx))
7364 .await;
7365 worktree_project
7366 .update(cx, |p, cx| p.git_scans_complete(cx))
7367 .await;
7368
7369 let (multi_workspace, cx) =
7370 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7371
7372 let sidebar = setup_sidebar(&multi_workspace, cx);
7373
7374 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7375 mw.test_add_workspace(worktree_project.clone(), window, cx)
7376 });
7377
7378 let main_workspace =
7379 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7380 let _main_panel = add_agent_panel(&main_workspace, cx);
7381 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7382
7383 // Activate the linked worktree workspace.
7384 multi_workspace.update_in(cx, |mw, window, cx| {
7385 mw.activate(worktree_workspace.clone(), None, window, cx);
7386 });
7387
7388 // Open a thread on the linked worktree — this is the ONLY thread.
7389 let connection = StubAgentConnection::new();
7390 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7391 send_message(&worktree_panel, cx);
7392
7393 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7394
7395 cx.update(|_, cx| {
7396 connection.send_update(
7397 worktree_thread_id.clone(),
7398 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7399 cx,
7400 );
7401 });
7402
7403 save_thread_metadata(
7404 worktree_thread_id.clone(),
7405 Some("Ochre Drift Thread".into()),
7406 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7407 None,
7408 None,
7409 &worktree_project,
7410 cx,
7411 );
7412
7413 cx.run_until_parked();
7414
7415 // Archive it — there are no other threads in the group.
7416 sidebar.update_in(cx, |sidebar, window, cx| {
7417 sidebar.archive_thread(&worktree_thread_id, window, cx);
7418 });
7419
7420 cx.run_until_parked();
7421
7422 let entries_after = visible_entries_as_strings(&sidebar, cx);
7423
7424 // No entry should reference the linked worktree.
7425 assert!(
7426 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7427 "no entry should reference the archived worktree, got: {entries_after:?}"
7428 );
7429
7430 // The active entry should be None — no draft is created.
7431 sidebar.read_with(cx, |s, _| {
7432 assert!(
7433 s.active_entry.is_none(),
7434 "expected no active entry after archiving the last thread, got: {:?}",
7435 s.active_entry,
7436 );
7437 });
7438}
7439
7440#[gpui::test]
7441async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
7442 cx: &mut TestAppContext,
7443) {
7444 // When an archived thread belongs to a linked worktree whose main repo is
7445 // already open, unarchiving should reopen the linked workspace into the
7446 // same project group and show only the restored real thread row.
7447 agent_ui::test_support::init_test(cx);
7448 cx.update(|cx| {
7449 ThreadStore::init_global(cx);
7450 ThreadMetadataStore::init_global(cx);
7451 language_model::LanguageModelRegistry::test(cx);
7452 prompt_store::init(cx);
7453 });
7454
7455 let fs = FakeFs::new(cx.executor());
7456
7457 fs.insert_tree(
7458 "/project",
7459 serde_json::json!({
7460 ".git": {},
7461 "src": {},
7462 }),
7463 )
7464 .await;
7465
7466 fs.insert_tree(
7467 "/wt-ochre-drift",
7468 serde_json::json!({
7469 ".git": "gitdir: /project/.git/worktrees/ochre-drift",
7470 "src": {},
7471 }),
7472 )
7473 .await;
7474
7475 fs.add_linked_worktree_for_repo(
7476 Path::new("/project/.git"),
7477 false,
7478 git::repository::Worktree {
7479 path: std::path::PathBuf::from("/wt-ochre-drift"),
7480 ref_name: Some("refs/heads/ochre-drift".into()),
7481 sha: "aaa".into(),
7482 is_main: false,
7483 is_bare: false,
7484 },
7485 )
7486 .await;
7487
7488 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7489
7490 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7491 let worktree_project =
7492 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7493
7494 main_project
7495 .update(cx, |p, cx| p.git_scans_complete(cx))
7496 .await;
7497 worktree_project
7498 .update(cx, |p, cx| p.git_scans_complete(cx))
7499 .await;
7500
7501 let (multi_workspace, cx) =
7502 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7503
7504 let sidebar = setup_sidebar(&multi_workspace, cx);
7505 let main_workspace =
7506 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7507 let _main_panel = add_agent_panel(&main_workspace, cx);
7508 cx.run_until_parked();
7509
7510 let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
7511 let original_thread_id = ThreadId::new();
7512 let main_paths = PathList::new(&[PathBuf::from("/project")]);
7513 let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
7514
7515 cx.update(|_, cx| {
7516 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7517 store.save(
7518 ThreadMetadata {
7519 thread_id: original_thread_id,
7520 session_id: Some(session_id.clone()),
7521 agent_id: agent::ZED_AGENT_ID.clone(),
7522 title: Some("Unarchived Linked Thread".into()),
7523 updated_at: Utc::now(),
7524 created_at: None,
7525 interacted_at: None,
7526 worktree_paths: WorktreePaths::from_path_lists(
7527 main_paths.clone(),
7528 folder_paths.clone(),
7529 )
7530 .expect("main and folder paths should be well-formed"),
7531 archived: true,
7532 remote_connection: None,
7533 },
7534 cx,
7535 )
7536 });
7537 });
7538 cx.run_until_parked();
7539
7540 let metadata = cx.update(|_, cx| {
7541 ThreadMetadataStore::global(cx)
7542 .read(cx)
7543 .entry(original_thread_id)
7544 .cloned()
7545 .expect("archived linked-worktree metadata should exist before restore")
7546 });
7547
7548 sidebar.update_in(cx, |sidebar, window, cx| {
7549 sidebar.open_thread_from_archive(metadata, window, cx);
7550 });
7551 cx.run_until_parked();
7552
7553 assert_eq!(
7554 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7555 2,
7556 "expected unarchive to open the linked worktree workspace into the project group"
7557 );
7558
7559 let session_entries = cx.update(|_, cx| {
7560 ThreadMetadataStore::global(cx)
7561 .read(cx)
7562 .entries()
7563 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7564 .cloned()
7565 .collect::<Vec<_>>()
7566 });
7567 assert_eq!(
7568 session_entries.len(),
7569 1,
7570 "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
7571 );
7572 assert_eq!(
7573 session_entries[0].thread_id, original_thread_id,
7574 "expected unarchive to reuse the original linked worktree thread id"
7575 );
7576 assert!(
7577 !session_entries[0].archived,
7578 "expected restored linked worktree metadata to be unarchived, got: {:?}",
7579 session_entries[0]
7580 );
7581
7582 let assert_no_extra_rows = |entries: &[String]| {
7583 let real_thread_rows = entries
7584 .iter()
7585 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7586 .filter(|entry| !entry.contains("Draft"))
7587 .count();
7588 assert_eq!(
7589 real_thread_rows, 1,
7590 "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
7591 );
7592 assert!(
7593 !entries.iter().any(|entry| entry.contains("Draft")),
7594 "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
7595 );
7596 assert!(
7597 !entries
7598 .iter()
7599 .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
7600 "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
7601 );
7602 assert!(
7603 entries
7604 .iter()
7605 .any(|entry| entry.contains("Unarchived Linked Thread")),
7606 "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
7607 );
7608 };
7609
7610 let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
7611 assert_no_extra_rows(&entries_after_restore);
7612
7613 // The reported bug may only appear after an extra scheduling turn.
7614 cx.run_until_parked();
7615
7616 let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
7617 assert_no_extra_rows(&entries_after_extra_turns);
7618}
7619
7620#[gpui::test]
7621async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
7622 // When a linked worktree thread is archived but the group has other
7623 // threads (e.g. on the main project), archive_thread should select
7624 // the nearest sibling.
7625 agent_ui::test_support::init_test(cx);
7626 cx.update(|cx| {
7627 ThreadStore::init_global(cx);
7628 ThreadMetadataStore::init_global(cx);
7629 language_model::LanguageModelRegistry::test(cx);
7630 prompt_store::init(cx);
7631 });
7632
7633 let fs = FakeFs::new(cx.executor());
7634
7635 fs.insert_tree(
7636 "/project",
7637 serde_json::json!({
7638 ".git": {},
7639 "src": {},
7640 }),
7641 )
7642 .await;
7643
7644 fs.add_linked_worktree_for_repo(
7645 Path::new("/project/.git"),
7646 false,
7647 git::repository::Worktree {
7648 path: std::path::PathBuf::from("/wt-ochre-drift"),
7649 ref_name: Some("refs/heads/ochre-drift".into()),
7650 sha: "aaa".into(),
7651 is_main: false,
7652 is_bare: false,
7653 },
7654 )
7655 .await;
7656
7657 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7658
7659 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7660 let worktree_project =
7661 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7662
7663 main_project
7664 .update(cx, |p, cx| p.git_scans_complete(cx))
7665 .await;
7666 worktree_project
7667 .update(cx, |p, cx| p.git_scans_complete(cx))
7668 .await;
7669
7670 let (multi_workspace, cx) =
7671 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7672
7673 let sidebar = setup_sidebar(&multi_workspace, cx);
7674
7675 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7676 mw.test_add_workspace(worktree_project.clone(), window, cx)
7677 });
7678
7679 let main_workspace =
7680 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7681 let _main_panel = add_agent_panel(&main_workspace, cx);
7682 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7683
7684 // Activate the linked worktree workspace.
7685 multi_workspace.update_in(cx, |mw, window, cx| {
7686 mw.activate(worktree_workspace.clone(), None, window, cx);
7687 });
7688
7689 // Open a thread on the linked worktree.
7690 let connection = StubAgentConnection::new();
7691 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7692 send_message(&worktree_panel, cx);
7693
7694 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7695
7696 cx.update(|_, cx| {
7697 connection.send_update(
7698 worktree_thread_id.clone(),
7699 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7700 cx,
7701 );
7702 });
7703
7704 save_thread_metadata(
7705 worktree_thread_id.clone(),
7706 Some("Ochre Drift Thread".into()),
7707 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7708 None,
7709 None,
7710 &worktree_project,
7711 cx,
7712 );
7713
7714 // Save a sibling thread on the main project.
7715 let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
7716 save_thread_metadata(
7717 main_thread_id,
7718 Some("Main Project Thread".into()),
7719 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7720 None,
7721 None,
7722 &main_project,
7723 cx,
7724 );
7725
7726 cx.run_until_parked();
7727
7728 // Confirm the worktree thread is active.
7729 sidebar.read_with(cx, |s, _| {
7730 assert_active_thread(
7731 s,
7732 &worktree_thread_id,
7733 "worktree thread should be active before archiving",
7734 );
7735 });
7736
7737 // Archive the worktree thread.
7738 sidebar.update_in(cx, |sidebar, window, cx| {
7739 sidebar.archive_thread(&worktree_thread_id, window, cx);
7740 });
7741
7742 cx.run_until_parked();
7743
7744 // The worktree workspace was removed and a draft was created on the
7745 // main workspace. No entry should reference the linked worktree.
7746 let entries_after = visible_entries_as_strings(&sidebar, cx);
7747 assert!(
7748 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7749 "no entry should reference the archived worktree, got: {entries_after:?}"
7750 );
7751
7752 // The main project thread should still be visible.
7753 assert!(
7754 entries_after
7755 .iter()
7756 .any(|s| s.contains("Main Project Thread")),
7757 "main project thread should still be visible, got: {entries_after:?}"
7758 );
7759}
7760
7761// TODO: Restore this test once linked worktree draft entries are re-implemented.
7762// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
7763#[gpui::test]
7764#[ignore = "linked worktree draft entries not yet implemented"]
7765async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
7766 init_test(cx);
7767 let fs = FakeFs::new(cx.executor());
7768
7769 fs.insert_tree(
7770 "/project",
7771 serde_json::json!({
7772 ".git": {
7773 "worktrees": {
7774 "feature-a": {
7775 "commondir": "../../",
7776 "HEAD": "ref: refs/heads/feature-a",
7777 },
7778 },
7779 },
7780 "src": {},
7781 }),
7782 )
7783 .await;
7784
7785 fs.insert_tree(
7786 "/wt-feature-a",
7787 serde_json::json!({
7788 ".git": "gitdir: /project/.git/worktrees/feature-a",
7789 "src": {},
7790 }),
7791 )
7792 .await;
7793
7794 fs.add_linked_worktree_for_repo(
7795 Path::new("/project/.git"),
7796 false,
7797 git::repository::Worktree {
7798 path: PathBuf::from("/wt-feature-a"),
7799 ref_name: Some("refs/heads/feature-a".into()),
7800 sha: "aaa".into(),
7801 is_main: false,
7802 is_bare: false,
7803 },
7804 )
7805 .await;
7806
7807 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7808
7809 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7810 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7811
7812 main_project
7813 .update(cx, |p, cx| p.git_scans_complete(cx))
7814 .await;
7815 worktree_project
7816 .update(cx, |p, cx| p.git_scans_complete(cx))
7817 .await;
7818
7819 let (multi_workspace, cx) =
7820 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7821 let sidebar = setup_sidebar(&multi_workspace, cx);
7822
7823 // Open the linked worktree as a separate workspace (simulates cmd-o).
7824 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7825 mw.test_add_workspace(worktree_project.clone(), window, cx)
7826 });
7827 add_agent_panel(&worktree_workspace, cx);
7828 cx.run_until_parked();
7829
7830 // Explicitly create a draft thread from the linked worktree workspace.
7831 // Auto-created drafts use the group's first workspace (the main one),
7832 // so a user-created draft is needed to make the linked worktree reachable.
7833 sidebar.update_in(cx, |sidebar, window, cx| {
7834 sidebar.create_new_thread(&worktree_workspace, window, cx);
7835 });
7836 cx.run_until_parked();
7837
7838 // Switch back to the main workspace.
7839 multi_workspace.update_in(cx, |mw, window, cx| {
7840 let main_ws = mw.workspaces().next().unwrap().clone();
7841 mw.activate(main_ws, None, window, cx);
7842 });
7843 cx.run_until_parked();
7844
7845 sidebar.update_in(cx, |sidebar, _window, cx| {
7846 sidebar.update_entries(cx);
7847 });
7848 cx.run_until_parked();
7849
7850 // The linked worktree workspace must be reachable from some sidebar entry.
7851 let worktree_ws_id = worktree_workspace.entity_id();
7852 let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
7853 let mw = multi_workspace.read(cx);
7854 sidebar
7855 .contents
7856 .entries
7857 .iter()
7858 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
7859 .map(|ws| ws.entity_id())
7860 .collect()
7861 });
7862 assert!(
7863 reachable.contains(&worktree_ws_id),
7864 "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
7865 );
7866
7867 // Find the draft Thread entry whose workspace is the linked worktree.
7868 let _ = (worktree_ws_id, sidebar, multi_workspace);
7869 // todo("re-implement once linked worktree draft entries exist");
7870}
7871
7872#[gpui::test]
7873async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
7874 // When only a linked worktree workspace is open (not the main repo),
7875 // threads saved against the main repo should still appear in the sidebar.
7876 init_test(cx);
7877 let fs = FakeFs::new(cx.executor());
7878
7879 // Create the main repo with a linked worktree.
7880 fs.insert_tree(
7881 "/project",
7882 serde_json::json!({
7883 ".git": {
7884 "worktrees": {
7885 "feature-a": {
7886 "commondir": "../../",
7887 "HEAD": "ref: refs/heads/feature-a",
7888 },
7889 },
7890 },
7891 "src": {},
7892 }),
7893 )
7894 .await;
7895
7896 fs.insert_tree(
7897 "/wt-feature-a",
7898 serde_json::json!({
7899 ".git": "gitdir: /project/.git/worktrees/feature-a",
7900 "src": {},
7901 }),
7902 )
7903 .await;
7904
7905 fs.add_linked_worktree_for_repo(
7906 std::path::Path::new("/project/.git"),
7907 false,
7908 git::repository::Worktree {
7909 path: std::path::PathBuf::from("/wt-feature-a"),
7910 ref_name: Some("refs/heads/feature-a".into()),
7911 sha: "abc".into(),
7912 is_main: false,
7913 is_bare: false,
7914 },
7915 )
7916 .await;
7917
7918 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7919
7920 // Only open the linked worktree as a workspace — NOT the main repo.
7921 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7922 worktree_project
7923 .update(cx, |p, cx| p.git_scans_complete(cx))
7924 .await;
7925
7926 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7927 main_project
7928 .update(cx, |p, cx| p.git_scans_complete(cx))
7929 .await;
7930
7931 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7932 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
7933 });
7934 let sidebar = setup_sidebar(&multi_workspace, cx);
7935
7936 // Save a thread against the MAIN repo path.
7937 save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
7938
7939 // Save a thread against the linked worktree path.
7940 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
7941
7942 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7943 cx.run_until_parked();
7944
7945 // Both threads should be visible: the worktree thread by direct lookup,
7946 // and the main repo thread because the workspace is a linked worktree
7947 // and we also query the main repo path.
7948 let entries = visible_entries_as_strings(&sidebar, cx);
7949 assert!(
7950 entries.iter().any(|e| e.contains("Main Repo Thread")),
7951 "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
7952 );
7953 assert!(
7954 entries.iter().any(|e| e.contains("Worktree Thread")),
7955 "expected worktree thread to be visible, got: {entries:?}"
7956 );
7957}
7958
7959async fn init_multi_project_test(
7960 paths: &[&str],
7961 cx: &mut TestAppContext,
7962) -> (Arc<FakeFs>, Entity<project::Project>) {
7963 agent_ui::test_support::init_test(cx);
7964 cx.update(|cx| {
7965 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
7966 ThreadStore::init_global(cx);
7967 ThreadMetadataStore::init_global(cx);
7968 language_model::LanguageModelRegistry::test(cx);
7969 prompt_store::init(cx);
7970 });
7971 let fs = FakeFs::new(cx.executor());
7972 for path in paths {
7973 fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
7974 .await;
7975 }
7976 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7977 let project =
7978 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
7979 (fs, project)
7980}
7981
7982async fn add_test_project(
7983 path: &str,
7984 fs: &Arc<FakeFs>,
7985 multi_workspace: &Entity<MultiWorkspace>,
7986 cx: &mut gpui::VisualTestContext,
7987) -> Entity<Workspace> {
7988 let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
7989 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7990 mw.test_add_workspace(project, window, cx)
7991 });
7992 cx.run_until_parked();
7993 workspace
7994}
7995
7996#[gpui::test]
7997async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
7998 let (fs, project_a) =
7999 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
8000 let (multi_workspace, cx) =
8001 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8002 let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
8003
8004 // Sidebar starts closed. Initial workspace A is transient.
8005 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8006 assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
8007 assert_eq!(
8008 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8009 1
8010 );
8011 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
8012
8013 // Add B — replaces A as the transient workspace.
8014 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8015 assert_eq!(
8016 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8017 1
8018 );
8019 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
8020
8021 // Add C — replaces B as the transient workspace.
8022 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8023 assert_eq!(
8024 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8025 1
8026 );
8027 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8028}
8029
8030#[gpui::test]
8031async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
8032 let (fs, project_a) = init_multi_project_test(
8033 &["/project-a", "/project-b", "/project-c", "/project-d"],
8034 cx,
8035 )
8036 .await;
8037 let (multi_workspace, cx) =
8038 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8039 let _sidebar = setup_sidebar(&multi_workspace, cx);
8040 assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
8041
8042 // Add B — retained since sidebar is open.
8043 let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8044 assert_eq!(
8045 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8046 2
8047 );
8048
8049 // Switch to A — B survives. (Switching from one internal workspace, to another)
8050 multi_workspace.update_in(cx, |mw, window, cx| {
8051 mw.activate(workspace_a, None, window, cx)
8052 });
8053 cx.run_until_parked();
8054 assert_eq!(
8055 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8056 2
8057 );
8058
8059 // Close sidebar — both A and B remain retained.
8060 multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
8061 cx.run_until_parked();
8062 assert_eq!(
8063 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8064 2
8065 );
8066
8067 // Add C — added as new transient workspace. (switching from retained, to transient)
8068 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8069 assert_eq!(
8070 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8071 3
8072 );
8073 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8074
8075 // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
8076 let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
8077 assert_eq!(
8078 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8079 3
8080 );
8081 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
8082}
8083
8084#[gpui::test]
8085async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
8086 let (fs, project_a) =
8087 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
8088 let (multi_workspace, cx) =
8089 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8090 setup_sidebar_closed(&multi_workspace, cx);
8091
8092 // Add B — replaces A as the transient workspace (A is discarded).
8093 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8094 assert_eq!(
8095 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8096 1
8097 );
8098 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
8099
8100 // Open sidebar — promotes the transient B to retained.
8101 multi_workspace.update_in(cx, |mw, window, cx| {
8102 mw.toggle_sidebar(window, cx);
8103 });
8104 cx.run_until_parked();
8105 assert_eq!(
8106 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8107 1
8108 );
8109 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
8110
8111 // Close sidebar — the retained B remains.
8112 multi_workspace.update_in(cx, |mw, window, cx| {
8113 mw.toggle_sidebar(window, cx);
8114 });
8115
8116 // Add C — added as new transient workspace.
8117 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8118 assert_eq!(
8119 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8120 2
8121 );
8122 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8123}
8124
8125#[gpui::test]
8126async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
8127 init_test(cx);
8128 let fs = FakeFs::new(cx.executor());
8129
8130 fs.insert_tree(
8131 "/project",
8132 serde_json::json!({
8133 ".git": {
8134 "worktrees": {
8135 "feature-a": {
8136 "commondir": "../../",
8137 "HEAD": "ref: refs/heads/feature-a",
8138 },
8139 },
8140 },
8141 "src": {},
8142 }),
8143 )
8144 .await;
8145
8146 fs.insert_tree(
8147 "/wt-feature-a",
8148 serde_json::json!({
8149 ".git": "gitdir: /project/.git/worktrees/feature-a",
8150 "src": {},
8151 }),
8152 )
8153 .await;
8154
8155 fs.add_linked_worktree_for_repo(
8156 Path::new("/project/.git"),
8157 false,
8158 git::repository::Worktree {
8159 path: PathBuf::from("/wt-feature-a"),
8160 ref_name: Some("refs/heads/feature-a".into()),
8161 sha: "abc".into(),
8162 is_main: false,
8163 is_bare: false,
8164 },
8165 )
8166 .await;
8167
8168 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8169
8170 // Only a linked worktree workspace is open — no workspace for /project.
8171 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
8172 worktree_project
8173 .update(cx, |p, cx| p.git_scans_complete(cx))
8174 .await;
8175
8176 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
8177 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
8178 });
8179 let sidebar = setup_sidebar(&multi_workspace, cx);
8180
8181 // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
8182 let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
8183 cx.update(|_, cx| {
8184 let metadata = ThreadMetadata {
8185 thread_id: ThreadId::new(),
8186 session_id: Some(legacy_session.clone()),
8187 agent_id: agent::ZED_AGENT_ID.clone(),
8188 title: Some("Legacy Main Thread".into()),
8189 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8190 created_at: None,
8191 interacted_at: None,
8192 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
8193 "/project",
8194 )])),
8195 archived: false,
8196 remote_connection: None,
8197 };
8198 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
8199 });
8200 cx.run_until_parked();
8201
8202 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
8203 cx.run_until_parked();
8204
8205 // The legacy thread should appear in the sidebar under the project group.
8206 let entries = visible_entries_as_strings(&sidebar, cx);
8207 assert!(
8208 entries.iter().any(|e| e.contains("Legacy Main Thread")),
8209 "legacy thread should be visible: {entries:?}",
8210 );
8211
8212 // Verify only 1 workspace before clicking.
8213 assert_eq!(
8214 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8215 1,
8216 );
8217
8218 // Focus and select the legacy thread, then confirm.
8219 focus_sidebar(&sidebar, cx);
8220 let thread_index = sidebar.read_with(cx, |sidebar, _| {
8221 sidebar
8222 .contents
8223 .entries
8224 .iter()
8225 .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
8226 .expect("legacy thread should be in entries")
8227 });
8228 sidebar.update_in(cx, |sidebar, _window, _cx| {
8229 sidebar.selection = Some(thread_index);
8230 });
8231 cx.dispatch_action(Confirm);
8232 cx.run_until_parked();
8233
8234 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8235 let new_path_list =
8236 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
8237 assert_eq!(
8238 new_path_list,
8239 PathList::new(&[PathBuf::from("/project")]),
8240 "the new workspace should be for the main repo, not the linked worktree",
8241 );
8242}
8243
8244#[gpui::test]
8245async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
8246 cx: &mut TestAppContext,
8247) {
8248 // Regression test for a property-test finding:
8249 // AddLinkedWorktree { project_group_index: 0 }
8250 // AddProject { use_worktree: true }
8251 // AddProject { use_worktree: false }
8252 // After these three steps, the linked-worktree workspace was not
8253 // reachable from any sidebar entry.
8254 agent_ui::test_support::init_test(cx);
8255 cx.update(|cx| {
8256 ThreadStore::init_global(cx);
8257 ThreadMetadataStore::init_global(cx);
8258 language_model::LanguageModelRegistry::test(cx);
8259 prompt_store::init(cx);
8260
8261 cx.observe_new(
8262 |workspace: &mut Workspace,
8263 window: Option<&mut Window>,
8264 cx: &mut gpui::Context<Workspace>| {
8265 if let Some(window) = window {
8266 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
8267 workspace.add_panel(panel, window, cx);
8268 }
8269 },
8270 )
8271 .detach();
8272 });
8273
8274 let fs = FakeFs::new(cx.executor());
8275 fs.insert_tree(
8276 "/my-project",
8277 serde_json::json!({
8278 ".git": {},
8279 "src": {},
8280 }),
8281 )
8282 .await;
8283 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8284 let project =
8285 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
8286 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8287
8288 let (multi_workspace, cx) =
8289 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8290 let sidebar = setup_sidebar(&multi_workspace, cx);
8291
8292 // Step 1: Create a linked worktree for the main project.
8293 let worktree_name = "wt-0";
8294 let worktree_path = "/worktrees/wt-0";
8295
8296 fs.insert_tree(
8297 worktree_path,
8298 serde_json::json!({
8299 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8300 "src": {},
8301 }),
8302 )
8303 .await;
8304 fs.insert_tree(
8305 "/my-project/.git/worktrees/wt-0",
8306 serde_json::json!({
8307 "commondir": "../../",
8308 "HEAD": "ref: refs/heads/wt-0",
8309 }),
8310 )
8311 .await;
8312 fs.add_linked_worktree_for_repo(
8313 Path::new("/my-project/.git"),
8314 false,
8315 git::repository::Worktree {
8316 path: PathBuf::from(worktree_path),
8317 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
8318 sha: "aaa".into(),
8319 is_main: false,
8320 is_bare: false,
8321 },
8322 )
8323 .await;
8324
8325 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8326 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
8327 main_project
8328 .update(cx, |p, cx| p.git_scans_complete(cx))
8329 .await;
8330 cx.run_until_parked();
8331
8332 // Step 2: Open the linked worktree as its own workspace.
8333 let worktree_project =
8334 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
8335 worktree_project
8336 .update(cx, |p, cx| p.git_scans_complete(cx))
8337 .await;
8338 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8339 mw.test_add_workspace(worktree_project.clone(), window, cx)
8340 });
8341 cx.run_until_parked();
8342
8343 // Step 3: Add an unrelated project.
8344 fs.insert_tree(
8345 "/other-project",
8346 serde_json::json!({
8347 ".git": {},
8348 "src": {},
8349 }),
8350 )
8351 .await;
8352 let other_project = project::Project::test(
8353 fs.clone() as Arc<dyn fs::Fs>,
8354 ["/other-project".as_ref()],
8355 cx,
8356 )
8357 .await;
8358 other_project
8359 .update(cx, |p, cx| p.git_scans_complete(cx))
8360 .await;
8361 multi_workspace.update_in(cx, |mw, window, cx| {
8362 mw.test_add_workspace(other_project.clone(), window, cx);
8363 });
8364 cx.run_until_parked();
8365
8366 // Force a full sidebar rebuild with all groups expanded.
8367 sidebar.update_in(cx, |sidebar, _window, cx| {
8368 if let Some(mw) = sidebar.multi_workspace.upgrade() {
8369 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
8370 }
8371 sidebar.update_entries(cx);
8372 });
8373 cx.run_until_parked();
8374
8375 // The linked-worktree workspace must be reachable from at least one
8376 // sidebar entry — otherwise the user has no way to navigate to it.
8377 let worktree_ws_id = worktree_workspace.entity_id();
8378 let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
8379 let mw = multi_workspace.read(cx);
8380
8381 let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
8382 let reachable: HashSet<gpui::EntityId> = sidebar
8383 .contents
8384 .entries
8385 .iter()
8386 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
8387 .map(|ws| ws.entity_id())
8388 .collect();
8389 (all, reachable)
8390 });
8391
8392 let unreachable = &all_ids - &reachable_ids;
8393 eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
8394
8395 assert!(
8396 unreachable.is_empty(),
8397 "workspaces not reachable from any sidebar entry: {:?}\n\
8398 (linked-worktree workspace id: {:?})",
8399 unreachable,
8400 worktree_ws_id,
8401 );
8402}
8403
8404#[gpui::test]
8405async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
8406 // Empty project groups no longer auto-create drafts via reconciliation.
8407 // A fresh startup with no restorable thread should show only the header.
8408 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8409 let (multi_workspace, cx) =
8410 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8411 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8412
8413 let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8414
8415 let entries = visible_entries_as_strings(&sidebar, cx);
8416 assert_eq!(
8417 entries,
8418 vec!["v [my-project]"],
8419 "empty group should show only the header, no auto-created draft"
8420 );
8421}
8422
8423#[gpui::test]
8424async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
8425 // Rule 5: When the app starts and the AgentPanel successfully loads
8426 // a thread, no spurious draft should appear.
8427 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8428 let (multi_workspace, cx) =
8429 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8430 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8431
8432 // Create and send a message to make a real thread.
8433 let connection = StubAgentConnection::new();
8434 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8435 acp::ContentChunk::new("Done".into()),
8436 )]);
8437 open_thread_with_connection(&panel, connection, cx);
8438 send_message(&panel, cx);
8439 let session_id = active_session_id(&panel, cx);
8440 save_test_thread_metadata(&session_id, &project, cx).await;
8441 cx.run_until_parked();
8442
8443 // Should show the thread, NOT a spurious draft.
8444 let entries = visible_entries_as_strings(&sidebar, cx);
8445 assert_eq!(entries, vec!["v [my-project]", " Hello *"]);
8446
8447 // active_entry should be Thread, not Draft.
8448 sidebar.read_with(cx, |sidebar, _| {
8449 assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
8450 });
8451}
8452
8453#[gpui::test]
8454async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
8455 // Rule 9: Clicking a project header should restore whatever the
8456 // user was last looking at in that group, not create new drafts
8457 // or jump to the first entry.
8458 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
8459 let (multi_workspace, cx) =
8460 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8461 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8462
8463 // Create two threads in project-a.
8464 let conn1 = StubAgentConnection::new();
8465 conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8466 acp::ContentChunk::new("Done".into()),
8467 )]);
8468 open_thread_with_connection(&panel_a, conn1, cx);
8469 send_message(&panel_a, cx);
8470 let thread_a1 = active_session_id(&panel_a, cx);
8471 save_test_thread_metadata(&thread_a1, &project_a, cx).await;
8472
8473 let conn2 = StubAgentConnection::new();
8474 conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8475 acp::ContentChunk::new("Done".into()),
8476 )]);
8477 open_thread_with_connection(&panel_a, conn2, cx);
8478 send_message(&panel_a, cx);
8479 let thread_a2 = active_session_id(&panel_a, cx);
8480 save_test_thread_metadata(&thread_a2, &project_a, cx).await;
8481 cx.run_until_parked();
8482
8483 // The user is now looking at thread_a2.
8484 sidebar.read_with(cx, |sidebar, _| {
8485 assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
8486 });
8487
8488 // Add project-b and switch to it.
8489 let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
8490 fs.as_fake()
8491 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
8492 .await;
8493 let project_b =
8494 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8495 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8496 mw.test_add_workspace(project_b.clone(), window, cx)
8497 });
8498 let _panel_b = add_agent_panel(&workspace_b, cx);
8499 cx.run_until_parked();
8500
8501 // Now switch BACK to project-a by activating its workspace.
8502 let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
8503 mw.workspaces()
8504 .find(|ws| {
8505 ws.read(cx)
8506 .project()
8507 .read(cx)
8508 .visible_worktrees(cx)
8509 .any(|wt| {
8510 wt.read(cx)
8511 .abs_path()
8512 .to_string_lossy()
8513 .contains("project-a")
8514 })
8515 })
8516 .unwrap()
8517 .clone()
8518 });
8519 multi_workspace.update_in(cx, |mw, window, cx| {
8520 mw.activate(workspace_a.clone(), None, window, cx);
8521 });
8522 cx.run_until_parked();
8523
8524 // The panel should still show thread_a2 (the last thing the user
8525 // was viewing in project-a), not a draft or thread_a1.
8526 sidebar.read_with(cx, |sidebar, _| {
8527 assert_active_thread(
8528 sidebar,
8529 &thread_a2,
8530 "switching back to project-a should restore thread_a2",
8531 );
8532 });
8533
8534 // No spurious draft entries should have been created in
8535 // project-a's group (project-b may have a placeholder).
8536 let entries = visible_entries_as_strings(&sidebar, cx);
8537 // Find project-a's section and check it has no drafts.
8538 let project_a_start = entries
8539 .iter()
8540 .position(|e| e.contains("project-a"))
8541 .unwrap();
8542 let project_a_end = entries[project_a_start + 1..]
8543 .iter()
8544 .position(|e| e.starts_with("v "))
8545 .map(|i| i + project_a_start + 1)
8546 .unwrap_or(entries.len());
8547 let project_a_drafts = entries[project_a_start..project_a_end]
8548 .iter()
8549 .filter(|e| e.contains("Draft"))
8550 .count();
8551 assert_eq!(
8552 project_a_drafts, 0,
8553 "switching back to project-a should not create drafts in its group"
8554 );
8555}
8556
8557#[gpui::test]
8558async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
8559 // When a workspace has a draft (from the panel's load fallback)
8560 // and the user activates it (e.g. by clicking the placeholder or
8561 // the project header), no extra drafts should be created.
8562 init_test(cx);
8563 let fs = FakeFs::new(cx.executor());
8564 fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
8565 .await;
8566 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8567 .await;
8568 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8569
8570 let project_a =
8571 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
8572 let (multi_workspace, cx) =
8573 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8574 let sidebar = setup_sidebar(&multi_workspace, cx);
8575 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8576 let _panel_a = add_agent_panel(&workspace_a, cx);
8577 cx.run_until_parked();
8578
8579 // Add project-b with its own workspace and agent panel.
8580 let project_b =
8581 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8582 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8583 mw.test_add_workspace(project_b.clone(), window, cx)
8584 });
8585 let _panel_b = add_agent_panel(&workspace_b, cx);
8586 cx.run_until_parked();
8587
8588 // Explicitly create a draft on workspace_b so the sidebar tracks one.
8589 sidebar.update_in(cx, |sidebar, window, cx| {
8590 sidebar.create_new_thread(&workspace_b, window, cx);
8591 });
8592 cx.run_until_parked();
8593
8594 // Count project-b's drafts.
8595 let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
8596 let entries = visible_entries_as_strings(&sidebar, cx);
8597 entries
8598 .iter()
8599 .skip_while(|e| !e.contains("project-b"))
8600 .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
8601 .filter(|e| e.contains("Draft"))
8602 .count()
8603 };
8604 let drafts_before = count_b_drafts(cx);
8605
8606 // Switch away from project-b, then back.
8607 multi_workspace.update_in(cx, |mw, window, cx| {
8608 mw.activate(workspace_a.clone(), None, window, cx);
8609 });
8610 cx.run_until_parked();
8611 multi_workspace.update_in(cx, |mw, window, cx| {
8612 mw.activate(workspace_b.clone(), None, window, cx);
8613 });
8614 cx.run_until_parked();
8615
8616 let drafts_after = count_b_drafts(cx);
8617 assert_eq!(
8618 drafts_before, drafts_after,
8619 "activating workspace should not create extra drafts"
8620 );
8621
8622 // The draft should be highlighted as active after switching back.
8623 sidebar.read_with(cx, |sidebar, _| {
8624 assert_active_draft(
8625 sidebar,
8626 &workspace_b,
8627 "draft should be active after switching back to its workspace",
8628 );
8629 });
8630}
8631
8632#[gpui::test]
8633async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
8634 // Historical threads (not open in any agent panel) should have their
8635 // worktree paths updated when a folder is added to or removed from the
8636 // project.
8637 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
8638 let (multi_workspace, cx) =
8639 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8640 let sidebar = setup_sidebar(&multi_workspace, cx);
8641
8642 // Save two threads directly into the metadata store (not via the agent
8643 // panel), so they are purely historical — no open views hold them.
8644 // Use different timestamps so sort order is deterministic.
8645 save_thread_metadata(
8646 acp::SessionId::new(Arc::from("hist-1")),
8647 Some("Historical 1".into()),
8648 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8649 None,
8650 None,
8651 &project,
8652 cx,
8653 );
8654 save_thread_metadata(
8655 acp::SessionId::new(Arc::from("hist-2")),
8656 Some("Historical 2".into()),
8657 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
8658 None,
8659 None,
8660 &project,
8661 cx,
8662 );
8663 cx.run_until_parked();
8664 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8665 cx.run_until_parked();
8666
8667 // Sanity-check: both threads exist under the initial key [/project-a].
8668 let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
8669 cx.update(|_window, cx| {
8670 let store = ThreadMetadataStore::global(cx).read(cx);
8671 assert_eq!(
8672 store
8673 .entries_for_main_worktree_path(&old_key_paths, None)
8674 .count(),
8675 2,
8676 "should have 2 historical threads under old key before worktree add"
8677 );
8678 });
8679
8680 // Add a second worktree to the project.
8681 project
8682 .update(cx, |project, cx| {
8683 project.find_or_create_worktree("/project-b", true, cx)
8684 })
8685 .await
8686 .expect("should add worktree");
8687 cx.run_until_parked();
8688
8689 // The historical threads should now be indexed under the new combined
8690 // key [/project-a, /project-b].
8691 let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
8692 cx.update(|_window, cx| {
8693 let store = ThreadMetadataStore::global(cx).read(cx);
8694 assert_eq!(
8695 store
8696 .entries_for_main_worktree_path(&old_key_paths, None)
8697 .count(),
8698 0,
8699 "should have 0 historical threads under old key after worktree add"
8700 );
8701 assert_eq!(
8702 store
8703 .entries_for_main_worktree_path(&new_key_paths, None)
8704 .count(),
8705 2,
8706 "should have 2 historical threads under new key after worktree add"
8707 );
8708 });
8709
8710 // Sidebar should show threads under the new header.
8711 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8712 cx.run_until_parked();
8713 assert_eq!(
8714 visible_entries_as_strings(&sidebar, cx),
8715 vec![
8716 "v [project-a, project-b]",
8717 " Historical 2",
8718 " Historical 1",
8719 ]
8720 );
8721
8722 // Now remove the second worktree.
8723 let worktree_id = project.read_with(cx, |project, cx| {
8724 project
8725 .visible_worktrees(cx)
8726 .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
8727 .map(|wt| wt.read(cx).id())
8728 .expect("should find project-b worktree")
8729 });
8730 project.update(cx, |project, cx| {
8731 project.remove_worktree(worktree_id, cx);
8732 });
8733 cx.run_until_parked();
8734
8735 // Historical threads should migrate back to the original key.
8736 cx.update(|_window, cx| {
8737 let store = ThreadMetadataStore::global(cx).read(cx);
8738 assert_eq!(
8739 store
8740 .entries_for_main_worktree_path(&new_key_paths, None)
8741 .count(),
8742 0,
8743 "should have 0 historical threads under new key after worktree remove"
8744 );
8745 assert_eq!(
8746 store
8747 .entries_for_main_worktree_path(&old_key_paths, None)
8748 .count(),
8749 2,
8750 "should have 2 historical threads under old key after worktree remove"
8751 );
8752 });
8753
8754 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8755 cx.run_until_parked();
8756 assert_eq!(
8757 visible_entries_as_strings(&sidebar, cx),
8758 vec!["v [project-a]", " Historical 2", " Historical 1",]
8759 );
8760}
8761
8762#[gpui::test]
8763async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
8764 // When two workspaces share the same project group (same main path)
8765 // but have different folder paths (main repo vs linked worktree),
8766 // adding a worktree to the main workspace should regroup only that
8767 // workspace and its threads into the new project group. Threads for the
8768 // linked worktree workspace should remain under the original group.
8769 agent_ui::test_support::init_test(cx);
8770 cx.update(|cx| {
8771 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
8772 ThreadStore::init_global(cx);
8773 ThreadMetadataStore::init_global(cx);
8774 language_model::LanguageModelRegistry::test(cx);
8775 prompt_store::init(cx);
8776 });
8777
8778 let fs = FakeFs::new(cx.executor());
8779 fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
8780 .await;
8781 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8782 .await;
8783 fs.add_linked_worktree_for_repo(
8784 Path::new("/project/.git"),
8785 false,
8786 git::repository::Worktree {
8787 path: std::path::PathBuf::from("/wt-feature"),
8788 ref_name: Some("refs/heads/feature".into()),
8789 sha: "aaa".into(),
8790 is_main: false,
8791 is_bare: false,
8792 },
8793 )
8794 .await;
8795 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8796
8797 // Workspace A: main repo at /project.
8798 let main_project =
8799 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
8800 // Workspace B: linked worktree of the same repo (same group, different folder).
8801 let worktree_project =
8802 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
8803
8804 main_project
8805 .update(cx, |p, cx| p.git_scans_complete(cx))
8806 .await;
8807 worktree_project
8808 .update(cx, |p, cx| p.git_scans_complete(cx))
8809 .await;
8810
8811 let (multi_workspace, cx) =
8812 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
8813 let sidebar = setup_sidebar(&multi_workspace, cx);
8814 multi_workspace.update_in(cx, |mw, window, cx| {
8815 mw.test_add_workspace(worktree_project.clone(), window, cx);
8816 });
8817 cx.run_until_parked();
8818
8819 // Save a thread for each workspace's folder paths.
8820 let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
8821 let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
8822 save_thread_metadata(
8823 acp::SessionId::new(Arc::from("thread-main")),
8824 Some("Main Thread".into()),
8825 time_main,
8826 Some(time_main),
8827 None,
8828 &main_project,
8829 cx,
8830 );
8831 save_thread_metadata(
8832 acp::SessionId::new(Arc::from("thread-wt")),
8833 Some("Worktree Thread".into()),
8834 time_wt,
8835 Some(time_wt),
8836 None,
8837 &worktree_project,
8838 cx,
8839 );
8840 cx.run_until_parked();
8841
8842 let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
8843 let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
8844
8845 // Sanity-check: each thread is indexed under its own folder paths, but
8846 // both appear under the shared sidebar group keyed by the main worktree.
8847 cx.update(|_window, cx| {
8848 let store = ThreadMetadataStore::global(cx).read(cx);
8849 assert_eq!(
8850 store.entries_for_path(&folder_paths_main, None).count(),
8851 1,
8852 "one thread under [/project]"
8853 );
8854 assert_eq!(
8855 store.entries_for_path(&folder_paths_wt, None).count(),
8856 1,
8857 "one thread under [/wt-feature]"
8858 );
8859 });
8860 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8861 cx.run_until_parked();
8862 assert_eq!(
8863 visible_entries_as_strings(&sidebar, cx),
8864 vec![
8865 "v [project]",
8866 " Worktree Thread {wt-feature}",
8867 " Main Thread",
8868 ]
8869 );
8870
8871 // Add /project-b to the main project only.
8872 main_project
8873 .update(cx, |project, cx| {
8874 project.find_or_create_worktree("/project-b", true, cx)
8875 })
8876 .await
8877 .expect("should add worktree");
8878 cx.run_until_parked();
8879
8880 // Main Thread (folder paths [/project]) should be regrouped to
8881 // [/project, /project-b]. Worktree Thread should remain under the
8882 // original [/project] group.
8883 let folder_paths_main_b =
8884 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
8885 cx.update(|_window, cx| {
8886 let store = ThreadMetadataStore::global(cx).read(cx);
8887 assert_eq!(
8888 store.entries_for_path(&folder_paths_main, None).count(),
8889 0,
8890 "main thread should no longer be under old folder paths [/project]"
8891 );
8892 assert_eq!(
8893 store.entries_for_path(&folder_paths_main_b, None).count(),
8894 1,
8895 "main thread should now be under [/project, /project-b]"
8896 );
8897 assert_eq!(
8898 store.entries_for_path(&folder_paths_wt, None).count(),
8899 1,
8900 "worktree thread should remain unchanged under [/wt-feature]"
8901 );
8902 });
8903
8904 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8905 cx.run_until_parked();
8906 assert_eq!(
8907 visible_entries_as_strings(&sidebar, cx),
8908 vec![
8909 "v [project]",
8910 " Worktree Thread {wt-feature}",
8911 "v [project, project-b]",
8912 " Main Thread",
8913 ]
8914 );
8915}
8916
8917#[gpui::test]
8918async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
8919 cx: &mut TestAppContext,
8920) {
8921 // When a linked worktree is opened as its own workspace and then a new
8922 // folder is added to the main project group, the linked worktree
8923 // workspace must still be reachable from some sidebar entry.
8924 let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
8925 let fs = _fs.clone();
8926
8927 // Set up git worktree infrastructure.
8928 fs.insert_tree(
8929 "/my-project/.git/worktrees/wt-0",
8930 serde_json::json!({
8931 "commondir": "../../",
8932 "HEAD": "ref: refs/heads/wt-0",
8933 }),
8934 )
8935 .await;
8936 fs.insert_tree(
8937 "/worktrees/wt-0",
8938 serde_json::json!({
8939 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8940 "src": {},
8941 }),
8942 )
8943 .await;
8944 fs.add_linked_worktree_for_repo(
8945 Path::new("/my-project/.git"),
8946 false,
8947 git::repository::Worktree {
8948 path: PathBuf::from("/worktrees/wt-0"),
8949 ref_name: Some("refs/heads/wt-0".into()),
8950 sha: "aaa".into(),
8951 is_main: false,
8952 is_bare: false,
8953 },
8954 )
8955 .await;
8956
8957 // Re-scan so the main project discovers the linked worktree.
8958 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8959
8960 let (multi_workspace, cx) =
8961 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8962 let sidebar = setup_sidebar(&multi_workspace, cx);
8963
8964 // Open the linked worktree as its own workspace.
8965 let worktree_project = project::Project::test(
8966 fs.clone() as Arc<dyn fs::Fs>,
8967 ["/worktrees/wt-0".as_ref()],
8968 cx,
8969 )
8970 .await;
8971 worktree_project
8972 .update(cx, |p, cx| p.git_scans_complete(cx))
8973 .await;
8974 multi_workspace.update_in(cx, |mw, window, cx| {
8975 mw.test_add_workspace(worktree_project.clone(), window, cx);
8976 });
8977 cx.run_until_parked();
8978
8979 // Both workspaces should be reachable.
8980 let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
8981 assert_eq!(workspace_count, 2, "should have 2 workspaces");
8982
8983 // Add a new folder to the main project, changing the project group key.
8984 fs.insert_tree(
8985 "/other-project",
8986 serde_json::json!({ ".git": {}, "src": {} }),
8987 )
8988 .await;
8989 project
8990 .update(cx, |project, cx| {
8991 project.find_or_create_worktree("/other-project", true, cx)
8992 })
8993 .await
8994 .expect("should add worktree");
8995 cx.run_until_parked();
8996
8997 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8998 cx.run_until_parked();
8999
9000 // The linked worktree workspace must still be reachable.
9001 let entries = visible_entries_as_strings(&sidebar, cx);
9002 let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
9003 mw.workspaces().map(|ws| ws.entity_id()).collect()
9004 });
9005 sidebar.read_with(cx, |sidebar, cx| {
9006 let multi_workspace = multi_workspace.read(cx);
9007 let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
9008 .contents
9009 .entries
9010 .iter()
9011 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
9012 .map(|ws| ws.entity_id())
9013 .collect();
9014 let all: std::collections::HashSet<gpui::EntityId> =
9015 mw_workspaces.iter().copied().collect();
9016 let unreachable = &all - &reachable;
9017 assert!(
9018 unreachable.is_empty(),
9019 "all workspaces should be reachable after adding folder; \
9020 unreachable: {:?}, entries: {:?}",
9021 unreachable,
9022 entries,
9023 );
9024 });
9025}
9026
9027mod property_test {
9028 use super::*;
9029 use gpui::proptest::prelude::*;
9030
9031 struct UnopenedWorktree {
9032 path: String,
9033 main_workspace_path: String,
9034 }
9035
9036 struct TestState {
9037 fs: Arc<FakeFs>,
9038 thread_counter: u32,
9039 workspace_counter: u32,
9040 worktree_counter: u32,
9041 saved_thread_ids: Vec<acp::SessionId>,
9042 unopened_worktrees: Vec<UnopenedWorktree>,
9043 }
9044
9045 impl TestState {
9046 fn new(fs: Arc<FakeFs>) -> Self {
9047 Self {
9048 fs,
9049 thread_counter: 0,
9050 workspace_counter: 1,
9051 worktree_counter: 0,
9052 saved_thread_ids: Vec::new(),
9053 unopened_worktrees: Vec::new(),
9054 }
9055 }
9056
9057 fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
9058 let id = self.thread_counter;
9059 self.thread_counter += 1;
9060 acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
9061 }
9062
9063 fn next_workspace_path(&mut self) -> String {
9064 let id = self.workspace_counter;
9065 self.workspace_counter += 1;
9066 format!("/prop-project-{id}")
9067 }
9068
9069 fn next_worktree_name(&mut self) -> String {
9070 let id = self.worktree_counter;
9071 self.worktree_counter += 1;
9072 format!("wt-{id}")
9073 }
9074 }
9075
9076 #[derive(Debug)]
9077 enum Operation {
9078 SaveThread { project_group_index: usize },
9079 SaveWorktreeThread { worktree_index: usize },
9080 ToggleAgentPanel,
9081 CreateDraftThread,
9082 AddProject { use_worktree: bool },
9083 ArchiveThread { index: usize },
9084 SwitchToThread { index: usize },
9085 SwitchToProjectGroup { index: usize },
9086 AddLinkedWorktree { project_group_index: usize },
9087 AddWorktreeToProject { project_group_index: usize },
9088 RemoveWorktreeFromProject { project_group_index: usize },
9089 }
9090
9091 // Distribution (out of 24 slots):
9092 // SaveThread: 5 slots (~21%)
9093 // SaveWorktreeThread: 2 slots (~8%)
9094 // ToggleAgentPanel: 1 slot (~4%)
9095 // CreateDraftThread: 1 slot (~4%)
9096 // AddProject: 1 slot (~4%)
9097 // ArchiveThread: 2 slots (~8%)
9098 // SwitchToThread: 2 slots (~8%)
9099 // SwitchToProjectGroup: 2 slots (~8%)
9100 // AddLinkedWorktree: 4 slots (~17%)
9101 // AddWorktreeToProject: 2 slots (~8%)
9102 // RemoveWorktreeFromProject: 2 slots (~8%)
9103 const DISTRIBUTION_SLOTS: u32 = 24;
9104
9105 impl TestState {
9106 fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
9107 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
9108
9109 match raw % DISTRIBUTION_SLOTS {
9110 0..=4 => Operation::SaveThread {
9111 project_group_index: extra % project_group_count,
9112 },
9113 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
9114 worktree_index: extra % self.unopened_worktrees.len(),
9115 },
9116 5..=6 => Operation::SaveThread {
9117 project_group_index: extra % project_group_count,
9118 },
9119 7 => Operation::ToggleAgentPanel,
9120 8 => Operation::CreateDraftThread,
9121 9 => Operation::AddProject {
9122 use_worktree: !self.unopened_worktrees.is_empty(),
9123 },
9124 10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
9125 index: extra % self.saved_thread_ids.len(),
9126 },
9127 10..=11 => Operation::AddProject {
9128 use_worktree: !self.unopened_worktrees.is_empty(),
9129 },
9130 12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
9131 index: extra % self.saved_thread_ids.len(),
9132 },
9133 12..=13 => Operation::SwitchToProjectGroup {
9134 index: extra % project_group_count,
9135 },
9136 14..=15 => Operation::SwitchToProjectGroup {
9137 index: extra % project_group_count,
9138 },
9139 16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
9140 project_group_index: extra % project_group_count,
9141 },
9142 16..=19 => Operation::SaveThread {
9143 project_group_index: extra % project_group_count,
9144 },
9145 20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
9146 project_group_index: extra % project_group_count,
9147 },
9148 20..=21 => Operation::SaveThread {
9149 project_group_index: extra % project_group_count,
9150 },
9151 22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
9152 project_group_index: extra % project_group_count,
9153 },
9154 22..=23 => Operation::SaveThread {
9155 project_group_index: extra % project_group_count,
9156 },
9157 _ => unreachable!(),
9158 }
9159 }
9160 }
9161
9162 fn save_thread_to_path_with_main(
9163 state: &mut TestState,
9164 path_list: PathList,
9165 main_worktree_paths: PathList,
9166 cx: &mut gpui::VisualTestContext,
9167 ) {
9168 let session_id = state.next_metadata_only_thread_id();
9169 let title: SharedString = format!("Thread {}", session_id).into();
9170 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9171 .unwrap()
9172 + chrono::Duration::seconds(state.thread_counter as i64);
9173 let metadata = ThreadMetadata {
9174 thread_id: ThreadId::new(),
9175 session_id: Some(session_id),
9176 agent_id: agent::ZED_AGENT_ID.clone(),
9177 title: Some(title),
9178 updated_at,
9179 created_at: None,
9180 interacted_at: None,
9181 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
9182 archived: false,
9183 remote_connection: None,
9184 };
9185 cx.update(|_, cx| {
9186 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
9187 });
9188 cx.run_until_parked();
9189 }
9190
9191 async fn perform_operation(
9192 operation: Operation,
9193 state: &mut TestState,
9194 multi_workspace: &Entity<MultiWorkspace>,
9195 sidebar: &Entity<Sidebar>,
9196 cx: &mut gpui::VisualTestContext,
9197 ) {
9198 match operation {
9199 Operation::SaveThread {
9200 project_group_index,
9201 } => {
9202 // Find a workspace for this project group and create a real
9203 // thread via its agent panel.
9204 let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
9205 let keys = mw.project_group_keys();
9206 let key = &keys[project_group_index];
9207 let ws = mw
9208 .workspaces_for_project_group(key, cx)
9209 .and_then(|ws| ws.first().cloned())
9210 .unwrap_or_else(|| mw.workspace().clone());
9211 let project = ws.read(cx).project().clone();
9212 (ws, project)
9213 });
9214
9215 let panel =
9216 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9217 if let Some(panel) = panel {
9218 let connection = StubAgentConnection::new();
9219 connection.set_next_prompt_updates(vec![
9220 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
9221 "Done".into(),
9222 )),
9223 ]);
9224 open_thread_with_connection(&panel, connection, cx);
9225 send_message(&panel, cx);
9226 let session_id = active_session_id(&panel, cx);
9227 state.saved_thread_ids.push(session_id.clone());
9228
9229 let title: SharedString = format!("Thread {}", state.thread_counter).into();
9230 state.thread_counter += 1;
9231 let updated_at =
9232 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9233 .unwrap()
9234 + chrono::Duration::seconds(state.thread_counter as i64);
9235 save_thread_metadata(
9236 session_id,
9237 Some(title),
9238 updated_at,
9239 None,
9240 None,
9241 &project,
9242 cx,
9243 );
9244 }
9245 }
9246 Operation::SaveWorktreeThread { worktree_index } => {
9247 let worktree = &state.unopened_worktrees[worktree_index];
9248 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
9249 let main_worktree_paths =
9250 PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
9251 save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
9252 }
9253
9254 Operation::ToggleAgentPanel => {
9255 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9256 let panel_open =
9257 workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
9258 workspace.update_in(cx, |workspace, window, cx| {
9259 if panel_open {
9260 workspace.close_panel::<AgentPanel>(window, cx);
9261 } else {
9262 workspace.open_panel::<AgentPanel>(window, cx);
9263 }
9264 });
9265 }
9266 Operation::CreateDraftThread => {
9267 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9268 let panel =
9269 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9270 if let Some(panel) = panel {
9271 panel.update_in(cx, |panel, window, cx| {
9272 panel.new_thread(&NewThread, window, cx);
9273 });
9274 cx.run_until_parked();
9275 }
9276 workspace.update_in(cx, |workspace, window, cx| {
9277 workspace.focus_panel::<AgentPanel>(window, cx);
9278 });
9279 }
9280 Operation::AddProject { use_worktree } => {
9281 let path = if use_worktree {
9282 // Open an existing linked worktree as a project (simulates Cmd+O
9283 // on a worktree directory).
9284 state.unopened_worktrees.remove(0).path
9285 } else {
9286 // Create a brand new project.
9287 let path = state.next_workspace_path();
9288 state
9289 .fs
9290 .insert_tree(
9291 &path,
9292 serde_json::json!({
9293 ".git": {},
9294 "src": {},
9295 }),
9296 )
9297 .await;
9298 path
9299 };
9300 let project = project::Project::test(
9301 state.fs.clone() as Arc<dyn fs::Fs>,
9302 [path.as_ref()],
9303 cx,
9304 )
9305 .await;
9306 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9307 multi_workspace.update_in(cx, |mw, window, cx| {
9308 mw.test_add_workspace(project.clone(), window, cx)
9309 });
9310 }
9311
9312 Operation::ArchiveThread { index } => {
9313 let session_id = state.saved_thread_ids[index].clone();
9314 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
9315 sidebar.archive_thread(&session_id, window, cx);
9316 });
9317 cx.run_until_parked();
9318 state.saved_thread_ids.remove(index);
9319 }
9320 Operation::SwitchToThread { index } => {
9321 let session_id = state.saved_thread_ids[index].clone();
9322 // Find the thread's position in the sidebar entries and select it.
9323 let thread_index = sidebar.read_with(cx, |sidebar, _| {
9324 sidebar.contents.entries.iter().position(|entry| {
9325 matches!(
9326 entry,
9327 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
9328 )
9329 })
9330 });
9331 if let Some(ix) = thread_index {
9332 sidebar.update_in(cx, |sidebar, window, cx| {
9333 sidebar.selection = Some(ix);
9334 sidebar.confirm(&Confirm, window, cx);
9335 });
9336 cx.run_until_parked();
9337 }
9338 }
9339 Operation::SwitchToProjectGroup { index } => {
9340 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9341 let keys = mw.project_group_keys();
9342 let key = &keys[index];
9343 mw.workspaces_for_project_group(key, cx)
9344 .and_then(|ws| ws.first().cloned())
9345 .unwrap_or_else(|| mw.workspace().clone())
9346 });
9347 multi_workspace.update_in(cx, |mw, window, cx| {
9348 mw.activate(workspace, None, window, cx);
9349 });
9350 }
9351 Operation::AddLinkedWorktree {
9352 project_group_index,
9353 } => {
9354 // Get the main worktree path from the project group key.
9355 let main_path = multi_workspace.read_with(cx, |mw, _| {
9356 let keys = mw.project_group_keys();
9357 let key = &keys[project_group_index];
9358 key.path_list()
9359 .paths()
9360 .first()
9361 .unwrap()
9362 .to_string_lossy()
9363 .to_string()
9364 });
9365 let dot_git = format!("{}/.git", main_path);
9366 let worktree_name = state.next_worktree_name();
9367 let worktree_path = format!("/worktrees/{}", worktree_name);
9368
9369 state.fs
9370 .insert_tree(
9371 &worktree_path,
9372 serde_json::json!({
9373 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
9374 "src": {},
9375 }),
9376 )
9377 .await;
9378
9379 // Also create the worktree metadata dir inside the main repo's .git
9380 state
9381 .fs
9382 .insert_tree(
9383 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
9384 serde_json::json!({
9385 "commondir": "../../",
9386 "HEAD": format!("ref: refs/heads/{}", worktree_name),
9387 }),
9388 )
9389 .await;
9390
9391 let dot_git_path = std::path::Path::new(&dot_git);
9392 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
9393 state
9394 .fs
9395 .add_linked_worktree_for_repo(
9396 dot_git_path,
9397 false,
9398 git::repository::Worktree {
9399 path: worktree_pathbuf,
9400 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
9401 sha: "aaa".into(),
9402 is_main: false,
9403 is_bare: false,
9404 },
9405 )
9406 .await;
9407
9408 // Re-scan the main workspace's project so it discovers the new worktree.
9409 let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
9410 let keys = mw.project_group_keys();
9411 let key = &keys[project_group_index];
9412 mw.workspaces_for_project_group(key, cx)
9413 .and_then(|ws| ws.first().cloned())
9414 .unwrap()
9415 });
9416 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
9417 main_project
9418 .update(cx, |p, cx| p.git_scans_complete(cx))
9419 .await;
9420
9421 state.unopened_worktrees.push(UnopenedWorktree {
9422 path: worktree_path,
9423 main_workspace_path: main_path.clone(),
9424 });
9425 }
9426 Operation::AddWorktreeToProject {
9427 project_group_index,
9428 } => {
9429 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9430 let keys = mw.project_group_keys();
9431 let key = &keys[project_group_index];
9432 mw.workspaces_for_project_group(key, cx)
9433 .and_then(|ws| ws.first().cloned())
9434 });
9435 let Some(workspace) = workspace else { return };
9436 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9437
9438 let new_path = state.next_workspace_path();
9439 state
9440 .fs
9441 .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
9442 .await;
9443
9444 let result = project
9445 .update(cx, |project, cx| {
9446 project.find_or_create_worktree(&new_path, true, cx)
9447 })
9448 .await;
9449 if result.is_err() {
9450 return;
9451 }
9452 cx.run_until_parked();
9453 }
9454 Operation::RemoveWorktreeFromProject {
9455 project_group_index,
9456 } => {
9457 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9458 let keys = mw.project_group_keys();
9459 let key = &keys[project_group_index];
9460 mw.workspaces_for_project_group(key, cx)
9461 .and_then(|ws| ws.first().cloned())
9462 });
9463 let Some(workspace) = workspace else { return };
9464 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9465
9466 let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
9467 if worktree_count <= 1 {
9468 return;
9469 }
9470
9471 let worktree_id = project.read_with(cx, |p, cx| {
9472 p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
9473 });
9474 if let Some(worktree_id) = worktree_id {
9475 project.update(cx, |project, cx| {
9476 project.remove_worktree(worktree_id, cx);
9477 });
9478 cx.run_until_parked();
9479 }
9480 }
9481 }
9482 }
9483
9484 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
9485 sidebar.update_in(cx, |sidebar, _window, cx| {
9486 if let Some(mw) = sidebar.multi_workspace.upgrade() {
9487 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
9488 }
9489 sidebar.update_entries(cx);
9490 });
9491 }
9492
9493 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9494 verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
9495 verify_no_duplicate_threads(sidebar)?;
9496 verify_all_threads_are_shown(sidebar, cx)?;
9497 verify_active_state_matches_current_workspace(sidebar, cx)?;
9498 verify_all_workspaces_are_reachable(sidebar, cx)?;
9499 verify_workspace_group_key_integrity(sidebar, cx)?;
9500 Ok(())
9501 }
9502
9503 fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
9504 let mut seen: HashSet<acp::SessionId> = HashSet::default();
9505 let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
9506
9507 for entry in &sidebar.contents.entries {
9508 if let Some(session_id) = entry.session_id() {
9509 if !seen.insert(session_id.clone()) {
9510 let title = match entry {
9511 ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
9512 _ => "<unknown>".to_string(),
9513 };
9514 duplicates.push((session_id.clone(), title));
9515 }
9516 }
9517 }
9518
9519 anyhow::ensure!(
9520 duplicates.is_empty(),
9521 "threads appear more than once in sidebar: {:?}",
9522 duplicates,
9523 );
9524 Ok(())
9525 }
9526
9527 fn verify_every_group_in_multiworkspace_is_shown(
9528 sidebar: &Sidebar,
9529 cx: &App,
9530 ) -> anyhow::Result<()> {
9531 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9532 anyhow::bail!("sidebar should still have an associated multi-workspace");
9533 };
9534
9535 let mw = multi_workspace.read(cx);
9536
9537 // Every project group key in the multi-workspace that has a
9538 // non-empty path list should appear as a ProjectHeader in the
9539 // sidebar.
9540 let all_keys = mw.project_group_keys();
9541 let expected_keys: HashSet<&ProjectGroupKey> = all_keys
9542 .iter()
9543 .filter(|k| !k.path_list().paths().is_empty())
9544 .collect();
9545
9546 let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
9547 .contents
9548 .entries
9549 .iter()
9550 .filter_map(|entry| match entry {
9551 ListEntry::ProjectHeader { key, .. } => Some(key),
9552 _ => None,
9553 })
9554 .collect();
9555
9556 let missing = &expected_keys - &sidebar_keys;
9557 let stray = &sidebar_keys - &expected_keys;
9558
9559 anyhow::ensure!(
9560 missing.is_empty() && stray.is_empty(),
9561 "sidebar project groups don't match multi-workspace.\n\
9562 Only in multi-workspace (missing): {:?}\n\
9563 Only in sidebar (stray): {:?}",
9564 missing,
9565 stray,
9566 );
9567
9568 Ok(())
9569 }
9570
9571 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9572 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9573 anyhow::bail!("sidebar should still have an associated multi-workspace");
9574 };
9575 let workspaces = multi_workspace
9576 .read(cx)
9577 .workspaces()
9578 .cloned()
9579 .collect::<Vec<_>>();
9580 let thread_store = ThreadMetadataStore::global(cx);
9581
9582 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
9583 .contents
9584 .entries
9585 .iter()
9586 .filter_map(|entry| entry.session_id().cloned())
9587 .collect();
9588
9589 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
9590
9591 // Query using the same approach as the sidebar: iterate project
9592 // group keys, then do main + legacy queries per group.
9593 let mw = multi_workspace.read(cx);
9594 let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
9595 HashMap::default();
9596 for workspace in &workspaces {
9597 let key = workspace.read(cx).project_group_key(cx);
9598 workspaces_by_group
9599 .entry(key)
9600 .or_default()
9601 .push(workspace.clone());
9602 }
9603
9604 for group_key in mw.project_group_keys() {
9605 let path_list = group_key.path_list().clone();
9606 if path_list.paths().is_empty() {
9607 continue;
9608 }
9609
9610 let group_workspaces = workspaces_by_group
9611 .get(&group_key)
9612 .map(|ws| ws.as_slice())
9613 .unwrap_or_default();
9614
9615 // Main code path queries (run for all groups, even without workspaces).
9616 // Skip drafts (session_id: None) — they are not shown in the
9617 // sidebar entries.
9618 for metadata in thread_store
9619 .read(cx)
9620 .entries_for_main_worktree_path(&path_list, None)
9621 {
9622 if let Some(sid) = metadata.session_id.clone() {
9623 metadata_thread_ids.insert(sid);
9624 }
9625 }
9626 for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
9627 if let Some(sid) = metadata.session_id.clone() {
9628 metadata_thread_ids.insert(sid);
9629 }
9630 }
9631
9632 // Legacy: per-workspace queries for different root paths.
9633 let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
9634 .iter()
9635 .flat_map(|ws| {
9636 ws.read(cx)
9637 .root_paths(cx)
9638 .into_iter()
9639 .map(|p| p.to_path_buf())
9640 })
9641 .collect();
9642
9643 for workspace in group_workspaces {
9644 let ws_path_list = workspace_path_list(workspace, cx);
9645 if ws_path_list != path_list {
9646 for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
9647 if let Some(sid) = metadata.session_id.clone() {
9648 metadata_thread_ids.insert(sid);
9649 }
9650 }
9651 }
9652 }
9653
9654 for workspace in group_workspaces {
9655 for snapshot in root_repository_snapshots(workspace, cx) {
9656 let repo_path_list =
9657 PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
9658 if repo_path_list != path_list {
9659 continue;
9660 }
9661 for linked_worktree in snapshot.linked_worktrees() {
9662 if covered_paths.contains(&*linked_worktree.path) {
9663 continue;
9664 }
9665 let worktree_path_list =
9666 PathList::new(std::slice::from_ref(&linked_worktree.path));
9667 for metadata in thread_store
9668 .read(cx)
9669 .entries_for_path(&worktree_path_list, None)
9670 {
9671 if let Some(sid) = metadata.session_id.clone() {
9672 metadata_thread_ids.insert(sid);
9673 }
9674 }
9675 }
9676 }
9677 }
9678 }
9679
9680 anyhow::ensure!(
9681 sidebar_thread_ids == metadata_thread_ids,
9682 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
9683 sidebar_thread_ids,
9684 metadata_thread_ids,
9685 );
9686 Ok(())
9687 }
9688
9689 fn verify_active_state_matches_current_workspace(
9690 sidebar: &Sidebar,
9691 cx: &App,
9692 ) -> anyhow::Result<()> {
9693 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9694 anyhow::bail!("sidebar should still have an associated multi-workspace");
9695 };
9696
9697 let active_workspace = multi_workspace.read(cx).workspace();
9698
9699 // 1. active_entry should be Some when the panel has content.
9700 // It may be None when the panel is uninitialized (no drafts,
9701 // no threads), which is fine.
9702 // It may also temporarily point at a different workspace
9703 // when the workspace just changed and the new panel has no
9704 // content yet.
9705 let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
9706 let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
9707 || panel.read(cx).active_conversation_view().is_some();
9708
9709 let Some(entry) = sidebar.active_entry.as_ref() else {
9710 if panel_has_content {
9711 anyhow::bail!("active_entry is None but panel has content (draft or thread)");
9712 }
9713 return Ok(());
9714 };
9715
9716 // If the entry workspace doesn't match the active workspace
9717 // and the panel has no content, this is a transient state that
9718 // will resolve when the panel gets content.
9719 if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
9720 return Ok(());
9721 }
9722
9723 // 2. The entry's workspace must agree with the multi-workspace's
9724 // active workspace.
9725 anyhow::ensure!(
9726 entry.workspace().entity_id() == active_workspace.entity_id(),
9727 "active_entry workspace ({:?}) != active workspace ({:?})",
9728 entry.workspace().entity_id(),
9729 active_workspace.entity_id(),
9730 );
9731
9732 // 3. The entry must match the agent panel's current state.
9733 if panel.read(cx).active_thread_id(cx).is_some() {
9734 anyhow::ensure!(
9735 matches!(entry, ActiveEntry { .. }),
9736 "panel shows a tracked draft but active_entry is {:?}",
9737 entry,
9738 );
9739 } else if let Some(thread_id) = panel
9740 .read(cx)
9741 .active_conversation_view()
9742 .map(|cv| cv.read(cx).parent_id())
9743 {
9744 anyhow::ensure!(
9745 matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
9746 "panel has thread {:?} but active_entry is {:?}",
9747 thread_id,
9748 entry,
9749 );
9750 }
9751
9752 // 4. Exactly one entry in sidebar contents must be uniquely
9753 // identified by the active_entry — unless the panel is showing
9754 // a draft, which is represented by the + button's active state
9755 // rather than a sidebar row.
9756 // TODO: Make this check more complete
9757 let is_draft = panel.read(cx).active_thread_is_draft(cx)
9758 || panel.read(cx).active_conversation_view().is_none();
9759 if is_draft {
9760 return Ok(());
9761 }
9762 let matching_count = sidebar
9763 .contents
9764 .entries
9765 .iter()
9766 .filter(|e| entry.matches_entry(e))
9767 .count();
9768 if matching_count != 1 {
9769 let thread_entries: Vec<_> = sidebar
9770 .contents
9771 .entries
9772 .iter()
9773 .filter_map(|e| match e {
9774 ListEntry::Thread(t) => Some(format!(
9775 "tid={:?} sid={:?}",
9776 t.metadata.thread_id, t.metadata.session_id
9777 )),
9778 _ => None,
9779 })
9780 .collect();
9781 let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
9782 let store_entries: Vec<_> = store
9783 .entries()
9784 .map(|m| {
9785 format!(
9786 "tid={:?} sid={:?} archived={} paths={:?}",
9787 m.thread_id,
9788 m.session_id,
9789 m.archived,
9790 m.folder_paths()
9791 )
9792 })
9793 .collect();
9794 anyhow::bail!(
9795 "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
9796 entry,
9797 matching_count,
9798 thread_entries,
9799 store_entries,
9800 );
9801 }
9802
9803 Ok(())
9804 }
9805
9806 /// Every workspace in the multi-workspace should be "reachable" from
9807 /// the sidebar — meaning there is at least one entry (thread, draft,
9808 /// new-thread, or project header) that, when clicked, would activate
9809 /// that workspace.
9810 fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9811 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9812 anyhow::bail!("sidebar should still have an associated multi-workspace");
9813 };
9814
9815 let multi_workspace = multi_workspace.read(cx);
9816
9817 let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
9818 .contents
9819 .entries
9820 .iter()
9821 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
9822 .map(|ws| ws.entity_id())
9823 .collect();
9824
9825 let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
9826 .workspaces()
9827 .map(|ws| ws.entity_id())
9828 .collect();
9829
9830 let unreachable = &all_workspace_ids - &reachable_workspaces;
9831
9832 anyhow::ensure!(
9833 unreachable.is_empty(),
9834 "The following workspaces are not reachable from any sidebar entry: {:?}",
9835 unreachable,
9836 );
9837
9838 Ok(())
9839 }
9840
9841 fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9842 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9843 anyhow::bail!("sidebar should still have an associated multi-workspace");
9844 };
9845 multi_workspace
9846 .read(cx)
9847 .assert_project_group_key_integrity(cx)
9848 }
9849
9850 #[gpui::property_test(config = ProptestConfig {
9851 cases: 20,
9852 ..Default::default()
9853 })]
9854 async fn test_sidebar_invariants(
9855 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
9856 raw_operations: Vec<u32>,
9857 cx: &mut TestAppContext,
9858 ) {
9859 use std::sync::atomic::{AtomicUsize, Ordering};
9860 static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
9861
9862 agent_ui::test_support::init_test(cx);
9863 cx.update(|cx| {
9864 cx.set_global(db::AppDatabase::test_new());
9865 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
9866 cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
9867 format!(
9868 "PROPTEST_THREAD_METADATA_{}",
9869 NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
9870 ),
9871 ));
9872
9873 ThreadStore::init_global(cx);
9874 ThreadMetadataStore::init_global(cx);
9875 language_model::LanguageModelRegistry::test(cx);
9876 prompt_store::init(cx);
9877
9878 // Auto-add an AgentPanel to every workspace so that implicitly
9879 // created workspaces (e.g. from thread activation) also have one.
9880 cx.observe_new(
9881 |workspace: &mut Workspace,
9882 window: Option<&mut Window>,
9883 cx: &mut gpui::Context<Workspace>| {
9884 if let Some(window) = window {
9885 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
9886 workspace.add_panel(panel, window, cx);
9887 }
9888 },
9889 )
9890 .detach();
9891 });
9892
9893 let fs = FakeFs::new(cx.executor());
9894 fs.insert_tree(
9895 "/my-project",
9896 serde_json::json!({
9897 ".git": {},
9898 "src": {},
9899 }),
9900 )
9901 .await;
9902 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
9903 let project =
9904 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
9905 .await;
9906 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9907
9908 let (multi_workspace, cx) =
9909 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9910 let sidebar = setup_sidebar(&multi_workspace, cx);
9911
9912 let mut state = TestState::new(fs);
9913 let mut executed: Vec<String> = Vec::new();
9914
9915 for &raw_op in &raw_operations {
9916 let project_group_count =
9917 multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
9918 let operation = state.generate_operation(raw_op, project_group_count);
9919 executed.push(format!("{:?}", operation));
9920 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
9921 cx.run_until_parked();
9922
9923 update_sidebar(&sidebar, cx);
9924 cx.run_until_parked();
9925
9926 let result =
9927 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
9928 if let Err(err) = result {
9929 let log = executed.join("\n ");
9930 panic!(
9931 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
9932 executed.len(),
9933 );
9934 }
9935 }
9936 }
9937}
9938
9939#[gpui::test]
9940async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
9941 cx: &mut TestAppContext,
9942 server_cx: &mut TestAppContext,
9943) {
9944 init_test(cx);
9945
9946 cx.update(|cx| {
9947 release_channel::init(semver::Version::new(0, 0, 0), cx);
9948 });
9949
9950 let app_state = cx.update(|cx| {
9951 let app_state = workspace::AppState::test(cx);
9952 workspace::init(app_state.clone(), cx);
9953 app_state
9954 });
9955
9956 // Set up the remote server side.
9957 let server_fs = FakeFs::new(server_cx.executor());
9958 server_fs
9959 .insert_tree(
9960 "/project",
9961 serde_json::json!({
9962 ".git": {},
9963 "src": { "main.rs": "fn main() {}" }
9964 }),
9965 )
9966 .await;
9967 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
9968
9969 // Create the linked worktree checkout path on the remote server,
9970 // but do not yet register it as a git-linked worktree. The real
9971 // regrouping update in this test should happen only after the
9972 // sidebar opens the closed remote thread.
9973 server_fs
9974 .insert_tree(
9975 "/project-wt-1",
9976 serde_json::json!({
9977 "src": { "main.rs": "fn main() {}" }
9978 }),
9979 )
9980 .await;
9981
9982 server_cx.update(|cx| {
9983 release_channel::init(semver::Version::new(0, 0, 0), cx);
9984 });
9985
9986 let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
9987
9988 server_cx.update(remote_server::HeadlessProject::init);
9989 let server_executor = server_cx.executor();
9990 let _headless = server_cx.new(|cx| {
9991 remote_server::HeadlessProject::new(
9992 remote_server::HeadlessAppState {
9993 session: server_session,
9994 fs: server_fs.clone(),
9995 http_client: Arc::new(http_client::BlockedHttpClient),
9996 node_runtime: node_runtime::NodeRuntime::unavailable(),
9997 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
9998 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
9999 startup_time: std::time::Instant::now(),
10000 },
10001 false,
10002 cx,
10003 )
10004 });
10005
10006 // Connect the client side and build a remote project.
10007 let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
10008 let project = cx.update(|cx| {
10009 let project_client = client::Client::new(
10010 Arc::new(clock::FakeSystemClock::new()),
10011 http_client::FakeHttpClient::with_404_response(),
10012 cx,
10013 );
10014 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
10015 project::Project::remote(
10016 remote_client,
10017 project_client,
10018 node_runtime::NodeRuntime::unavailable(),
10019 user_store,
10020 app_state.languages.clone(),
10021 app_state.fs.clone(),
10022 false,
10023 cx,
10024 )
10025 });
10026
10027 // Open the remote worktree.
10028 project
10029 .update(cx, |project, cx| {
10030 project.find_or_create_worktree(Path::new("/project"), true, cx)
10031 })
10032 .await
10033 .expect("should open remote worktree");
10034 cx.run_until_parked();
10035
10036 // Verify the project is remote.
10037 project.read_with(cx, |project, cx| {
10038 assert!(!project.is_local(), "project should be remote");
10039 assert!(
10040 project.remote_connection_options(cx).is_some(),
10041 "project should have remote connection options"
10042 );
10043 });
10044
10045 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10046
10047 // Create MultiWorkspace with the remote project.
10048 let (multi_workspace, cx) =
10049 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10050 let sidebar = setup_sidebar(&multi_workspace, cx);
10051
10052 cx.run_until_parked();
10053
10054 // Save a thread for the main remote workspace (folder_paths match
10055 // the open workspace, so it will be classified as Open).
10056 let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
10057 save_thread_metadata(
10058 main_thread_id.clone(),
10059 Some("Main Thread".into()),
10060 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10061 None,
10062 None,
10063 &project,
10064 cx,
10065 );
10066 cx.run_until_parked();
10067
10068 // Save a thread whose folder_paths point to a linked worktree path
10069 // that doesn't have an open workspace ("/project-wt-1"), but whose
10070 // main_worktree_paths match the project group key so it appears
10071 // in the sidebar under the same remote group. This simulates a
10072 // linked worktree workspace that was closed.
10073 let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10074 let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
10075 (
10076 p.project_group_key(cx).path_list().clone(),
10077 p.remote_connection_options(cx),
10078 )
10079 });
10080 cx.update(|_window, cx| {
10081 let metadata = ThreadMetadata {
10082 thread_id: ThreadId::new(),
10083 session_id: Some(remote_thread_id.clone()),
10084 agent_id: agent::ZED_AGENT_ID.clone(),
10085 title: Some("Worktree Thread".into()),
10086 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10087 created_at: None,
10088 interacted_at: None,
10089 worktree_paths: WorktreePaths::from_path_lists(
10090 main_worktree_paths,
10091 PathList::new(&[PathBuf::from("/project-wt-1")]),
10092 )
10093 .unwrap(),
10094 archived: false,
10095 remote_connection,
10096 };
10097 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10098 });
10099 cx.run_until_parked();
10100
10101 focus_sidebar(&sidebar, cx);
10102 sidebar.update_in(cx, |sidebar, _window, _cx| {
10103 sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10104 matches!(
10105 entry,
10106 ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10107 )
10108 });
10109 });
10110
10111 let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10112 let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10113
10114 sidebar
10115 .update(cx, |_, cx| {
10116 cx.observe_self(move |sidebar, _cx| {
10117 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10118 if let ListEntry::ProjectHeader { label, .. } = entry {
10119 Some(label.as_ref())
10120 } else {
10121 None
10122 }
10123 });
10124
10125 let Some(project_header) = project_headers.next() else {
10126 saw_separate_project_header_for_observer
10127 .store(true, std::sync::atomic::Ordering::SeqCst);
10128 return;
10129 };
10130
10131 if project_header != "project" || project_headers.next().is_some() {
10132 saw_separate_project_header_for_observer
10133 .store(true, std::sync::atomic::Ordering::SeqCst);
10134 }
10135 })
10136 })
10137 .detach();
10138
10139 multi_workspace.update(cx, |multi_workspace, cx| {
10140 let workspace = multi_workspace.workspace().clone();
10141 workspace.update(cx, |workspace: &mut Workspace, cx| {
10142 let remote_client = workspace
10143 .project()
10144 .read(cx)
10145 .remote_client()
10146 .expect("main remote project should have a remote client");
10147 remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10148 remote_client.force_server_not_running(cx);
10149 });
10150 });
10151 });
10152 cx.run_until_parked();
10153
10154 let (server_session_2, connect_guard_2) =
10155 remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10156 let _headless_2 = server_cx.new(|cx| {
10157 remote_server::HeadlessProject::new(
10158 remote_server::HeadlessAppState {
10159 session: server_session_2,
10160 fs: server_fs.clone(),
10161 http_client: Arc::new(http_client::BlockedHttpClient),
10162 node_runtime: node_runtime::NodeRuntime::unavailable(),
10163 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10164 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10165 startup_time: std::time::Instant::now(),
10166 },
10167 false,
10168 cx,
10169 )
10170 });
10171 drop(connect_guard_2);
10172
10173 let window = cx.windows()[0];
10174 cx.update_window(window, |_, window, cx| {
10175 window.dispatch_action(Confirm.boxed_clone(), cx);
10176 })
10177 .unwrap();
10178
10179 cx.run_until_parked();
10180
10181 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10182 assert_eq!(
10183 mw.workspaces().count(),
10184 2,
10185 "confirming a closed remote thread should open a second workspace"
10186 );
10187 mw.workspaces()
10188 .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10189 .unwrap()
10190 .clone()
10191 });
10192
10193 server_fs
10194 .add_linked_worktree_for_repo(
10195 Path::new("/project/.git"),
10196 true,
10197 git::repository::Worktree {
10198 path: PathBuf::from("/project-wt-1"),
10199 ref_name: Some("refs/heads/feature-wt".into()),
10200 sha: "abc123".into(),
10201 is_main: false,
10202 is_bare: false,
10203 },
10204 )
10205 .await;
10206
10207 server_cx.run_until_parked();
10208 cx.run_until_parked();
10209 server_cx.run_until_parked();
10210 cx.run_until_parked();
10211
10212 let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10213 let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10214 workspace.project().read(cx).project_group_key(cx)
10215 });
10216
10217 assert_eq!(
10218 group_after_update,
10219 project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10220 "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10221 final sidebar entries: {:?}",
10222 entries_after_update,
10223 );
10224
10225 sidebar.update(cx, |sidebar, _cx| {
10226 assert_remote_project_integration_sidebar_state(
10227 sidebar,
10228 &main_thread_id,
10229 &remote_thread_id,
10230 );
10231 });
10232
10233 assert!(
10234 !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10235 "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10236 final group: {:?}; final sidebar entries: {:?}",
10237 group_after_update,
10238 entries_after_update,
10239 );
10240}
10241
10242#[gpui::test]
10243async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10244 // When the thread's folder_paths don't exactly match any workspace's
10245 // root paths (e.g. because a folder was added to the workspace after
10246 // the thread was created), workspace_to_remove is None. But the linked
10247 // worktree workspace still needs to be removed so that its worktree
10248 // entities are released, allowing git worktree removal to proceed.
10249 //
10250 // With the fix, archive_thread scans roots_to_archive for any linked
10251 // worktree workspaces and includes them in the removal set, even when
10252 // the thread's folder_paths don't match the workspace's root paths.
10253 init_test(cx);
10254 let fs = FakeFs::new(cx.executor());
10255
10256 fs.insert_tree(
10257 "/project",
10258 serde_json::json!({
10259 ".git": {
10260 "worktrees": {
10261 "feature-a": {
10262 "commondir": "../../",
10263 "HEAD": "ref: refs/heads/feature-a",
10264 },
10265 },
10266 },
10267 "src": {},
10268 }),
10269 )
10270 .await;
10271
10272 fs.insert_tree(
10273 "/worktrees/project/feature-a/project",
10274 serde_json::json!({
10275 ".git": "gitdir: /project/.git/worktrees/feature-a",
10276 "src": {
10277 "main.rs": "fn main() {}",
10278 },
10279 }),
10280 )
10281 .await;
10282
10283 fs.add_linked_worktree_for_repo(
10284 Path::new("/project/.git"),
10285 false,
10286 git::repository::Worktree {
10287 path: PathBuf::from("/worktrees/project/feature-a/project"),
10288 ref_name: Some("refs/heads/feature-a".into()),
10289 sha: "abc".into(),
10290 is_main: false,
10291 is_bare: false,
10292 },
10293 )
10294 .await;
10295
10296 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10297
10298 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10299 let worktree_project = project::Project::test(
10300 fs.clone(),
10301 ["/worktrees/project/feature-a/project".as_ref()],
10302 cx,
10303 )
10304 .await;
10305
10306 main_project
10307 .update(cx, |p, cx| p.git_scans_complete(cx))
10308 .await;
10309 worktree_project
10310 .update(cx, |p, cx| p.git_scans_complete(cx))
10311 .await;
10312
10313 let (multi_workspace, cx) =
10314 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10315 let sidebar = setup_sidebar(&multi_workspace, cx);
10316
10317 multi_workspace.update_in(cx, |mw, window, cx| {
10318 mw.test_add_workspace(worktree_project.clone(), window, cx)
10319 });
10320
10321 // Save thread metadata using folder_paths that DON'T match the
10322 // workspace's root paths. This simulates the case where the workspace's
10323 // paths diverged (e.g. a folder was added after thread creation).
10324 // This causes workspace_to_remove to be None because
10325 // workspace_for_paths can't find a workspace with these exact paths.
10326 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10327 save_thread_metadata_with_main_paths(
10328 "worktree-thread",
10329 "Worktree Thread",
10330 PathList::new(&[
10331 PathBuf::from("/worktrees/project/feature-a/project"),
10332 PathBuf::from("/nonexistent"),
10333 ]),
10334 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10335 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10336 cx,
10337 );
10338
10339 // Also save a main thread so the sidebar has something to show.
10340 save_thread_metadata(
10341 acp::SessionId::new(Arc::from("main-thread")),
10342 Some("Main Thread".into()),
10343 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10344 None,
10345 None,
10346 &main_project,
10347 cx,
10348 );
10349 cx.run_until_parked();
10350
10351 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10352 cx.run_until_parked();
10353
10354 assert_eq!(
10355 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10356 2,
10357 "should start with 2 workspaces (main + linked worktree)"
10358 );
10359
10360 // Archive the worktree thread.
10361 sidebar.update_in(cx, |sidebar, window, cx| {
10362 sidebar.archive_thread(&wt_thread_id, window, cx);
10363 });
10364
10365 cx.run_until_parked();
10366
10367 // The linked worktree workspace should have been removed, even though
10368 // workspace_to_remove was None (paths didn't match).
10369 assert_eq!(
10370 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10371 1,
10372 "linked worktree workspace should be removed after archiving, \
10373 even when folder_paths don't match workspace root paths"
10374 );
10375
10376 // The thread should still be archived (not unarchived due to an error).
10377 let still_archived = cx.update(|_, cx| {
10378 ThreadMetadataStore::global(cx)
10379 .read(cx)
10380 .entry_by_session(&wt_thread_id)
10381 .map(|t| t.archived)
10382 });
10383 assert_eq!(
10384 still_archived,
10385 Some(true),
10386 "thread should still be archived (not rolled back due to error)"
10387 );
10388
10389 // The linked worktree directory should be removed from disk.
10390 assert!(
10391 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
10392 .await,
10393 "linked worktree directory should be removed from disk"
10394 );
10395}
10396
10397#[gpui::test]
10398async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10399 // When a workspace contains both a worktree being archived and other
10400 // worktrees that should remain, only the editor items referencing the
10401 // archived worktree should be closed — the workspace itself must be
10402 // preserved.
10403 init_test(cx);
10404 let fs = FakeFs::new(cx.executor());
10405
10406 fs.insert_tree(
10407 "/main-repo",
10408 serde_json::json!({
10409 ".git": {
10410 "worktrees": {
10411 "feature-b": {
10412 "commondir": "../../",
10413 "HEAD": "ref: refs/heads/feature-b",
10414 },
10415 },
10416 },
10417 "src": {
10418 "lib.rs": "pub fn hello() {}",
10419 },
10420 }),
10421 )
10422 .await;
10423
10424 fs.insert_tree(
10425 "/worktrees/main-repo/feature-b/main-repo",
10426 serde_json::json!({
10427 ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10428 "src": {
10429 "main.rs": "fn main() { hello(); }",
10430 },
10431 }),
10432 )
10433 .await;
10434
10435 fs.add_linked_worktree_for_repo(
10436 Path::new("/main-repo/.git"),
10437 false,
10438 git::repository::Worktree {
10439 path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10440 ref_name: Some("refs/heads/feature-b".into()),
10441 sha: "def".into(),
10442 is_main: false,
10443 is_bare: false,
10444 },
10445 )
10446 .await;
10447
10448 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10449
10450 // Create a single project that contains BOTH the main repo and the
10451 // linked worktree — this makes it a "mixed" workspace.
10452 let mixed_project = project::Project::test(
10453 fs.clone(),
10454 [
10455 "/main-repo".as_ref(),
10456 "/worktrees/main-repo/feature-b/main-repo".as_ref(),
10457 ],
10458 cx,
10459 )
10460 .await;
10461
10462 mixed_project
10463 .update(cx, |p, cx| p.git_scans_complete(cx))
10464 .await;
10465
10466 let (multi_workspace, cx) = cx
10467 .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10468 let sidebar = setup_sidebar(&multi_workspace, cx);
10469
10470 // Open editor items in both worktrees so we can verify which ones
10471 // get closed.
10472 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10473
10474 let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10475 ws.project()
10476 .read(cx)
10477 .visible_worktrees(cx)
10478 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10479 .collect()
10480 });
10481
10482 let main_repo_wt_id = worktree_ids
10483 .iter()
10484 .find(|(_, path)| path.as_ref() == Path::new("/main-repo"))
10485 .map(|(id, _)| *id)
10486 .expect("should find main-repo worktree");
10487
10488 let feature_b_wt_id = worktree_ids
10489 .iter()
10490 .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo"))
10491 .map(|(id, _)| *id)
10492 .expect("should find feature-b worktree");
10493
10494 // Open files from both worktrees.
10495 let main_repo_path = project::ProjectPath {
10496 worktree_id: main_repo_wt_id,
10497 path: Arc::from(rel_path("src/lib.rs")),
10498 };
10499 let feature_b_path = project::ProjectPath {
10500 worktree_id: feature_b_wt_id,
10501 path: Arc::from(rel_path("src/main.rs")),
10502 };
10503
10504 workspace
10505 .update_in(cx, |ws, window, cx| {
10506 ws.open_path(main_repo_path.clone(), None, true, window, cx)
10507 })
10508 .await
10509 .expect("should open main-repo file");
10510 workspace
10511 .update_in(cx, |ws, window, cx| {
10512 ws.open_path(feature_b_path.clone(), None, true, window, cx)
10513 })
10514 .await
10515 .expect("should open feature-b file");
10516
10517 cx.run_until_parked();
10518
10519 // Verify both items are open.
10520 let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10521 ws.panes()
10522 .iter()
10523 .flat_map(|pane| {
10524 pane.read(cx)
10525 .items()
10526 .filter_map(|item| item.project_path(cx))
10527 })
10528 .collect()
10529 });
10530 assert!(
10531 open_paths_before
10532 .iter()
10533 .any(|pp| pp.worktree_id == main_repo_wt_id),
10534 "main-repo file should be open"
10535 );
10536 assert!(
10537 open_paths_before
10538 .iter()
10539 .any(|pp| pp.worktree_id == feature_b_wt_id),
10540 "feature-b file should be open"
10541 );
10542
10543 // Save thread metadata for the linked worktree with deliberately
10544 // mismatched folder_paths to trigger the scan-based detection.
10545 save_thread_metadata_with_main_paths(
10546 "feature-b-thread",
10547 "Feature B Thread",
10548 PathList::new(&[
10549 PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10550 PathBuf::from("/nonexistent"),
10551 ]),
10552 PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10553 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10554 cx,
10555 );
10556
10557 // Save another thread that references only the main repo (not the
10558 // linked worktree) so archiving the feature-b thread's worktree isn't
10559 // blocked by another unarchived thread referencing the same path.
10560 save_thread_metadata_with_main_paths(
10561 "other-thread",
10562 "Other Thread",
10563 PathList::new(&[PathBuf::from("/main-repo")]),
10564 PathList::new(&[PathBuf::from("/main-repo")]),
10565 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10566 cx,
10567 );
10568 cx.run_until_parked();
10569
10570 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10571 cx.run_until_parked();
10572
10573 // There should still be exactly 1 workspace.
10574 assert_eq!(
10575 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10576 1,
10577 "should have 1 workspace (the mixed workspace)"
10578 );
10579
10580 // Archive the feature-b thread.
10581 let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10582 sidebar.update_in(cx, |sidebar, window, cx| {
10583 sidebar.archive_thread(&fb_session_id, window, cx);
10584 });
10585
10586 cx.run_until_parked();
10587
10588 // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10589 assert_eq!(
10590 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10591 1,
10592 "mixed workspace should be preserved"
10593 );
10594
10595 // Only the feature-b editor item should have been closed.
10596 let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10597 ws.panes()
10598 .iter()
10599 .flat_map(|pane| {
10600 pane.read(cx)
10601 .items()
10602 .filter_map(|item| item.project_path(cx))
10603 })
10604 .collect()
10605 });
10606 assert!(
10607 open_paths_after
10608 .iter()
10609 .any(|pp| pp.worktree_id == main_repo_wt_id),
10610 "main-repo file should still be open"
10611 );
10612 assert!(
10613 !open_paths_after
10614 .iter()
10615 .any(|pp| pp.worktree_id == feature_b_wt_id),
10616 "feature-b file should have been closed"
10617 );
10618}
10619
10620#[test]
10621fn test_worktree_info_branch_names_for_main_worktrees() {
10622 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10623 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10624
10625 let branch_by_path: HashMap<PathBuf, SharedString> =
10626 [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10627 .into_iter()
10628 .collect();
10629
10630 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10631 assert_eq!(infos.len(), 1);
10632 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10633 assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10634 assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10635}
10636
10637#[test]
10638fn test_worktree_info_branch_names_for_linked_worktrees() {
10639 let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10640 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10641 let worktree_paths =
10642 WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10643
10644 let branch_by_path: HashMap<PathBuf, SharedString> = [(
10645 PathBuf::from("/projects/myapp-feature"),
10646 "feature-branch".into(),
10647 )]
10648 .into_iter()
10649 .collect();
10650
10651 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10652 assert_eq!(infos.len(), 1);
10653 assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10654 assert_eq!(
10655 infos[0].branch_name,
10656 Some(SharedString::from("feature-branch"))
10657 );
10658}
10659
10660#[test]
10661fn test_worktree_info_missing_branch_returns_none() {
10662 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10663 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10664
10665 let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10666
10667 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10668 assert_eq!(infos.len(), 1);
10669 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10670 assert_eq!(infos[0].branch_name, None);
10671 assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10672}
10673
10674#[gpui::test]
10675async fn test_remote_archive_thread_with_active_connection(
10676 cx: &mut TestAppContext,
10677 server_cx: &mut TestAppContext,
10678) {
10679 // End-to-end test of archiving a remote thread tied to a linked git
10680 // worktree. Archival should:
10681 // 1. Persist the worktree's git state via the remote repository RPCs
10682 // (head_sha / create_archive_checkpoint / update_ref).
10683 // 2. Remove the linked worktree directory from the *remote* filesystem
10684 // via the GitRemoveWorktree RPC.
10685 // 3. Mark the thread metadata archived and hide it from the sidebar.
10686 //
10687 // The mock remote transport only supports one live `RemoteClient` per
10688 // connection at a time (each client's `start_proxy` replaces the
10689 // previous server channel), so we can't split the main repo and the
10690 // linked worktree across two remote projects the way Zed does in
10691 // production. Opening both as visible worktrees of a single remote
10692 // project still exercises every interesting path of the archive flow
10693 // while staying within the mock's multiplexing limits.
10694 init_test(cx);
10695
10696 cx.update(|cx| {
10697 release_channel::init(semver::Version::new(0, 0, 0), cx);
10698 });
10699
10700 let app_state = cx.update(|cx| {
10701 let app_state = workspace::AppState::test(cx);
10702 workspace::init(app_state.clone(), cx);
10703 app_state
10704 });
10705
10706 server_cx.update(|cx| {
10707 release_channel::init(semver::Version::new(0, 0, 0), cx);
10708 });
10709
10710 // Set up the remote filesystem with a main repo and one linked worktree.
10711 let server_fs = FakeFs::new(server_cx.executor());
10712 server_fs
10713 .insert_tree(
10714 "/project",
10715 serde_json::json!({
10716 ".git": {
10717 "worktrees": {
10718 "feature-a": {
10719 "commondir": "../../",
10720 "HEAD": "ref: refs/heads/feature-a",
10721 },
10722 },
10723 },
10724 "src": { "main.rs": "fn main() {}" },
10725 }),
10726 )
10727 .await;
10728 server_fs
10729 .insert_tree(
10730 "/worktrees/project/feature-a/project",
10731 serde_json::json!({
10732 ".git": "gitdir: /project/.git/worktrees/feature-a",
10733 "src": { "lib.rs": "// feature" },
10734 }),
10735 )
10736 .await;
10737 server_fs
10738 .add_linked_worktree_for_repo(
10739 Path::new("/project/.git"),
10740 false,
10741 git::repository::Worktree {
10742 path: PathBuf::from("/worktrees/project/feature-a/project"),
10743 ref_name: Some("refs/heads/feature-a".into()),
10744 sha: "abc".into(),
10745 is_main: false,
10746 is_bare: false,
10747 },
10748 )
10749 .await;
10750 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10751 server_fs.set_head_for_repo(
10752 Path::new("/project/.git"),
10753 &[("src/main.rs", "fn main() {}".into())],
10754 "head-sha",
10755 );
10756
10757 // Open a single remote project with both the main repo and the linked
10758 // worktree as visible worktrees. The mock transport doesn't multiplex
10759 // multiple `RemoteClient`s over one pooled connection cleanly (each
10760 // client's `start_proxy` clobbers the previous one's server channel),
10761 // so we can't build two separate `Project::remote` instances in this
10762 // test. Folding both worktrees into one project still exercises the
10763 // archive flow's interesting paths: `build_root_plan` classifies the
10764 // linked worktree correctly, and `find_or_create_repository` finds
10765 // the main repo live on that same project — avoiding the temp-project
10766 // fallback that would also run into the multiplexing limitation.
10767 let (project, _headless, _opts) = start_remote_project(
10768 &server_fs,
10769 Path::new("/project"),
10770 &app_state,
10771 None,
10772 cx,
10773 server_cx,
10774 )
10775 .await;
10776 project
10777 .update(cx, |project, cx| {
10778 project.find_or_create_worktree(
10779 Path::new("/worktrees/project/feature-a/project"),
10780 true,
10781 cx,
10782 )
10783 })
10784 .await
10785 .expect("should open linked worktree on remote");
10786 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10787 cx.run_until_parked();
10788
10789 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10790
10791 let (multi_workspace, cx) =
10792 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10793 let sidebar = setup_sidebar(&multi_workspace, cx);
10794
10795 // The worktree thread's (main_worktree_path, folder_path) pair points
10796 // the folder at the linked worktree checkout and the main at the
10797 // parent repo, so `build_root_plan` targets the linked worktree
10798 // specifically and knows which main repo owns it.
10799 let remote_connection = project.read_with(cx, |p, cx| p.remote_connection_options(cx));
10800 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10801 cx.update(|_window, cx| {
10802 let metadata = ThreadMetadata {
10803 thread_id: ThreadId::new(),
10804 session_id: Some(wt_thread_id.clone()),
10805 agent_id: agent::ZED_AGENT_ID.clone(),
10806 title: Some("Worktree Thread".into()),
10807 updated_at: chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
10808 .unwrap(),
10809 created_at: None,
10810 interacted_at: None,
10811 worktree_paths: WorktreePaths::from_path_lists(
10812 PathList::new(&[PathBuf::from("/project")]),
10813 PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]),
10814 )
10815 .unwrap(),
10816 archived: false,
10817 remote_connection,
10818 };
10819 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10820 });
10821 cx.run_until_parked();
10822
10823 assert!(
10824 server_fs
10825 .is_dir(Path::new("/worktrees/project/feature-a/project"))
10826 .await,
10827 "linked worktree directory should exist on remote before archiving"
10828 );
10829
10830 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
10831 sidebar.archive_thread(&wt_thread_id, window, cx);
10832 });
10833 cx.run_until_parked();
10834 server_cx.run_until_parked();
10835
10836 let is_archived = cx.update(|_window, cx| {
10837 ThreadMetadataStore::global(cx)
10838 .read(cx)
10839 .entry_by_session(&wt_thread_id)
10840 .map(|t| t.archived)
10841 .unwrap_or(false)
10842 });
10843 assert!(is_archived, "worktree thread should be archived");
10844
10845 assert!(
10846 !server_fs
10847 .is_dir(Path::new("/worktrees/project/feature-a/project"))
10848 .await,
10849 "linked worktree directory should be removed from remote fs \
10850 (the GitRemoveWorktree RPC runs `Repository::remove_worktree` \
10851 on the headless server, which deletes the directory via `Fs::remove_dir` \
10852 before running `git worktree remove --force`)"
10853 );
10854
10855 let entries = visible_entries_as_strings(&sidebar, cx);
10856 assert!(
10857 !entries.iter().any(|e| e.contains("Worktree Thread")),
10858 "archived worktree thread should be hidden from sidebar: {entries:?}"
10859 );
10860}
10861
10862#[gpui::test]
10863async fn test_remote_archive_thread_with_disconnected_remote(
10864 cx: &mut TestAppContext,
10865 server_cx: &mut TestAppContext,
10866) {
10867 // When a remote thread has no linked-worktree state to archive (only
10868 // a main worktree), archival is a pure metadata operation: no RPCs
10869 // are issued against the remote server. This must succeed even when
10870 // the connection has dropped out, because losing connectivity should
10871 // not block users from cleaning up their thread list.
10872 //
10873 // Threads that *do* have linked-worktree state require a live
10874 // connection to run the git worktree removal on the server; that
10875 // path is covered by `test_remote_archive_thread_with_active_connection`.
10876 init_test(cx);
10877
10878 cx.update(|cx| {
10879 release_channel::init(semver::Version::new(0, 0, 0), cx);
10880 });
10881
10882 let app_state = cx.update(|cx| {
10883 let app_state = workspace::AppState::test(cx);
10884 workspace::init(app_state.clone(), cx);
10885 app_state
10886 });
10887
10888 server_cx.update(|cx| {
10889 release_channel::init(semver::Version::new(0, 0, 0), cx);
10890 });
10891
10892 let server_fs = FakeFs::new(server_cx.executor());
10893 server_fs
10894 .insert_tree(
10895 "/project",
10896 serde_json::json!({
10897 ".git": {},
10898 "src": { "main.rs": "fn main() {}" },
10899 }),
10900 )
10901 .await;
10902 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10903
10904 let (project, _headless, _opts) = start_remote_project(
10905 &server_fs,
10906 Path::new("/project"),
10907 &app_state,
10908 None,
10909 cx,
10910 server_cx,
10911 )
10912 .await;
10913 let remote_client = project
10914 .read_with(cx, |project, _cx| project.remote_client())
10915 .expect("remote project should expose its client");
10916
10917 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10918
10919 let (multi_workspace, cx) =
10920 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10921 let sidebar = setup_sidebar(&multi_workspace, cx);
10922
10923 let thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10924 save_thread_metadata(
10925 thread_id.clone(),
10926 Some("Remote Thread".into()),
10927 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10928 None,
10929 None,
10930 &project,
10931 cx,
10932 );
10933 cx.run_until_parked();
10934
10935 // Sanity-check: there is nothing on the remote fs outside the main
10936 // repo, so archival should not need to touch the server.
10937 assert!(
10938 !server_fs.is_dir(Path::new("/worktrees")).await,
10939 "no linked worktrees on the server before archiving"
10940 );
10941
10942 // Disconnect the remote connection before archiving. We don't
10943 // `run_until_parked` here because the disconnect itself triggers
10944 // reconnection work that can't complete in the test environment.
10945 remote_client.update_in(cx, |client, _window, cx| {
10946 client.simulate_disconnect(cx).detach();
10947 });
10948
10949 sidebar.update_in(cx, |sidebar, window, cx| {
10950 sidebar.archive_thread(&thread_id, window, cx);
10951 });
10952 cx.run_until_parked();
10953
10954 let is_archived = cx.update(|_window, cx| {
10955 ThreadMetadataStore::global(cx)
10956 .read(cx)
10957 .entry_by_session(&thread_id)
10958 .map(|t| t.archived)
10959 .unwrap_or(false)
10960 });
10961 assert!(
10962 is_archived,
10963 "thread should be archived even when remote is disconnected"
10964 );
10965
10966 let entries = visible_entries_as_strings(&sidebar, cx);
10967 assert!(
10968 !entries.iter().any(|e| e.contains("Remote Thread")),
10969 "archived thread should be hidden from sidebar: {entries:?}"
10970 );
10971}
10972
10973#[gpui::test]
10974async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) {
10975 init_test(cx);
10976 let fs = FakeFs::new(cx.executor());
10977 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
10978 .await;
10979 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
10980 .await;
10981 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10982 let project = project::Project::test(fs, ["/project-a".as_ref()], cx).await;
10983
10984 let (multi_workspace, cx) =
10985 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10986
10987 // Set up the sidebar while the project is local. This registers the
10988 // WorktreePathsChanged subscription for the project.
10989 let _sidebar = setup_sidebar(&multi_workspace, cx);
10990
10991 let session_id = acp::SessionId::new(Arc::from("test-thread"));
10992 save_named_thread_metadata("test-thread", "My Thread", &project, cx).await;
10993
10994 let thread_id = cx.update(|_window, cx| {
10995 ThreadMetadataStore::global(cx)
10996 .read(cx)
10997 .entry_by_session(&session_id)
10998 .map(|e| e.thread_id)
10999 .expect("thread must be in the store")
11000 });
11001
11002 cx.update(|_window, cx| {
11003 let store = ThreadMetadataStore::global(cx);
11004 let entry = store.read(cx).entry(thread_id).unwrap();
11005 assert_eq!(
11006 entry.folder_paths().paths(),
11007 &[PathBuf::from("/project-a")],
11008 "thread must be saved with /project-a before collab"
11009 );
11010 });
11011
11012 // Transition the project into collab mode. The sidebar's subscription is
11013 // still active from when the project was local.
11014 project.update(cx, |project, _cx| {
11015 project.mark_as_collab_for_testing();
11016 });
11017
11018 // Adding a worktree fires WorktreePathsChanged with old_paths = {/project-a}.
11019 // The sidebar's subscription is still active, so move_thread_paths is called.
11020 // Without the is_via_collab() guard inside move_thread_paths, this would
11021 // update the stored thread paths from {/project-a} to {/project-a, /project-b}.
11022 project
11023 .update(cx, |project, cx| {
11024 project.find_or_create_worktree("/project-b", true, cx)
11025 })
11026 .await
11027 .expect("should add worktree");
11028 cx.run_until_parked();
11029
11030 cx.update(|_window, cx| {
11031 let store = ThreadMetadataStore::global(cx);
11032 let entry = store
11033 .read(cx)
11034 .entry(thread_id)
11035 .expect("thread must still exist");
11036 assert_eq!(
11037 entry.folder_paths().paths(),
11038 &[PathBuf::from("/project-a")],
11039 "thread path must not change when project is via collab"
11040 );
11041 });
11042}
11043
11044#[gpui::test]
11045async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace(
11046 cx: &mut TestAppContext,
11047) {
11048 // Regression test for: cmd-clicking a project group header should return
11049 // the user to the workspace they most recently had active in that group,
11050 // including workspaces rooted at a linked worktree.
11051 init_test(cx);
11052 let fs = FakeFs::new(cx.executor());
11053
11054 fs.insert_tree(
11055 "/project-a",
11056 serde_json::json!({
11057 ".git": {},
11058 "src": {},
11059 }),
11060 )
11061 .await;
11062 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11063 .await;
11064
11065 fs.add_linked_worktree_for_repo(
11066 Path::new("/project-a/.git"),
11067 false,
11068 git::repository::Worktree {
11069 path: std::path::PathBuf::from("/wt-feature-a"),
11070 ref_name: Some("refs/heads/feature-a".into()),
11071 sha: "aaa".into(),
11072 is_main: false,
11073 is_bare: false,
11074 },
11075 )
11076 .await;
11077
11078 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11079
11080 let main_project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
11081 let worktree_project_a =
11082 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
11083 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
11084
11085 main_project_a
11086 .update(cx, |p, cx| p.git_scans_complete(cx))
11087 .await;
11088 worktree_project_a
11089 .update(cx, |p, cx| p.git_scans_complete(cx))
11090 .await;
11091
11092 // The multi-workspace starts with the main-paths workspace of group A
11093 // as the initially active workspace.
11094 let (multi_workspace, cx) = cx
11095 .add_window_view(|window, cx| MultiWorkspace::test_new(main_project_a.clone(), window, cx));
11096
11097 let sidebar = setup_sidebar(&multi_workspace, cx);
11098
11099 // Capture the initially active workspace (group A's main-paths workspace)
11100 // *before* registering additional workspaces, since `workspaces()` returns
11101 // retained workspaces in registration order — not activation order — and
11102 // the multi-workspace's starting workspace may not be retained yet.
11103 let main_workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11104
11105 // Register the linked-worktree workspace (group A) and the group-B
11106 // workspace. Both get retained by the multi-workspace.
11107 let worktree_workspace_a = multi_workspace.update_in(cx, |mw, window, cx| {
11108 mw.test_add_workspace(worktree_project_a.clone(), window, cx)
11109 });
11110 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
11111 mw.test_add_workspace(project_b.clone(), window, cx)
11112 });
11113
11114 cx.run_until_parked();
11115
11116 // Step 1: activate the linked-worktree workspace. The MultiWorkspace
11117 // records this as the last-active workspace for group A on its
11118 // ProjectGroupState. (We don't assert on the initial active workspace
11119 // because `test_add_workspace` may auto-activate newly registered
11120 // workspaces — what matters for this test is the explicit sequence of
11121 // activations below.)
11122 multi_workspace.update_in(cx, |mw, window, cx| {
11123 mw.activate(worktree_workspace_a.clone(), None, window, cx);
11124 });
11125 cx.run_until_parked();
11126 assert_eq!(
11127 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11128 worktree_workspace_a,
11129 "linked-worktree workspace should be active after step 1"
11130 );
11131
11132 // Step 2: switch to the workspace for group B. Group A's last-active
11133 // workspace remains the linked-worktree one (group B getting activated
11134 // records *its own* last-active workspace, not group A's).
11135 multi_workspace.update_in(cx, |mw, window, cx| {
11136 mw.activate(workspace_b.clone(), None, window, cx);
11137 });
11138 cx.run_until_parked();
11139 assert_eq!(
11140 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11141 workspace_b,
11142 "group B's workspace should be active after step 2"
11143 );
11144
11145 // Step 3: simulate cmd-click on group A's header. The project group key
11146 // for group A is derived from the *main-paths* workspace (linked-worktree
11147 // workspaces share the same key because it normalizes to main-worktree
11148 // paths).
11149 let group_a_key = main_workspace_a.read_with(cx, |ws, cx| ws.project_group_key(cx));
11150 sidebar.update_in(cx, |sidebar, window, cx| {
11151 sidebar.activate_or_open_workspace_for_group(&group_a_key, window, cx);
11152 });
11153 cx.run_until_parked();
11154
11155 // Expected: we're back in the linked-worktree workspace, not the
11156 // main-paths one.
11157 let active_after_cmd_click = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11158 assert_eq!(
11159 active_after_cmd_click, worktree_workspace_a,
11160 "cmd-click on group A's header should return to the last-active \
11161 linked-worktree workspace, not the main-paths workspace"
11162 );
11163 assert_ne!(
11164 active_after_cmd_click, main_workspace_a,
11165 "cmd-click must not fall back to the main-paths workspace when a \
11166 linked-worktree workspace was the last-active one for the group"
11167 );
11168}