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_drops_retained_conversation_view(cx: &mut TestAppContext) {
6146 let project = init_test_project_with_agent_panel("/project-a", cx).await;
6147 let (multi_workspace, cx) =
6148 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6149 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6150 cx.run_until_parked();
6151
6152 let connection = acp_thread::StubAgentConnection::new();
6153 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6154 acp::ContentChunk::new("Done".into()),
6155 )]);
6156 open_thread_with_connection(&panel, connection, cx);
6157 send_message(&panel, cx);
6158 let session_id = active_session_id(&panel, cx);
6159 let thread_id = active_thread_id(&panel, cx);
6160 cx.run_until_parked();
6161
6162 sidebar.read_with(cx, |sidebar, _| {
6163 assert!(
6164 is_active_session(sidebar, &session_id),
6165 "expected the newly created thread to be active before archiving",
6166 );
6167 });
6168
6169 sidebar.update_in(cx, |sidebar, window, cx| {
6170 sidebar.archive_thread(&session_id, window, cx);
6171 });
6172 cx.run_until_parked();
6173
6174 panel.read_with(cx, |panel, _| {
6175 assert!(
6176 !panel.is_retained_thread(&thread_id),
6177 "archiving a thread must drop its ConversationView from retained_threads, \
6178 but the archived thread id {thread_id:?} is still retained",
6179 );
6180 });
6181}
6182
6183#[gpui::test]
6184async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
6185 // Tests two archive scenarios:
6186 // 1. Archiving a thread in a non-active workspace leaves active_entry
6187 // as the current draft.
6188 // 2. Archiving the thread the user is looking at falls back to a draft
6189 // on the same workspace.
6190 agent_ui::test_support::init_test(cx);
6191 cx.update(|cx| {
6192 ThreadStore::init_global(cx);
6193 ThreadMetadataStore::init_global(cx);
6194 language_model::LanguageModelRegistry::test(cx);
6195 prompt_store::init(cx);
6196 });
6197
6198 let fs = FakeFs::new(cx.executor());
6199 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6200 .await;
6201 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6202 .await;
6203 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6204
6205 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6206 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6207
6208 let (multi_workspace, cx) =
6209 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6210 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6211
6212 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6213 mw.test_add_workspace(project_b.clone(), window, cx)
6214 });
6215 let panel_b = add_agent_panel(&workspace_b, cx);
6216 cx.run_until_parked();
6217
6218 // Explicitly create a draft on workspace_b so the sidebar tracks one.
6219 sidebar.update_in(cx, |sidebar, window, cx| {
6220 sidebar.create_new_thread(&workspace_b, window, cx);
6221 });
6222 cx.run_until_parked();
6223
6224 // --- Scenario 1: archive a thread in the non-active workspace ---
6225
6226 // Create a thread in project-a (non-active — project-b is active).
6227 let connection = acp_thread::StubAgentConnection::new();
6228 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6229 acp::ContentChunk::new("Done".into()),
6230 )]);
6231 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
6232 agent_ui::test_support::send_message(&panel_a, cx);
6233 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
6234 cx.run_until_parked();
6235
6236 sidebar.update_in(cx, |sidebar, window, cx| {
6237 sidebar.archive_thread(&thread_a, window, cx);
6238 });
6239 cx.run_until_parked();
6240
6241 // active_entry should still be a draft on workspace_b (the active one).
6242 sidebar.read_with(cx, |sidebar, _| {
6243 assert!(
6244 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
6245 "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
6246 sidebar.active_entry,
6247 );
6248 });
6249
6250 // --- Scenario 2: archive the thread the user is looking at ---
6251
6252 // Create a thread in project-b (the active workspace) and verify it
6253 // becomes the active entry.
6254 let connection = acp_thread::StubAgentConnection::new();
6255 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6256 acp::ContentChunk::new("Done".into()),
6257 )]);
6258 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6259 agent_ui::test_support::send_message(&panel_b, cx);
6260 let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
6261 cx.run_until_parked();
6262
6263 sidebar.read_with(cx, |sidebar, _| {
6264 assert!(
6265 is_active_session(&sidebar, &thread_b),
6266 "expected active_entry to be Thread({thread_b}), got: {:?}",
6267 sidebar.active_entry,
6268 );
6269 });
6270
6271 sidebar.update_in(cx, |sidebar, window, cx| {
6272 sidebar.archive_thread(&thread_b, window, cx);
6273 });
6274 cx.run_until_parked();
6275
6276 // Archiving the active thread activates a draft on the same workspace
6277 // (via clear_base_view → activate_draft). The draft is not shown as a
6278 // sidebar row but active_entry tracks it.
6279 sidebar.read_with(cx, |sidebar, _| {
6280 assert!(
6281 matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
6282 "expected draft on workspace_b after archiving active thread, got: {:?}",
6283 sidebar.active_entry,
6284 );
6285 });
6286}
6287
6288#[gpui::test]
6289async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
6290 // Full flow: create a thread, archive it (removing the workspace),
6291 // then unarchive. Only the restored thread should appear — no
6292 // leftover drafts or previously-serialized threads.
6293 let project = init_test_project_with_agent_panel("/my-project", cx).await;
6294 let (multi_workspace, cx) =
6295 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6296 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6297 cx.run_until_parked();
6298
6299 // Create a thread and send a message so it's a real thread.
6300 let connection = acp_thread::StubAgentConnection::new();
6301 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6302 acp::ContentChunk::new("Hello".into()),
6303 )]);
6304 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6305 agent_ui::test_support::send_message(&panel, cx);
6306 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6307 cx.run_until_parked();
6308
6309 // Archive it.
6310 sidebar.update_in(cx, |sidebar, window, cx| {
6311 sidebar.archive_thread(&session_id, window, cx);
6312 });
6313 cx.run_until_parked();
6314
6315 // Grab metadata for unarchive.
6316 let thread_id = cx.update(|_, cx| {
6317 ThreadMetadataStore::global(cx)
6318 .read(cx)
6319 .entries()
6320 .find(|e| e.session_id.as_ref() == Some(&session_id))
6321 .map(|e| e.thread_id)
6322 .expect("thread should exist")
6323 });
6324 let metadata = cx.update(|_, cx| {
6325 ThreadMetadataStore::global(cx)
6326 .read(cx)
6327 .entry(thread_id)
6328 .cloned()
6329 .expect("metadata should exist")
6330 });
6331
6332 // Unarchive it — the draft should be replaced by the restored thread.
6333 sidebar.update_in(cx, |sidebar, window, cx| {
6334 sidebar.open_thread_from_archive(metadata, window, cx);
6335 });
6336 cx.run_until_parked();
6337
6338 // Only the unarchived thread should be visible — no drafts, no other threads.
6339 let entries = visible_entries_as_strings(&sidebar, cx);
6340 let thread_count = entries
6341 .iter()
6342 .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
6343 .count();
6344 assert_eq!(
6345 thread_count, 1,
6346 "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
6347 );
6348 assert!(
6349 !entries.iter().any(|e| e.contains("Draft")),
6350 "expected no drafts after restoring, got entries: {entries:?}"
6351 );
6352}
6353
6354#[gpui::test]
6355async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
6356 cx: &mut TestAppContext,
6357) {
6358 // When a thread is unarchived into a project group that has no open
6359 // workspace, the sidebar opens a new workspace and loads the thread.
6360 // No spurious draft should appear alongside the unarchived thread.
6361 agent_ui::test_support::init_test(cx);
6362 cx.update(|cx| {
6363 ThreadStore::init_global(cx);
6364 ThreadMetadataStore::init_global(cx);
6365 language_model::LanguageModelRegistry::test(cx);
6366 prompt_store::init(cx);
6367 });
6368
6369 let fs = FakeFs::new(cx.executor());
6370 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6371 .await;
6372 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6373 .await;
6374 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6375
6376 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6377 let (multi_workspace, cx) =
6378 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6379 let sidebar = setup_sidebar(&multi_workspace, cx);
6380 cx.run_until_parked();
6381
6382 // Save an archived thread whose folder_paths point to project-b,
6383 // which has no open workspace.
6384 let session_id = acp::SessionId::new(Arc::from("archived-thread"));
6385 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6386 let thread_id = ThreadId::new();
6387 cx.update(|_, cx| {
6388 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6389 store.save(
6390 ThreadMetadata {
6391 thread_id,
6392 session_id: Some(session_id.clone()),
6393 agent_id: agent::ZED_AGENT_ID.clone(),
6394 title: Some("Unarchived Thread".into()),
6395 updated_at: Utc::now(),
6396 created_at: None,
6397 interacted_at: None,
6398 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6399 archived: true,
6400 remote_connection: None,
6401 },
6402 cx,
6403 )
6404 });
6405 });
6406 cx.run_until_parked();
6407
6408 // Verify no workspace for project-b exists yet.
6409 assert_eq!(
6410 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6411 1,
6412 "should start with only the project-a workspace"
6413 );
6414
6415 // Un-archive the thread — should open project-b workspace and load it.
6416 let metadata = cx.update(|_, cx| {
6417 ThreadMetadataStore::global(cx)
6418 .read(cx)
6419 .entry(thread_id)
6420 .cloned()
6421 .expect("metadata should exist")
6422 });
6423
6424 sidebar.update_in(cx, |sidebar, window, cx| {
6425 sidebar.open_thread_from_archive(metadata, window, cx);
6426 });
6427 cx.run_until_parked();
6428
6429 // A second workspace should have been created for project-b.
6430 assert_eq!(
6431 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6432 2,
6433 "should have opened a workspace for the unarchived thread"
6434 );
6435
6436 // The sidebar should show the unarchived thread without a spurious draft
6437 // in the project-b group.
6438 let entries = visible_entries_as_strings(&sidebar, cx);
6439 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6440 // project-a gets a draft (it's the active workspace with no threads),
6441 // but project-b should NOT have one — only the unarchived thread.
6442 assert!(
6443 draft_count <= 1,
6444 "expected at most one draft (for project-a), got entries: {entries:?}"
6445 );
6446 assert!(
6447 entries.iter().any(|e| e.contains("Unarchived Thread")),
6448 "expected unarchived thread to appear, got entries: {entries:?}"
6449 );
6450}
6451
6452#[gpui::test]
6453async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
6454 cx: &mut TestAppContext,
6455) {
6456 agent_ui::test_support::init_test(cx);
6457 cx.update(|cx| {
6458 ThreadStore::init_global(cx);
6459 ThreadMetadataStore::init_global(cx);
6460 language_model::LanguageModelRegistry::test(cx);
6461 prompt_store::init(cx);
6462 });
6463
6464 let fs = FakeFs::new(cx.executor());
6465 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6466 .await;
6467 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6468 .await;
6469 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6470
6471 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6472 let (multi_workspace, cx) =
6473 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6474 let sidebar = setup_sidebar(&multi_workspace, cx);
6475 cx.run_until_parked();
6476
6477 let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
6478 let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
6479 let original_thread_id = ThreadId::new();
6480 cx.update(|_, cx| {
6481 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6482 store.save(
6483 ThreadMetadata {
6484 thread_id: original_thread_id,
6485 session_id: Some(session_id.clone()),
6486 agent_id: agent::ZED_AGENT_ID.clone(),
6487 title: Some("Unarchived Thread".into()),
6488 updated_at: Utc::now(),
6489 created_at: None,
6490 interacted_at: None,
6491 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6492 archived: true,
6493 remote_connection: None,
6494 },
6495 cx,
6496 )
6497 });
6498 });
6499 cx.run_until_parked();
6500
6501 let metadata = cx.update(|_, cx| {
6502 ThreadMetadataStore::global(cx)
6503 .read(cx)
6504 .entry(original_thread_id)
6505 .cloned()
6506 .expect("metadata should exist before unarchive")
6507 });
6508
6509 sidebar.update_in(cx, |sidebar, window, cx| {
6510 sidebar.open_thread_from_archive(metadata, window, cx);
6511 });
6512
6513 cx.run_until_parked();
6514
6515 assert_eq!(
6516 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6517 2,
6518 "expected unarchive to open the target workspace"
6519 );
6520
6521 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6522 mw.workspaces()
6523 .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
6524 .cloned()
6525 .expect("expected restored workspace for unarchived thread")
6526 });
6527 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6528 workspace
6529 .panel::<AgentPanel>(cx)
6530 .expect("expected unarchive to install an agent panel in the new workspace")
6531 });
6532
6533 let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6534 assert_eq!(
6535 restored_thread_id,
6536 Some(original_thread_id),
6537 "expected the new workspace's agent panel to target the restored archived thread id"
6538 );
6539
6540 let session_entries = cx.update(|_, cx| {
6541 ThreadMetadataStore::global(cx)
6542 .read(cx)
6543 .entries()
6544 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6545 .cloned()
6546 .collect::<Vec<_>>()
6547 });
6548 assert_eq!(
6549 session_entries.len(),
6550 1,
6551 "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
6552 );
6553 assert_eq!(
6554 session_entries[0].thread_id, original_thread_id,
6555 "expected restore into a new workspace to reuse the original thread id"
6556 );
6557 assert!(
6558 !session_entries[0].archived,
6559 "expected restored thread metadata to be unarchived, got: {:?}",
6560 session_entries[0]
6561 );
6562
6563 let mapped_thread_id = cx.update(|_, cx| {
6564 ThreadMetadataStore::global(cx)
6565 .read(cx)
6566 .entries()
6567 .find(|e| e.session_id.as_ref() == Some(&session_id))
6568 .map(|e| e.thread_id)
6569 });
6570 assert_eq!(
6571 mapped_thread_id,
6572 Some(original_thread_id),
6573 "expected session mapping to remain stable after opening the new workspace"
6574 );
6575
6576 let entries = visible_entries_as_strings(&sidebar, cx);
6577 let real_thread_rows = entries
6578 .iter()
6579 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6580 .filter(|entry| !entry.contains("Draft"))
6581 .count();
6582 assert_eq!(
6583 real_thread_rows, 1,
6584 "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
6585 );
6586 assert!(
6587 entries
6588 .iter()
6589 .any(|entry| entry.contains("Unarchived Thread")),
6590 "expected restored thread row to be visible, got entries: {entries:?}"
6591 );
6592}
6593
6594#[gpui::test]
6595async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
6596 // When a workspace already exists with an empty draft and a thread
6597 // is unarchived into it, the draft should be replaced — not kept
6598 // alongside the loaded thread.
6599 agent_ui::test_support::init_test(cx);
6600 cx.update(|cx| {
6601 ThreadStore::init_global(cx);
6602 ThreadMetadataStore::init_global(cx);
6603 language_model::LanguageModelRegistry::test(cx);
6604 prompt_store::init(cx);
6605 });
6606
6607 let fs = FakeFs::new(cx.executor());
6608 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6609 .await;
6610 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6611
6612 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6613 let (multi_workspace, cx) =
6614 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6615 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6616 cx.run_until_parked();
6617
6618 // Create a thread and send a message so it's no longer a draft.
6619 let connection = acp_thread::StubAgentConnection::new();
6620 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6621 acp::ContentChunk::new("Done".into()),
6622 )]);
6623 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6624 agent_ui::test_support::send_message(&panel, cx);
6625 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6626 cx.run_until_parked();
6627
6628 // Archive the thread — the group is left empty (no draft created).
6629 sidebar.update_in(cx, |sidebar, window, cx| {
6630 sidebar.archive_thread(&session_id, window, cx);
6631 });
6632 cx.run_until_parked();
6633
6634 // Un-archive the thread.
6635 let thread_id = cx.update(|_, cx| {
6636 ThreadMetadataStore::global(cx)
6637 .read(cx)
6638 .entries()
6639 .find(|e| e.session_id.as_ref() == Some(&session_id))
6640 .map(|e| e.thread_id)
6641 .expect("thread should exist in store")
6642 });
6643 let metadata = cx.update(|_, cx| {
6644 ThreadMetadataStore::global(cx)
6645 .read(cx)
6646 .entry(thread_id)
6647 .cloned()
6648 .expect("metadata should exist")
6649 });
6650
6651 sidebar.update_in(cx, |sidebar, window, cx| {
6652 sidebar.open_thread_from_archive(metadata, window, cx);
6653 });
6654 cx.run_until_parked();
6655
6656 // The draft should be gone — only the unarchived thread remains.
6657 let entries = visible_entries_as_strings(&sidebar, cx);
6658 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6659 assert_eq!(
6660 draft_count, 0,
6661 "expected no drafts after unarchiving, got entries: {entries:?}"
6662 );
6663}
6664
6665#[gpui::test]
6666async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
6667 cx: &mut TestAppContext,
6668) {
6669 agent_ui::test_support::init_test(cx);
6670 cx.update(|cx| {
6671 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6672 ThreadStore::init_global(cx);
6673 ThreadMetadataStore::init_global(cx);
6674 language_model::LanguageModelRegistry::test(cx);
6675 prompt_store::init(cx);
6676 });
6677
6678 let fs = FakeFs::new(cx.executor());
6679 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6680 .await;
6681 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6682 .await;
6683 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6684
6685 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6686 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6687
6688 let (multi_workspace, cx) =
6689 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6690 let sidebar = setup_sidebar(&multi_workspace, cx);
6691
6692 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6693 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6694 mw.test_add_workspace(project_b.clone(), window, cx)
6695 });
6696 let _panel_b = add_agent_panel(&workspace_b, cx);
6697 cx.run_until_parked();
6698
6699 multi_workspace.update_in(cx, |mw, window, cx| {
6700 mw.activate(workspace_a.clone(), None, window, cx);
6701 });
6702 cx.run_until_parked();
6703
6704 let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
6705 let thread_id = ThreadId::new();
6706 cx.update(|_, cx| {
6707 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6708 store.save(
6709 ThreadMetadata {
6710 thread_id,
6711 session_id: Some(session_id.clone()),
6712 agent_id: agent::ZED_AGENT_ID.clone(),
6713 title: Some("Restored In Inactive Workspace".into()),
6714 updated_at: Utc::now(),
6715 created_at: None,
6716 interacted_at: None,
6717 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
6718 PathBuf::from("/project-b"),
6719 ])),
6720 archived: true,
6721 remote_connection: None,
6722 },
6723 cx,
6724 )
6725 });
6726 });
6727 cx.run_until_parked();
6728
6729 let metadata = cx.update(|_, cx| {
6730 ThreadMetadataStore::global(cx)
6731 .read(cx)
6732 .entry(thread_id)
6733 .cloned()
6734 .expect("archived metadata should exist before restore")
6735 });
6736
6737 sidebar.update_in(cx, |sidebar, window, cx| {
6738 sidebar.open_thread_from_archive(metadata, window, cx);
6739 });
6740
6741 let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
6742 workspace.panel::<AgentPanel>(cx).expect(
6743 "target workspace should still have an agent panel immediately after activation",
6744 )
6745 });
6746 let immediate_active_thread_id =
6747 panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6748
6749 cx.run_until_parked();
6750
6751 sidebar.read_with(cx, |sidebar, _cx| {
6752 assert_active_thread(
6753 sidebar,
6754 &session_id,
6755 "unarchiving into an inactive existing workspace should end on the restored thread",
6756 );
6757 });
6758
6759 let panel_b = workspace_b.read_with(cx, |workspace, cx| {
6760 workspace
6761 .panel::<AgentPanel>(cx)
6762 .expect("target workspace should still have an agent panel")
6763 });
6764 assert_eq!(
6765 panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6766 Some(thread_id),
6767 "expected target panel to activate the restored thread id"
6768 );
6769 assert!(
6770 immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
6771 "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
6772 );
6773
6774 let entries = visible_entries_as_strings(&sidebar, cx);
6775 let target_rows: Vec<_> = entries
6776 .iter()
6777 .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
6778 .cloned()
6779 .collect();
6780 assert_eq!(
6781 target_rows.len(),
6782 1,
6783 "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
6784 );
6785 assert!(
6786 target_rows[0].contains("Restored In Inactive Workspace"),
6787 "expected the remaining row to be the restored thread, got entries: {entries:?}"
6788 );
6789 assert!(
6790 !target_rows[0].contains("Draft"),
6791 "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
6792 );
6793}
6794
6795#[gpui::test]
6796async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
6797 cx: &mut TestAppContext,
6798) {
6799 agent_ui::test_support::init_test(cx);
6800 cx.update(|cx| {
6801 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6802 ThreadStore::init_global(cx);
6803 ThreadMetadataStore::init_global(cx);
6804 language_model::LanguageModelRegistry::test(cx);
6805 prompt_store::init(cx);
6806 });
6807
6808 let fs = FakeFs::new(cx.executor());
6809 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6810 .await;
6811 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6812 .await;
6813 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6814
6815 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6816 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6817
6818 let (multi_workspace, cx) =
6819 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6820 let sidebar = setup_sidebar(&multi_workspace, cx);
6821
6822 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6823 mw.test_add_workspace(project_b.clone(), window, cx)
6824 });
6825 let panel_b = add_agent_panel(&workspace_b, cx);
6826 cx.run_until_parked();
6827
6828 let connection = acp_thread::StubAgentConnection::new();
6829 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6830 acp::ContentChunk::new("Done".into()),
6831 )]);
6832 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6833 agent_ui::test_support::send_message(&panel_b, cx);
6834 let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
6835 save_test_thread_metadata(&session_id, &project_b, cx).await;
6836 cx.run_until_parked();
6837
6838 sidebar.update_in(cx, |sidebar, window, cx| {
6839 sidebar.archive_thread(&session_id, window, cx);
6840 });
6841
6842 cx.run_until_parked();
6843
6844 let archived_metadata = cx.update(|_, cx| {
6845 let store = ThreadMetadataStore::global(cx).read(cx);
6846 let thread_id = store
6847 .entries()
6848 .find(|e| e.session_id.as_ref() == Some(&session_id))
6849 .map(|e| e.thread_id)
6850 .expect("archived thread should still exist in metadata store");
6851 let metadata = store
6852 .entry(thread_id)
6853 .cloned()
6854 .expect("archived metadata should still exist after archive");
6855 assert!(
6856 metadata.archived,
6857 "thread should be archived before project removal"
6858 );
6859 metadata
6860 });
6861
6862 let group_key_b =
6863 project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
6864 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
6865 mw.remove_project_group(&group_key_b, window, cx)
6866 });
6867 remove_task
6868 .await
6869 .expect("remove project group task should complete");
6870 cx.run_until_parked();
6871
6872 assert_eq!(
6873 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6874 1,
6875 "removing the archived thread's parent project group should remove its workspace"
6876 );
6877
6878 sidebar.update_in(cx, |sidebar, window, cx| {
6879 sidebar.open_thread_from_archive(archived_metadata.clone(), window, cx);
6880 });
6881 cx.run_until_parked();
6882
6883 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6884 mw.workspaces()
6885 .find(|workspace| {
6886 PathList::new(&workspace.read(cx).root_paths(cx))
6887 == PathList::new(&[PathBuf::from("/project-b")])
6888 })
6889 .cloned()
6890 .expect("expected unarchive to recreate the removed project workspace")
6891 });
6892 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6893 workspace
6894 .panel::<AgentPanel>(cx)
6895 .expect("expected restored workspace to bootstrap an agent panel")
6896 });
6897
6898 let restored_thread_id = cx.update(|_, cx| {
6899 ThreadMetadataStore::global(cx)
6900 .read(cx)
6901 .entries()
6902 .find(|e| e.session_id.as_ref() == Some(&session_id))
6903 .map(|e| e.thread_id)
6904 .expect("session should still map to restored thread id")
6905 });
6906 assert_eq!(
6907 restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6908 Some(restored_thread_id),
6909 "expected unarchive after project removal to activate the restored real thread"
6910 );
6911
6912 sidebar.read_with(cx, |sidebar, _cx| {
6913 assert_active_thread(
6914 sidebar,
6915 &session_id,
6916 "expected sidebar active entry to track the restored thread after project removal",
6917 );
6918 });
6919
6920 let entries = visible_entries_as_strings(&sidebar, cx);
6921 let restored_title = archived_metadata.display_title().to_string();
6922 let matching_rows: Vec<_> = entries
6923 .iter()
6924 .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
6925 .cloned()
6926 .collect();
6927 assert_eq!(
6928 matching_rows.len(),
6929 1,
6930 "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
6931 );
6932 assert!(
6933 !matching_rows[0].contains("Draft"),
6934 "expected no draft row after unarchive following project removal, got entries: {entries:?}"
6935 );
6936}
6937
6938#[gpui::test]
6939async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
6940 agent_ui::test_support::init_test(cx);
6941 cx.update(|cx| {
6942 ThreadStore::init_global(cx);
6943 ThreadMetadataStore::init_global(cx);
6944 language_model::LanguageModelRegistry::test(cx);
6945 prompt_store::init(cx);
6946 });
6947
6948 let fs = FakeFs::new(cx.executor());
6949 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6950 .await;
6951 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6952
6953 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6954 let (multi_workspace, cx) =
6955 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6956 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6957 cx.run_until_parked();
6958
6959 let connection = acp_thread::StubAgentConnection::new();
6960 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6961 acp::ContentChunk::new("Done".into()),
6962 )]);
6963 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6964 agent_ui::test_support::send_message(&panel, cx);
6965 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6966 cx.run_until_parked();
6967
6968 let original_thread_id = cx.update(|_, cx| {
6969 ThreadMetadataStore::global(cx)
6970 .read(cx)
6971 .entries()
6972 .find(|e| e.session_id.as_ref() == Some(&session_id))
6973 .map(|e| e.thread_id)
6974 .expect("thread should exist in store before archiving")
6975 });
6976
6977 sidebar.update_in(cx, |sidebar, window, cx| {
6978 sidebar.archive_thread(&session_id, window, cx);
6979 });
6980 cx.run_until_parked();
6981
6982 let metadata = cx.update(|_, cx| {
6983 ThreadMetadataStore::global(cx)
6984 .read(cx)
6985 .entry(original_thread_id)
6986 .cloned()
6987 .expect("metadata should exist after archiving")
6988 });
6989
6990 sidebar.update_in(cx, |sidebar, window, cx| {
6991 sidebar.open_thread_from_archive(metadata, window, cx);
6992 });
6993 cx.run_until_parked();
6994
6995 let session_entries = cx.update(|_, cx| {
6996 ThreadMetadataStore::global(cx)
6997 .read(cx)
6998 .entries()
6999 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7000 .cloned()
7001 .collect::<Vec<_>>()
7002 });
7003
7004 assert_eq!(
7005 session_entries.len(),
7006 1,
7007 "expected exactly one metadata row for the restored session, got: {session_entries:?}"
7008 );
7009 assert_eq!(
7010 session_entries[0].thread_id, original_thread_id,
7011 "expected unarchive to reuse the original thread id instead of creating a duplicate row"
7012 );
7013 assert!(
7014 session_entries[0].session_id.is_some(),
7015 "expected restored metadata to be a real thread, got: {:?}",
7016 session_entries[0]
7017 );
7018
7019 let entries = visible_entries_as_strings(&sidebar, cx);
7020 let real_thread_rows = entries
7021 .iter()
7022 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7023 .filter(|entry| !entry.contains("Draft"))
7024 .count();
7025 assert_eq!(
7026 real_thread_rows, 1,
7027 "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
7028 );
7029 assert!(
7030 !entries.iter().any(|entry| entry.contains("Draft")),
7031 "expected no draft rows after restoring, got entries: {entries:?}"
7032 );
7033}
7034
7035#[gpui::test]
7036async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
7037 cx: &mut TestAppContext,
7038) {
7039 // When a thread is archived while the user is in a different workspace,
7040 // clear_base_view creates a draft on the archived workspace's panel.
7041 // Switching back to that workspace shows the draft as active_entry.
7042 agent_ui::test_support::init_test(cx);
7043 cx.update(|cx| {
7044 ThreadStore::init_global(cx);
7045 ThreadMetadataStore::init_global(cx);
7046 language_model::LanguageModelRegistry::test(cx);
7047 prompt_store::init(cx);
7048 });
7049
7050 let fs = FakeFs::new(cx.executor());
7051 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
7052 .await;
7053 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
7054 .await;
7055 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7056
7057 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
7058 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
7059
7060 let (multi_workspace, cx) =
7061 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
7062 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
7063
7064 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
7065 mw.test_add_workspace(project_b.clone(), window, cx)
7066 });
7067 let _panel_b = add_agent_panel(&workspace_b, cx);
7068 cx.run_until_parked();
7069
7070 // Create a thread in project-a's panel (currently non-active).
7071 let connection = acp_thread::StubAgentConnection::new();
7072 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7073 acp::ContentChunk::new("Done".into()),
7074 )]);
7075 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
7076 agent_ui::test_support::send_message(&panel_a, cx);
7077 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
7078 cx.run_until_parked();
7079
7080 // Archive it while project-b is active.
7081 sidebar.update_in(cx, |sidebar, window, cx| {
7082 sidebar.archive_thread(&thread_a, window, cx);
7083 });
7084 cx.run_until_parked();
7085
7086 // Switch back to project-a. Its panel was cleared during archiving
7087 // (clear_base_view activated a draft), so active_entry should point
7088 // to the draft on workspace_a.
7089 let workspace_a =
7090 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7091 multi_workspace.update_in(cx, |mw, window, cx| {
7092 mw.activate(workspace_a.clone(), None, window, cx);
7093 });
7094 cx.run_until_parked();
7095
7096 sidebar.update_in(cx, |sidebar, _window, cx| {
7097 sidebar.update_entries(cx);
7098 });
7099 cx.run_until_parked();
7100
7101 sidebar.read_with(cx, |sidebar, _| {
7102 assert_active_draft(
7103 sidebar,
7104 &workspace_a,
7105 "after switching to workspace with archived thread, active_entry should be the draft",
7106 );
7107 });
7108}
7109
7110#[gpui::test]
7111async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
7112 let project = init_test_project("/my-project", cx).await;
7113 let (multi_workspace, cx) =
7114 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7115 let sidebar = setup_sidebar(&multi_workspace, cx);
7116
7117 save_thread_metadata(
7118 acp::SessionId::new(Arc::from("visible-thread")),
7119 Some("Visible Thread".into()),
7120 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7121 None,
7122 None,
7123 &project,
7124 cx,
7125 );
7126
7127 let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
7128 save_thread_metadata(
7129 archived_thread_session_id.clone(),
7130 Some("Archived Thread".into()),
7131 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7132 None,
7133 None,
7134 &project,
7135 cx,
7136 );
7137
7138 cx.update(|_, cx| {
7139 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7140 let thread_id = store
7141 .entries()
7142 .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
7143 .map(|e| e.thread_id)
7144 .unwrap();
7145 store.archive(thread_id, None, cx)
7146 })
7147 });
7148 cx.run_until_parked();
7149
7150 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7151 cx.run_until_parked();
7152
7153 let entries = visible_entries_as_strings(&sidebar, cx);
7154 assert!(
7155 entries.iter().any(|e| e.contains("Visible Thread")),
7156 "expected visible thread in sidebar, got: {entries:?}"
7157 );
7158 assert!(
7159 !entries.iter().any(|e| e.contains("Archived Thread")),
7160 "expected archived thread to be hidden from sidebar, got: {entries:?}"
7161 );
7162
7163 cx.update(|_, cx| {
7164 let store = ThreadMetadataStore::global(cx);
7165 let all: Vec<_> = store.read(cx).entries().collect();
7166 assert_eq!(
7167 all.len(),
7168 2,
7169 "expected 2 total entries in the store, got: {}",
7170 all.len()
7171 );
7172
7173 let archived: Vec<_> = store.read(cx).archived_entries().collect();
7174 assert_eq!(archived.len(), 1);
7175 assert_eq!(
7176 archived[0].session_id.as_ref().unwrap().0.as_ref(),
7177 "archived-thread"
7178 );
7179 });
7180}
7181
7182#[gpui::test]
7183async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
7184 cx: &mut TestAppContext,
7185) {
7186 // When a linked worktree has a single thread and that thread is archived,
7187 // the sidebar must NOT create a new thread on the same worktree (which
7188 // would prevent the worktree from being cleaned up on disk). Instead,
7189 // archive_thread switches to a sibling thread on the main workspace (or
7190 // creates a draft there) before archiving the metadata.
7191 agent_ui::test_support::init_test(cx);
7192 cx.update(|cx| {
7193 ThreadStore::init_global(cx);
7194 ThreadMetadataStore::init_global(cx);
7195 language_model::LanguageModelRegistry::test(cx);
7196 prompt_store::init(cx);
7197 });
7198
7199 let fs = FakeFs::new(cx.executor());
7200
7201 fs.insert_tree(
7202 "/project",
7203 serde_json::json!({
7204 ".git": {},
7205 "src": {},
7206 }),
7207 )
7208 .await;
7209
7210 fs.add_linked_worktree_for_repo(
7211 Path::new("/project/.git"),
7212 false,
7213 git::repository::Worktree {
7214 path: std::path::PathBuf::from("/wt-ochre-drift"),
7215 ref_name: Some("refs/heads/ochre-drift".into()),
7216 sha: "aaa".into(),
7217 is_main: false,
7218 is_bare: false,
7219 },
7220 )
7221 .await;
7222
7223 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7224
7225 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7226 let worktree_project =
7227 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7228
7229 main_project
7230 .update(cx, |p, cx| p.git_scans_complete(cx))
7231 .await;
7232 worktree_project
7233 .update(cx, |p, cx| p.git_scans_complete(cx))
7234 .await;
7235
7236 let (multi_workspace, cx) =
7237 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7238
7239 let sidebar = setup_sidebar(&multi_workspace, cx);
7240
7241 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7242 mw.test_add_workspace(worktree_project.clone(), window, cx)
7243 });
7244
7245 // Set up both workspaces with agent panels.
7246 let main_workspace =
7247 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7248 let _main_panel = add_agent_panel(&main_workspace, cx);
7249 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7250
7251 // Activate the linked worktree workspace so the sidebar tracks it.
7252 multi_workspace.update_in(cx, |mw, window, cx| {
7253 mw.activate(worktree_workspace.clone(), None, window, cx);
7254 });
7255
7256 // Open a thread in the linked worktree panel and send a message
7257 // so it becomes the active thread.
7258 let connection = StubAgentConnection::new();
7259 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7260 send_message(&worktree_panel, cx);
7261
7262 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7263
7264 // Give the thread a response chunk so it has content.
7265 cx.update(|_, cx| {
7266 connection.send_update(
7267 worktree_thread_id.clone(),
7268 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7269 cx,
7270 );
7271 });
7272
7273 // Save the worktree thread's metadata.
7274 save_thread_metadata(
7275 worktree_thread_id.clone(),
7276 Some("Ochre Drift Thread".into()),
7277 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7278 None,
7279 None,
7280 &worktree_project,
7281 cx,
7282 );
7283
7284 // Also save a thread on the main project so there's a sibling in the
7285 // group that can be selected after archiving.
7286 save_thread_metadata(
7287 acp::SessionId::new(Arc::from("main-project-thread")),
7288 Some("Main Project Thread".into()),
7289 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7290 None,
7291 None,
7292 &main_project,
7293 cx,
7294 );
7295
7296 cx.run_until_parked();
7297
7298 // Verify the linked worktree thread appears with its chip.
7299 // The live thread title comes from the message text ("Hello"), not
7300 // the metadata title we saved.
7301 let entries_before = visible_entries_as_strings(&sidebar, cx);
7302 assert!(
7303 entries_before
7304 .iter()
7305 .any(|s| s.contains("{wt-ochre-drift}")),
7306 "expected worktree thread with chip before archiving, got: {entries_before:?}"
7307 );
7308 assert!(
7309 entries_before
7310 .iter()
7311 .any(|s| s.contains("Main Project Thread")),
7312 "expected main project thread before archiving, got: {entries_before:?}"
7313 );
7314
7315 // Confirm the worktree thread is the active entry.
7316 sidebar.read_with(cx, |s, _| {
7317 assert_active_thread(
7318 s,
7319 &worktree_thread_id,
7320 "worktree thread should be active before archiving",
7321 );
7322 });
7323
7324 // Archive the worktree thread — it's the only thread using ochre-drift.
7325 sidebar.update_in(cx, |sidebar, window, cx| {
7326 sidebar.archive_thread(&worktree_thread_id, window, cx);
7327 });
7328
7329 cx.run_until_parked();
7330
7331 // The archived thread should no longer appear in the sidebar.
7332 let entries_after = visible_entries_as_strings(&sidebar, cx);
7333 assert!(
7334 !entries_after
7335 .iter()
7336 .any(|s| s.contains("Ochre Drift Thread")),
7337 "archived thread should be hidden, got: {entries_after:?}"
7338 );
7339
7340 // No "+ New Thread" entry should appear with the ochre-drift worktree
7341 // chip — that would keep the worktree alive and prevent cleanup.
7342 assert!(
7343 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7344 "no entry should reference the archived worktree, got: {entries_after:?}"
7345 );
7346
7347 // The main project thread should still be visible.
7348 assert!(
7349 entries_after
7350 .iter()
7351 .any(|s| s.contains("Main Project Thread")),
7352 "main project thread should still be visible, got: {entries_after:?}"
7353 );
7354}
7355
7356#[gpui::test]
7357async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
7358 cx: &mut TestAppContext,
7359) {
7360 // When a linked worktree thread is the ONLY thread in the project group
7361 // (no threads on the main repo either), archiving it should leave the
7362 // group empty with no active entry.
7363 agent_ui::test_support::init_test(cx);
7364 cx.update(|cx| {
7365 ThreadStore::init_global(cx);
7366 ThreadMetadataStore::init_global(cx);
7367 language_model::LanguageModelRegistry::test(cx);
7368 prompt_store::init(cx);
7369 });
7370
7371 let fs = FakeFs::new(cx.executor());
7372
7373 fs.insert_tree(
7374 "/project",
7375 serde_json::json!({
7376 ".git": {},
7377 "src": {},
7378 }),
7379 )
7380 .await;
7381
7382 fs.add_linked_worktree_for_repo(
7383 Path::new("/project/.git"),
7384 false,
7385 git::repository::Worktree {
7386 path: std::path::PathBuf::from("/wt-ochre-drift"),
7387 ref_name: Some("refs/heads/ochre-drift".into()),
7388 sha: "aaa".into(),
7389 is_main: false,
7390 is_bare: false,
7391 },
7392 )
7393 .await;
7394
7395 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7396
7397 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7398 let worktree_project =
7399 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7400
7401 main_project
7402 .update(cx, |p, cx| p.git_scans_complete(cx))
7403 .await;
7404 worktree_project
7405 .update(cx, |p, cx| p.git_scans_complete(cx))
7406 .await;
7407
7408 let (multi_workspace, cx) =
7409 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7410
7411 let sidebar = setup_sidebar(&multi_workspace, cx);
7412
7413 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7414 mw.test_add_workspace(worktree_project.clone(), window, cx)
7415 });
7416
7417 let main_workspace =
7418 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7419 let _main_panel = add_agent_panel(&main_workspace, cx);
7420 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7421
7422 // Activate the linked worktree workspace.
7423 multi_workspace.update_in(cx, |mw, window, cx| {
7424 mw.activate(worktree_workspace.clone(), None, window, cx);
7425 });
7426
7427 // Open a thread on the linked worktree — this is the ONLY thread.
7428 let connection = StubAgentConnection::new();
7429 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7430 send_message(&worktree_panel, cx);
7431
7432 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7433
7434 cx.update(|_, cx| {
7435 connection.send_update(
7436 worktree_thread_id.clone(),
7437 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7438 cx,
7439 );
7440 });
7441
7442 save_thread_metadata(
7443 worktree_thread_id.clone(),
7444 Some("Ochre Drift Thread".into()),
7445 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7446 None,
7447 None,
7448 &worktree_project,
7449 cx,
7450 );
7451
7452 cx.run_until_parked();
7453
7454 // Archive it — there are no other threads in the group.
7455 sidebar.update_in(cx, |sidebar, window, cx| {
7456 sidebar.archive_thread(&worktree_thread_id, window, cx);
7457 });
7458
7459 cx.run_until_parked();
7460
7461 let entries_after = visible_entries_as_strings(&sidebar, cx);
7462
7463 // No entry should reference the linked worktree.
7464 assert!(
7465 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7466 "no entry should reference the archived worktree, got: {entries_after:?}"
7467 );
7468
7469 // The active entry should be None — no draft is created.
7470 sidebar.read_with(cx, |s, _| {
7471 assert!(
7472 s.active_entry.is_none(),
7473 "expected no active entry after archiving the last thread, got: {:?}",
7474 s.active_entry,
7475 );
7476 });
7477}
7478
7479#[gpui::test]
7480async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
7481 cx: &mut TestAppContext,
7482) {
7483 // When an archived thread belongs to a linked worktree whose main repo is
7484 // already open, unarchiving should reopen the linked workspace into the
7485 // same project group and show only the restored real thread row.
7486 agent_ui::test_support::init_test(cx);
7487 cx.update(|cx| {
7488 ThreadStore::init_global(cx);
7489 ThreadMetadataStore::init_global(cx);
7490 language_model::LanguageModelRegistry::test(cx);
7491 prompt_store::init(cx);
7492 });
7493
7494 let fs = FakeFs::new(cx.executor());
7495
7496 fs.insert_tree(
7497 "/project",
7498 serde_json::json!({
7499 ".git": {},
7500 "src": {},
7501 }),
7502 )
7503 .await;
7504
7505 fs.insert_tree(
7506 "/wt-ochre-drift",
7507 serde_json::json!({
7508 ".git": "gitdir: /project/.git/worktrees/ochre-drift",
7509 "src": {},
7510 }),
7511 )
7512 .await;
7513
7514 fs.add_linked_worktree_for_repo(
7515 Path::new("/project/.git"),
7516 false,
7517 git::repository::Worktree {
7518 path: std::path::PathBuf::from("/wt-ochre-drift"),
7519 ref_name: Some("refs/heads/ochre-drift".into()),
7520 sha: "aaa".into(),
7521 is_main: false,
7522 is_bare: false,
7523 },
7524 )
7525 .await;
7526
7527 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7528
7529 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7530 let worktree_project =
7531 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7532
7533 main_project
7534 .update(cx, |p, cx| p.git_scans_complete(cx))
7535 .await;
7536 worktree_project
7537 .update(cx, |p, cx| p.git_scans_complete(cx))
7538 .await;
7539
7540 let (multi_workspace, cx) =
7541 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7542
7543 let sidebar = setup_sidebar(&multi_workspace, cx);
7544 let main_workspace =
7545 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7546 let _main_panel = add_agent_panel(&main_workspace, cx);
7547 cx.run_until_parked();
7548
7549 let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
7550 let original_thread_id = ThreadId::new();
7551 let main_paths = PathList::new(&[PathBuf::from("/project")]);
7552 let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
7553
7554 cx.update(|_, cx| {
7555 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7556 store.save(
7557 ThreadMetadata {
7558 thread_id: original_thread_id,
7559 session_id: Some(session_id.clone()),
7560 agent_id: agent::ZED_AGENT_ID.clone(),
7561 title: Some("Unarchived Linked Thread".into()),
7562 updated_at: Utc::now(),
7563 created_at: None,
7564 interacted_at: None,
7565 worktree_paths: WorktreePaths::from_path_lists(
7566 main_paths.clone(),
7567 folder_paths.clone(),
7568 )
7569 .expect("main and folder paths should be well-formed"),
7570 archived: true,
7571 remote_connection: None,
7572 },
7573 cx,
7574 )
7575 });
7576 });
7577 cx.run_until_parked();
7578
7579 let metadata = cx.update(|_, cx| {
7580 ThreadMetadataStore::global(cx)
7581 .read(cx)
7582 .entry(original_thread_id)
7583 .cloned()
7584 .expect("archived linked-worktree metadata should exist before restore")
7585 });
7586
7587 sidebar.update_in(cx, |sidebar, window, cx| {
7588 sidebar.open_thread_from_archive(metadata, window, cx);
7589 });
7590 cx.run_until_parked();
7591
7592 assert_eq!(
7593 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7594 2,
7595 "expected unarchive to open the linked worktree workspace into the project group"
7596 );
7597
7598 let session_entries = cx.update(|_, cx| {
7599 ThreadMetadataStore::global(cx)
7600 .read(cx)
7601 .entries()
7602 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7603 .cloned()
7604 .collect::<Vec<_>>()
7605 });
7606 assert_eq!(
7607 session_entries.len(),
7608 1,
7609 "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
7610 );
7611 assert_eq!(
7612 session_entries[0].thread_id, original_thread_id,
7613 "expected unarchive to reuse the original linked worktree thread id"
7614 );
7615 assert!(
7616 !session_entries[0].archived,
7617 "expected restored linked worktree metadata to be unarchived, got: {:?}",
7618 session_entries[0]
7619 );
7620
7621 let assert_no_extra_rows = |entries: &[String]| {
7622 let real_thread_rows = entries
7623 .iter()
7624 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7625 .filter(|entry| !entry.contains("Draft"))
7626 .count();
7627 assert_eq!(
7628 real_thread_rows, 1,
7629 "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
7630 );
7631 assert!(
7632 !entries.iter().any(|entry| entry.contains("Draft")),
7633 "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
7634 );
7635 assert!(
7636 !entries
7637 .iter()
7638 .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
7639 "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
7640 );
7641 assert!(
7642 entries
7643 .iter()
7644 .any(|entry| entry.contains("Unarchived Linked Thread")),
7645 "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
7646 );
7647 };
7648
7649 let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
7650 assert_no_extra_rows(&entries_after_restore);
7651
7652 // The reported bug may only appear after an extra scheduling turn.
7653 cx.run_until_parked();
7654
7655 let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
7656 assert_no_extra_rows(&entries_after_extra_turns);
7657}
7658
7659#[gpui::test]
7660async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
7661 // When a linked worktree thread is archived but the group has other
7662 // threads (e.g. on the main project), archive_thread should select
7663 // the nearest sibling.
7664 agent_ui::test_support::init_test(cx);
7665 cx.update(|cx| {
7666 ThreadStore::init_global(cx);
7667 ThreadMetadataStore::init_global(cx);
7668 language_model::LanguageModelRegistry::test(cx);
7669 prompt_store::init(cx);
7670 });
7671
7672 let fs = FakeFs::new(cx.executor());
7673
7674 fs.insert_tree(
7675 "/project",
7676 serde_json::json!({
7677 ".git": {},
7678 "src": {},
7679 }),
7680 )
7681 .await;
7682
7683 fs.add_linked_worktree_for_repo(
7684 Path::new("/project/.git"),
7685 false,
7686 git::repository::Worktree {
7687 path: std::path::PathBuf::from("/wt-ochre-drift"),
7688 ref_name: Some("refs/heads/ochre-drift".into()),
7689 sha: "aaa".into(),
7690 is_main: false,
7691 is_bare: false,
7692 },
7693 )
7694 .await;
7695
7696 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7697
7698 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7699 let worktree_project =
7700 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7701
7702 main_project
7703 .update(cx, |p, cx| p.git_scans_complete(cx))
7704 .await;
7705 worktree_project
7706 .update(cx, |p, cx| p.git_scans_complete(cx))
7707 .await;
7708
7709 let (multi_workspace, cx) =
7710 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7711
7712 let sidebar = setup_sidebar(&multi_workspace, cx);
7713
7714 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7715 mw.test_add_workspace(worktree_project.clone(), window, cx)
7716 });
7717
7718 let main_workspace =
7719 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7720 let _main_panel = add_agent_panel(&main_workspace, cx);
7721 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7722
7723 // Activate the linked worktree workspace.
7724 multi_workspace.update_in(cx, |mw, window, cx| {
7725 mw.activate(worktree_workspace.clone(), None, window, cx);
7726 });
7727
7728 // Open a thread on the linked worktree.
7729 let connection = StubAgentConnection::new();
7730 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7731 send_message(&worktree_panel, cx);
7732
7733 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7734
7735 cx.update(|_, cx| {
7736 connection.send_update(
7737 worktree_thread_id.clone(),
7738 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7739 cx,
7740 );
7741 });
7742
7743 save_thread_metadata(
7744 worktree_thread_id.clone(),
7745 Some("Ochre Drift Thread".into()),
7746 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7747 None,
7748 None,
7749 &worktree_project,
7750 cx,
7751 );
7752
7753 // Save a sibling thread on the main project.
7754 let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
7755 save_thread_metadata(
7756 main_thread_id,
7757 Some("Main Project Thread".into()),
7758 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7759 None,
7760 None,
7761 &main_project,
7762 cx,
7763 );
7764
7765 cx.run_until_parked();
7766
7767 // Confirm the worktree thread is active.
7768 sidebar.read_with(cx, |s, _| {
7769 assert_active_thread(
7770 s,
7771 &worktree_thread_id,
7772 "worktree thread should be active before archiving",
7773 );
7774 });
7775
7776 // Archive the worktree thread.
7777 sidebar.update_in(cx, |sidebar, window, cx| {
7778 sidebar.archive_thread(&worktree_thread_id, window, cx);
7779 });
7780
7781 cx.run_until_parked();
7782
7783 // The worktree workspace was removed and a draft was created on the
7784 // main workspace. No entry should reference the linked worktree.
7785 let entries_after = visible_entries_as_strings(&sidebar, cx);
7786 assert!(
7787 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7788 "no entry should reference the archived worktree, got: {entries_after:?}"
7789 );
7790
7791 // The main project thread should still be visible.
7792 assert!(
7793 entries_after
7794 .iter()
7795 .any(|s| s.contains("Main Project Thread")),
7796 "main project thread should still be visible, got: {entries_after:?}"
7797 );
7798}
7799
7800// TODO: Restore this test once linked worktree draft entries are re-implemented.
7801// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
7802#[gpui::test]
7803#[ignore = "linked worktree draft entries not yet implemented"]
7804async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
7805 init_test(cx);
7806 let fs = FakeFs::new(cx.executor());
7807
7808 fs.insert_tree(
7809 "/project",
7810 serde_json::json!({
7811 ".git": {
7812 "worktrees": {
7813 "feature-a": {
7814 "commondir": "../../",
7815 "HEAD": "ref: refs/heads/feature-a",
7816 },
7817 },
7818 },
7819 "src": {},
7820 }),
7821 )
7822 .await;
7823
7824 fs.insert_tree(
7825 "/wt-feature-a",
7826 serde_json::json!({
7827 ".git": "gitdir: /project/.git/worktrees/feature-a",
7828 "src": {},
7829 }),
7830 )
7831 .await;
7832
7833 fs.add_linked_worktree_for_repo(
7834 Path::new("/project/.git"),
7835 false,
7836 git::repository::Worktree {
7837 path: PathBuf::from("/wt-feature-a"),
7838 ref_name: Some("refs/heads/feature-a".into()),
7839 sha: "aaa".into(),
7840 is_main: false,
7841 is_bare: false,
7842 },
7843 )
7844 .await;
7845
7846 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7847
7848 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7849 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7850
7851 main_project
7852 .update(cx, |p, cx| p.git_scans_complete(cx))
7853 .await;
7854 worktree_project
7855 .update(cx, |p, cx| p.git_scans_complete(cx))
7856 .await;
7857
7858 let (multi_workspace, cx) =
7859 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7860 let sidebar = setup_sidebar(&multi_workspace, cx);
7861
7862 // Open the linked worktree as a separate workspace (simulates cmd-o).
7863 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7864 mw.test_add_workspace(worktree_project.clone(), window, cx)
7865 });
7866 add_agent_panel(&worktree_workspace, cx);
7867 cx.run_until_parked();
7868
7869 // Explicitly create a draft thread from the linked worktree workspace.
7870 // Auto-created drafts use the group's first workspace (the main one),
7871 // so a user-created draft is needed to make the linked worktree reachable.
7872 sidebar.update_in(cx, |sidebar, window, cx| {
7873 sidebar.create_new_thread(&worktree_workspace, window, cx);
7874 });
7875 cx.run_until_parked();
7876
7877 // Switch back to the main workspace.
7878 multi_workspace.update_in(cx, |mw, window, cx| {
7879 let main_ws = mw.workspaces().next().unwrap().clone();
7880 mw.activate(main_ws, None, window, cx);
7881 });
7882 cx.run_until_parked();
7883
7884 sidebar.update_in(cx, |sidebar, _window, cx| {
7885 sidebar.update_entries(cx);
7886 });
7887 cx.run_until_parked();
7888
7889 // The linked worktree workspace must be reachable from some sidebar entry.
7890 let worktree_ws_id = worktree_workspace.entity_id();
7891 let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
7892 let mw = multi_workspace.read(cx);
7893 sidebar
7894 .contents
7895 .entries
7896 .iter()
7897 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
7898 .map(|ws| ws.entity_id())
7899 .collect()
7900 });
7901 assert!(
7902 reachable.contains(&worktree_ws_id),
7903 "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
7904 );
7905
7906 // Find the draft Thread entry whose workspace is the linked worktree.
7907 let _ = (worktree_ws_id, sidebar, multi_workspace);
7908 // todo("re-implement once linked worktree draft entries exist");
7909}
7910
7911#[gpui::test]
7912async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
7913 // When only a linked worktree workspace is open (not the main repo),
7914 // threads saved against the main repo should still appear in the sidebar.
7915 init_test(cx);
7916 let fs = FakeFs::new(cx.executor());
7917
7918 // Create the main repo with a linked worktree.
7919 fs.insert_tree(
7920 "/project",
7921 serde_json::json!({
7922 ".git": {
7923 "worktrees": {
7924 "feature-a": {
7925 "commondir": "../../",
7926 "HEAD": "ref: refs/heads/feature-a",
7927 },
7928 },
7929 },
7930 "src": {},
7931 }),
7932 )
7933 .await;
7934
7935 fs.insert_tree(
7936 "/wt-feature-a",
7937 serde_json::json!({
7938 ".git": "gitdir: /project/.git/worktrees/feature-a",
7939 "src": {},
7940 }),
7941 )
7942 .await;
7943
7944 fs.add_linked_worktree_for_repo(
7945 std::path::Path::new("/project/.git"),
7946 false,
7947 git::repository::Worktree {
7948 path: std::path::PathBuf::from("/wt-feature-a"),
7949 ref_name: Some("refs/heads/feature-a".into()),
7950 sha: "abc".into(),
7951 is_main: false,
7952 is_bare: false,
7953 },
7954 )
7955 .await;
7956
7957 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7958
7959 // Only open the linked worktree as a workspace — NOT the main repo.
7960 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7961 worktree_project
7962 .update(cx, |p, cx| p.git_scans_complete(cx))
7963 .await;
7964
7965 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7966 main_project
7967 .update(cx, |p, cx| p.git_scans_complete(cx))
7968 .await;
7969
7970 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7971 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
7972 });
7973 let sidebar = setup_sidebar(&multi_workspace, cx);
7974
7975 // Save a thread against the MAIN repo path.
7976 save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
7977
7978 // Save a thread against the linked worktree path.
7979 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
7980
7981 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7982 cx.run_until_parked();
7983
7984 // Both threads should be visible: the worktree thread by direct lookup,
7985 // and the main repo thread because the workspace is a linked worktree
7986 // and we also query the main repo path.
7987 let entries = visible_entries_as_strings(&sidebar, cx);
7988 assert!(
7989 entries.iter().any(|e| e.contains("Main Repo Thread")),
7990 "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
7991 );
7992 assert!(
7993 entries.iter().any(|e| e.contains("Worktree Thread")),
7994 "expected worktree thread to be visible, got: {entries:?}"
7995 );
7996}
7997
7998async fn init_multi_project_test(
7999 paths: &[&str],
8000 cx: &mut TestAppContext,
8001) -> (Arc<FakeFs>, Entity<project::Project>) {
8002 agent_ui::test_support::init_test(cx);
8003 cx.update(|cx| {
8004 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
8005 ThreadStore::init_global(cx);
8006 ThreadMetadataStore::init_global(cx);
8007 language_model::LanguageModelRegistry::test(cx);
8008 prompt_store::init(cx);
8009 });
8010 let fs = FakeFs::new(cx.executor());
8011 for path in paths {
8012 fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
8013 .await;
8014 }
8015 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8016 let project =
8017 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
8018 (fs, project)
8019}
8020
8021async fn add_test_project(
8022 path: &str,
8023 fs: &Arc<FakeFs>,
8024 multi_workspace: &Entity<MultiWorkspace>,
8025 cx: &mut gpui::VisualTestContext,
8026) -> Entity<Workspace> {
8027 let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
8028 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8029 mw.test_add_workspace(project, window, cx)
8030 });
8031 cx.run_until_parked();
8032 workspace
8033}
8034
8035#[gpui::test]
8036async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
8037 let (fs, project_a) =
8038 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
8039 let (multi_workspace, cx) =
8040 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8041 let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
8042
8043 // Sidebar starts closed. Initial workspace A is transient.
8044 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8045 assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
8046 assert_eq!(
8047 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8048 1
8049 );
8050 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
8051
8052 // Add B — replaces A as the transient workspace.
8053 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8054 assert_eq!(
8055 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8056 1
8057 );
8058 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
8059
8060 // Add C — replaces B as the transient workspace.
8061 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8062 assert_eq!(
8063 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8064 1
8065 );
8066 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8067}
8068
8069#[gpui::test]
8070async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
8071 let (fs, project_a) = init_multi_project_test(
8072 &["/project-a", "/project-b", "/project-c", "/project-d"],
8073 cx,
8074 )
8075 .await;
8076 let (multi_workspace, cx) =
8077 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8078 let _sidebar = setup_sidebar(&multi_workspace, cx);
8079 assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
8080
8081 // Add B — retained since sidebar is open.
8082 let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8083 assert_eq!(
8084 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8085 2
8086 );
8087
8088 // Switch to A — B survives. (Switching from one internal workspace, to another)
8089 multi_workspace.update_in(cx, |mw, window, cx| {
8090 mw.activate(workspace_a, None, window, cx)
8091 });
8092 cx.run_until_parked();
8093 assert_eq!(
8094 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8095 2
8096 );
8097
8098 // Close sidebar — both A and B remain retained.
8099 multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
8100 cx.run_until_parked();
8101 assert_eq!(
8102 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8103 2
8104 );
8105
8106 // Add C — added as new transient workspace. (switching from retained, to transient)
8107 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8108 assert_eq!(
8109 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8110 3
8111 );
8112 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8113
8114 // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
8115 let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
8116 assert_eq!(
8117 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8118 3
8119 );
8120 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
8121}
8122
8123#[gpui::test]
8124async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
8125 let (fs, project_a) =
8126 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
8127 let (multi_workspace, cx) =
8128 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8129 setup_sidebar_closed(&multi_workspace, cx);
8130
8131 // Add B — replaces A as the transient workspace (A is discarded).
8132 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8133 assert_eq!(
8134 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8135 1
8136 );
8137 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
8138
8139 // Open sidebar — promotes the transient B to retained.
8140 multi_workspace.update_in(cx, |mw, window, cx| {
8141 mw.toggle_sidebar(window, cx);
8142 });
8143 cx.run_until_parked();
8144 assert_eq!(
8145 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8146 1
8147 );
8148 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
8149
8150 // Close sidebar — the retained B remains.
8151 multi_workspace.update_in(cx, |mw, window, cx| {
8152 mw.toggle_sidebar(window, cx);
8153 });
8154
8155 // Add C — added as new transient workspace.
8156 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8157 assert_eq!(
8158 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8159 2
8160 );
8161 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8162}
8163
8164#[gpui::test]
8165async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
8166 init_test(cx);
8167 let fs = FakeFs::new(cx.executor());
8168
8169 fs.insert_tree(
8170 "/project",
8171 serde_json::json!({
8172 ".git": {
8173 "worktrees": {
8174 "feature-a": {
8175 "commondir": "../../",
8176 "HEAD": "ref: refs/heads/feature-a",
8177 },
8178 },
8179 },
8180 "src": {},
8181 }),
8182 )
8183 .await;
8184
8185 fs.insert_tree(
8186 "/wt-feature-a",
8187 serde_json::json!({
8188 ".git": "gitdir: /project/.git/worktrees/feature-a",
8189 "src": {},
8190 }),
8191 )
8192 .await;
8193
8194 fs.add_linked_worktree_for_repo(
8195 Path::new("/project/.git"),
8196 false,
8197 git::repository::Worktree {
8198 path: PathBuf::from("/wt-feature-a"),
8199 ref_name: Some("refs/heads/feature-a".into()),
8200 sha: "abc".into(),
8201 is_main: false,
8202 is_bare: false,
8203 },
8204 )
8205 .await;
8206
8207 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8208
8209 // Only a linked worktree workspace is open — no workspace for /project.
8210 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
8211 worktree_project
8212 .update(cx, |p, cx| p.git_scans_complete(cx))
8213 .await;
8214
8215 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
8216 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
8217 });
8218 let sidebar = setup_sidebar(&multi_workspace, cx);
8219
8220 // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
8221 let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
8222 cx.update(|_, cx| {
8223 let metadata = ThreadMetadata {
8224 thread_id: ThreadId::new(),
8225 session_id: Some(legacy_session.clone()),
8226 agent_id: agent::ZED_AGENT_ID.clone(),
8227 title: Some("Legacy Main Thread".into()),
8228 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8229 created_at: None,
8230 interacted_at: None,
8231 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
8232 "/project",
8233 )])),
8234 archived: false,
8235 remote_connection: None,
8236 };
8237 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
8238 });
8239 cx.run_until_parked();
8240
8241 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
8242 cx.run_until_parked();
8243
8244 // The legacy thread should appear in the sidebar under the project group.
8245 let entries = visible_entries_as_strings(&sidebar, cx);
8246 assert!(
8247 entries.iter().any(|e| e.contains("Legacy Main Thread")),
8248 "legacy thread should be visible: {entries:?}",
8249 );
8250
8251 // Verify only 1 workspace before clicking.
8252 assert_eq!(
8253 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8254 1,
8255 );
8256
8257 // Focus and select the legacy thread, then confirm.
8258 focus_sidebar(&sidebar, cx);
8259 let thread_index = sidebar.read_with(cx, |sidebar, _| {
8260 sidebar
8261 .contents
8262 .entries
8263 .iter()
8264 .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
8265 .expect("legacy thread should be in entries")
8266 });
8267 sidebar.update_in(cx, |sidebar, _window, _cx| {
8268 sidebar.selection = Some(thread_index);
8269 });
8270 cx.dispatch_action(Confirm);
8271 cx.run_until_parked();
8272
8273 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8274 let new_path_list =
8275 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
8276 assert_eq!(
8277 new_path_list,
8278 PathList::new(&[PathBuf::from("/project")]),
8279 "the new workspace should be for the main repo, not the linked worktree",
8280 );
8281}
8282
8283#[gpui::test]
8284async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
8285 cx: &mut TestAppContext,
8286) {
8287 // Regression test for a property-test finding:
8288 // AddLinkedWorktree { project_group_index: 0 }
8289 // AddProject { use_worktree: true }
8290 // AddProject { use_worktree: false }
8291 // After these three steps, the linked-worktree workspace was not
8292 // reachable from any sidebar entry.
8293 agent_ui::test_support::init_test(cx);
8294 cx.update(|cx| {
8295 ThreadStore::init_global(cx);
8296 ThreadMetadataStore::init_global(cx);
8297 language_model::LanguageModelRegistry::test(cx);
8298 prompt_store::init(cx);
8299
8300 cx.observe_new(
8301 |workspace: &mut Workspace,
8302 window: Option<&mut Window>,
8303 cx: &mut gpui::Context<Workspace>| {
8304 if let Some(window) = window {
8305 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
8306 workspace.add_panel(panel, window, cx);
8307 }
8308 },
8309 )
8310 .detach();
8311 });
8312
8313 let fs = FakeFs::new(cx.executor());
8314 fs.insert_tree(
8315 "/my-project",
8316 serde_json::json!({
8317 ".git": {},
8318 "src": {},
8319 }),
8320 )
8321 .await;
8322 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8323 let project =
8324 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
8325 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8326
8327 let (multi_workspace, cx) =
8328 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8329 let sidebar = setup_sidebar(&multi_workspace, cx);
8330
8331 // Step 1: Create a linked worktree for the main project.
8332 let worktree_name = "wt-0";
8333 let worktree_path = "/worktrees/wt-0";
8334
8335 fs.insert_tree(
8336 worktree_path,
8337 serde_json::json!({
8338 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8339 "src": {},
8340 }),
8341 )
8342 .await;
8343 fs.insert_tree(
8344 "/my-project/.git/worktrees/wt-0",
8345 serde_json::json!({
8346 "commondir": "../../",
8347 "HEAD": "ref: refs/heads/wt-0",
8348 }),
8349 )
8350 .await;
8351 fs.add_linked_worktree_for_repo(
8352 Path::new("/my-project/.git"),
8353 false,
8354 git::repository::Worktree {
8355 path: PathBuf::from(worktree_path),
8356 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
8357 sha: "aaa".into(),
8358 is_main: false,
8359 is_bare: false,
8360 },
8361 )
8362 .await;
8363
8364 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8365 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
8366 main_project
8367 .update(cx, |p, cx| p.git_scans_complete(cx))
8368 .await;
8369 cx.run_until_parked();
8370
8371 // Step 2: Open the linked worktree as its own workspace.
8372 let worktree_project =
8373 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
8374 worktree_project
8375 .update(cx, |p, cx| p.git_scans_complete(cx))
8376 .await;
8377 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8378 mw.test_add_workspace(worktree_project.clone(), window, cx)
8379 });
8380 cx.run_until_parked();
8381
8382 // Step 3: Add an unrelated project.
8383 fs.insert_tree(
8384 "/other-project",
8385 serde_json::json!({
8386 ".git": {},
8387 "src": {},
8388 }),
8389 )
8390 .await;
8391 let other_project = project::Project::test(
8392 fs.clone() as Arc<dyn fs::Fs>,
8393 ["/other-project".as_ref()],
8394 cx,
8395 )
8396 .await;
8397 other_project
8398 .update(cx, |p, cx| p.git_scans_complete(cx))
8399 .await;
8400 multi_workspace.update_in(cx, |mw, window, cx| {
8401 mw.test_add_workspace(other_project.clone(), window, cx);
8402 });
8403 cx.run_until_parked();
8404
8405 // Force a full sidebar rebuild with all groups expanded.
8406 sidebar.update_in(cx, |sidebar, _window, cx| {
8407 if let Some(mw) = sidebar.multi_workspace.upgrade() {
8408 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
8409 }
8410 sidebar.update_entries(cx);
8411 });
8412 cx.run_until_parked();
8413
8414 // The linked-worktree workspace must be reachable from at least one
8415 // sidebar entry — otherwise the user has no way to navigate to it.
8416 let worktree_ws_id = worktree_workspace.entity_id();
8417 let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
8418 let mw = multi_workspace.read(cx);
8419
8420 let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
8421 let reachable: HashSet<gpui::EntityId> = sidebar
8422 .contents
8423 .entries
8424 .iter()
8425 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
8426 .map(|ws| ws.entity_id())
8427 .collect();
8428 (all, reachable)
8429 });
8430
8431 let unreachable = &all_ids - &reachable_ids;
8432 eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
8433
8434 assert!(
8435 unreachable.is_empty(),
8436 "workspaces not reachable from any sidebar entry: {:?}\n\
8437 (linked-worktree workspace id: {:?})",
8438 unreachable,
8439 worktree_ws_id,
8440 );
8441}
8442
8443#[gpui::test]
8444async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
8445 // Empty project groups no longer auto-create drafts via reconciliation.
8446 // A fresh startup with no restorable thread should show only the header.
8447 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8448 let (multi_workspace, cx) =
8449 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8450 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8451
8452 let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8453
8454 let entries = visible_entries_as_strings(&sidebar, cx);
8455 assert_eq!(
8456 entries,
8457 vec!["v [my-project]"],
8458 "empty group should show only the header, no auto-created draft"
8459 );
8460}
8461
8462#[gpui::test]
8463async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
8464 // Rule 5: When the app starts and the AgentPanel successfully loads
8465 // a thread, no spurious draft should appear.
8466 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8467 let (multi_workspace, cx) =
8468 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8469 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8470
8471 // Create and send a message to make a real thread.
8472 let connection = StubAgentConnection::new();
8473 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8474 acp::ContentChunk::new("Done".into()),
8475 )]);
8476 open_thread_with_connection(&panel, connection, cx);
8477 send_message(&panel, cx);
8478 let session_id = active_session_id(&panel, cx);
8479 save_test_thread_metadata(&session_id, &project, cx).await;
8480 cx.run_until_parked();
8481
8482 // Should show the thread, NOT a spurious draft.
8483 let entries = visible_entries_as_strings(&sidebar, cx);
8484 assert_eq!(entries, vec!["v [my-project]", " Hello *"]);
8485
8486 // active_entry should be Thread, not Draft.
8487 sidebar.read_with(cx, |sidebar, _| {
8488 assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
8489 });
8490}
8491
8492#[gpui::test]
8493async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
8494 // Rule 9: Clicking a project header should restore whatever the
8495 // user was last looking at in that group, not create new drafts
8496 // or jump to the first entry.
8497 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
8498 let (multi_workspace, cx) =
8499 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8500 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8501
8502 // Create two threads in project-a.
8503 let conn1 = StubAgentConnection::new();
8504 conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8505 acp::ContentChunk::new("Done".into()),
8506 )]);
8507 open_thread_with_connection(&panel_a, conn1, cx);
8508 send_message(&panel_a, cx);
8509 let thread_a1 = active_session_id(&panel_a, cx);
8510 save_test_thread_metadata(&thread_a1, &project_a, cx).await;
8511
8512 let conn2 = StubAgentConnection::new();
8513 conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8514 acp::ContentChunk::new("Done".into()),
8515 )]);
8516 open_thread_with_connection(&panel_a, conn2, cx);
8517 send_message(&panel_a, cx);
8518 let thread_a2 = active_session_id(&panel_a, cx);
8519 save_test_thread_metadata(&thread_a2, &project_a, cx).await;
8520 cx.run_until_parked();
8521
8522 // The user is now looking at thread_a2.
8523 sidebar.read_with(cx, |sidebar, _| {
8524 assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
8525 });
8526
8527 // Add project-b and switch to it.
8528 let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
8529 fs.as_fake()
8530 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
8531 .await;
8532 let project_b =
8533 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8534 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8535 mw.test_add_workspace(project_b.clone(), window, cx)
8536 });
8537 let _panel_b = add_agent_panel(&workspace_b, cx);
8538 cx.run_until_parked();
8539
8540 // Now switch BACK to project-a by activating its workspace.
8541 let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
8542 mw.workspaces()
8543 .find(|ws| {
8544 ws.read(cx)
8545 .project()
8546 .read(cx)
8547 .visible_worktrees(cx)
8548 .any(|wt| {
8549 wt.read(cx)
8550 .abs_path()
8551 .to_string_lossy()
8552 .contains("project-a")
8553 })
8554 })
8555 .unwrap()
8556 .clone()
8557 });
8558 multi_workspace.update_in(cx, |mw, window, cx| {
8559 mw.activate(workspace_a.clone(), None, window, cx);
8560 });
8561 cx.run_until_parked();
8562
8563 // The panel should still show thread_a2 (the last thing the user
8564 // was viewing in project-a), not a draft or thread_a1.
8565 sidebar.read_with(cx, |sidebar, _| {
8566 assert_active_thread(
8567 sidebar,
8568 &thread_a2,
8569 "switching back to project-a should restore thread_a2",
8570 );
8571 });
8572
8573 // No spurious draft entries should have been created in
8574 // project-a's group (project-b may have a placeholder).
8575 let entries = visible_entries_as_strings(&sidebar, cx);
8576 // Find project-a's section and check it has no drafts.
8577 let project_a_start = entries
8578 .iter()
8579 .position(|e| e.contains("project-a"))
8580 .unwrap();
8581 let project_a_end = entries[project_a_start + 1..]
8582 .iter()
8583 .position(|e| e.starts_with("v "))
8584 .map(|i| i + project_a_start + 1)
8585 .unwrap_or(entries.len());
8586 let project_a_drafts = entries[project_a_start..project_a_end]
8587 .iter()
8588 .filter(|e| e.contains("Draft"))
8589 .count();
8590 assert_eq!(
8591 project_a_drafts, 0,
8592 "switching back to project-a should not create drafts in its group"
8593 );
8594}
8595
8596#[gpui::test]
8597async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
8598 // When a workspace has a draft (from the panel's load fallback)
8599 // and the user activates it (e.g. by clicking the placeholder or
8600 // the project header), no extra drafts should be created.
8601 init_test(cx);
8602 let fs = FakeFs::new(cx.executor());
8603 fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
8604 .await;
8605 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8606 .await;
8607 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8608
8609 let project_a =
8610 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
8611 let (multi_workspace, cx) =
8612 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8613 let sidebar = setup_sidebar(&multi_workspace, cx);
8614 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8615 let _panel_a = add_agent_panel(&workspace_a, cx);
8616 cx.run_until_parked();
8617
8618 // Add project-b with its own workspace and agent panel.
8619 let project_b =
8620 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8621 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8622 mw.test_add_workspace(project_b.clone(), window, cx)
8623 });
8624 let _panel_b = add_agent_panel(&workspace_b, cx);
8625 cx.run_until_parked();
8626
8627 // Explicitly create a draft on workspace_b so the sidebar tracks one.
8628 sidebar.update_in(cx, |sidebar, window, cx| {
8629 sidebar.create_new_thread(&workspace_b, window, cx);
8630 });
8631 cx.run_until_parked();
8632
8633 // Count project-b's drafts.
8634 let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
8635 let entries = visible_entries_as_strings(&sidebar, cx);
8636 entries
8637 .iter()
8638 .skip_while(|e| !e.contains("project-b"))
8639 .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
8640 .filter(|e| e.contains("Draft"))
8641 .count()
8642 };
8643 let drafts_before = count_b_drafts(cx);
8644
8645 // Switch away from project-b, then back.
8646 multi_workspace.update_in(cx, |mw, window, cx| {
8647 mw.activate(workspace_a.clone(), None, window, cx);
8648 });
8649 cx.run_until_parked();
8650 multi_workspace.update_in(cx, |mw, window, cx| {
8651 mw.activate(workspace_b.clone(), None, window, cx);
8652 });
8653 cx.run_until_parked();
8654
8655 let drafts_after = count_b_drafts(cx);
8656 assert_eq!(
8657 drafts_before, drafts_after,
8658 "activating workspace should not create extra drafts"
8659 );
8660
8661 // The draft should be highlighted as active after switching back.
8662 sidebar.read_with(cx, |sidebar, _| {
8663 assert_active_draft(
8664 sidebar,
8665 &workspace_b,
8666 "draft should be active after switching back to its workspace",
8667 );
8668 });
8669}
8670
8671#[gpui::test]
8672async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
8673 // Historical threads (not open in any agent panel) should have their
8674 // worktree paths updated when a folder is added to or removed from the
8675 // project.
8676 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
8677 let (multi_workspace, cx) =
8678 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8679 let sidebar = setup_sidebar(&multi_workspace, cx);
8680
8681 // Save two threads directly into the metadata store (not via the agent
8682 // panel), so they are purely historical — no open views hold them.
8683 // Use different timestamps so sort order is deterministic.
8684 save_thread_metadata(
8685 acp::SessionId::new(Arc::from("hist-1")),
8686 Some("Historical 1".into()),
8687 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8688 None,
8689 None,
8690 &project,
8691 cx,
8692 );
8693 save_thread_metadata(
8694 acp::SessionId::new(Arc::from("hist-2")),
8695 Some("Historical 2".into()),
8696 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
8697 None,
8698 None,
8699 &project,
8700 cx,
8701 );
8702 cx.run_until_parked();
8703 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8704 cx.run_until_parked();
8705
8706 // Sanity-check: both threads exist under the initial key [/project-a].
8707 let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
8708 cx.update(|_window, cx| {
8709 let store = ThreadMetadataStore::global(cx).read(cx);
8710 assert_eq!(
8711 store
8712 .entries_for_main_worktree_path(&old_key_paths, None)
8713 .count(),
8714 2,
8715 "should have 2 historical threads under old key before worktree add"
8716 );
8717 });
8718
8719 // Add a second worktree to the project.
8720 project
8721 .update(cx, |project, cx| {
8722 project.find_or_create_worktree("/project-b", true, cx)
8723 })
8724 .await
8725 .expect("should add worktree");
8726 cx.run_until_parked();
8727
8728 // The historical threads should now be indexed under the new combined
8729 // key [/project-a, /project-b].
8730 let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
8731 cx.update(|_window, cx| {
8732 let store = ThreadMetadataStore::global(cx).read(cx);
8733 assert_eq!(
8734 store
8735 .entries_for_main_worktree_path(&old_key_paths, None)
8736 .count(),
8737 0,
8738 "should have 0 historical threads under old key after worktree add"
8739 );
8740 assert_eq!(
8741 store
8742 .entries_for_main_worktree_path(&new_key_paths, None)
8743 .count(),
8744 2,
8745 "should have 2 historical threads under new key after worktree add"
8746 );
8747 });
8748
8749 // Sidebar should show threads under the new header.
8750 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8751 cx.run_until_parked();
8752 assert_eq!(
8753 visible_entries_as_strings(&sidebar, cx),
8754 vec![
8755 "v [project-a, project-b]",
8756 " Historical 2",
8757 " Historical 1",
8758 ]
8759 );
8760
8761 // Now remove the second worktree.
8762 let worktree_id = project.read_with(cx, |project, cx| {
8763 project
8764 .visible_worktrees(cx)
8765 .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
8766 .map(|wt| wt.read(cx).id())
8767 .expect("should find project-b worktree")
8768 });
8769 project.update(cx, |project, cx| {
8770 project.remove_worktree(worktree_id, cx);
8771 });
8772 cx.run_until_parked();
8773
8774 // Historical threads should migrate back to the original key.
8775 cx.update(|_window, cx| {
8776 let store = ThreadMetadataStore::global(cx).read(cx);
8777 assert_eq!(
8778 store
8779 .entries_for_main_worktree_path(&new_key_paths, None)
8780 .count(),
8781 0,
8782 "should have 0 historical threads under new key after worktree remove"
8783 );
8784 assert_eq!(
8785 store
8786 .entries_for_main_worktree_path(&old_key_paths, None)
8787 .count(),
8788 2,
8789 "should have 2 historical threads under old key after worktree remove"
8790 );
8791 });
8792
8793 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8794 cx.run_until_parked();
8795 assert_eq!(
8796 visible_entries_as_strings(&sidebar, cx),
8797 vec!["v [project-a]", " Historical 2", " Historical 1",]
8798 );
8799}
8800
8801#[gpui::test]
8802async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
8803 // When two workspaces share the same project group (same main path)
8804 // but have different folder paths (main repo vs linked worktree),
8805 // adding a worktree to the main workspace should regroup only that
8806 // workspace and its threads into the new project group. Threads for the
8807 // linked worktree workspace should remain under the original group.
8808 agent_ui::test_support::init_test(cx);
8809 cx.update(|cx| {
8810 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
8811 ThreadStore::init_global(cx);
8812 ThreadMetadataStore::init_global(cx);
8813 language_model::LanguageModelRegistry::test(cx);
8814 prompt_store::init(cx);
8815 });
8816
8817 let fs = FakeFs::new(cx.executor());
8818 fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
8819 .await;
8820 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8821 .await;
8822 fs.add_linked_worktree_for_repo(
8823 Path::new("/project/.git"),
8824 false,
8825 git::repository::Worktree {
8826 path: std::path::PathBuf::from("/wt-feature"),
8827 ref_name: Some("refs/heads/feature".into()),
8828 sha: "aaa".into(),
8829 is_main: false,
8830 is_bare: false,
8831 },
8832 )
8833 .await;
8834 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8835
8836 // Workspace A: main repo at /project.
8837 let main_project =
8838 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
8839 // Workspace B: linked worktree of the same repo (same group, different folder).
8840 let worktree_project =
8841 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
8842
8843 main_project
8844 .update(cx, |p, cx| p.git_scans_complete(cx))
8845 .await;
8846 worktree_project
8847 .update(cx, |p, cx| p.git_scans_complete(cx))
8848 .await;
8849
8850 let (multi_workspace, cx) =
8851 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
8852 let sidebar = setup_sidebar(&multi_workspace, cx);
8853 multi_workspace.update_in(cx, |mw, window, cx| {
8854 mw.test_add_workspace(worktree_project.clone(), window, cx);
8855 });
8856 cx.run_until_parked();
8857
8858 // Save a thread for each workspace's folder paths.
8859 let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
8860 let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
8861 save_thread_metadata(
8862 acp::SessionId::new(Arc::from("thread-main")),
8863 Some("Main Thread".into()),
8864 time_main,
8865 Some(time_main),
8866 None,
8867 &main_project,
8868 cx,
8869 );
8870 save_thread_metadata(
8871 acp::SessionId::new(Arc::from("thread-wt")),
8872 Some("Worktree Thread".into()),
8873 time_wt,
8874 Some(time_wt),
8875 None,
8876 &worktree_project,
8877 cx,
8878 );
8879 cx.run_until_parked();
8880
8881 let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
8882 let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
8883
8884 // Sanity-check: each thread is indexed under its own folder paths, but
8885 // both appear under the shared sidebar group keyed by the main worktree.
8886 cx.update(|_window, cx| {
8887 let store = ThreadMetadataStore::global(cx).read(cx);
8888 assert_eq!(
8889 store.entries_for_path(&folder_paths_main, None).count(),
8890 1,
8891 "one thread under [/project]"
8892 );
8893 assert_eq!(
8894 store.entries_for_path(&folder_paths_wt, None).count(),
8895 1,
8896 "one thread under [/wt-feature]"
8897 );
8898 });
8899 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8900 cx.run_until_parked();
8901 assert_eq!(
8902 visible_entries_as_strings(&sidebar, cx),
8903 vec![
8904 "v [project]",
8905 " Worktree Thread {wt-feature}",
8906 " Main Thread",
8907 ]
8908 );
8909
8910 // Add /project-b to the main project only.
8911 main_project
8912 .update(cx, |project, cx| {
8913 project.find_or_create_worktree("/project-b", true, cx)
8914 })
8915 .await
8916 .expect("should add worktree");
8917 cx.run_until_parked();
8918
8919 // Main Thread (folder paths [/project]) should be regrouped to
8920 // [/project, /project-b]. Worktree Thread should remain under the
8921 // original [/project] group.
8922 let folder_paths_main_b =
8923 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
8924 cx.update(|_window, cx| {
8925 let store = ThreadMetadataStore::global(cx).read(cx);
8926 assert_eq!(
8927 store.entries_for_path(&folder_paths_main, None).count(),
8928 0,
8929 "main thread should no longer be under old folder paths [/project]"
8930 );
8931 assert_eq!(
8932 store.entries_for_path(&folder_paths_main_b, None).count(),
8933 1,
8934 "main thread should now be under [/project, /project-b]"
8935 );
8936 assert_eq!(
8937 store.entries_for_path(&folder_paths_wt, None).count(),
8938 1,
8939 "worktree thread should remain unchanged under [/wt-feature]"
8940 );
8941 });
8942
8943 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8944 cx.run_until_parked();
8945 assert_eq!(
8946 visible_entries_as_strings(&sidebar, cx),
8947 vec![
8948 "v [project]",
8949 " Worktree Thread {wt-feature}",
8950 "v [project, project-b]",
8951 " Main Thread",
8952 ]
8953 );
8954}
8955
8956#[gpui::test]
8957async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
8958 cx: &mut TestAppContext,
8959) {
8960 // When a linked worktree is opened as its own workspace and then a new
8961 // folder is added to the main project group, the linked worktree
8962 // workspace must still be reachable from some sidebar entry.
8963 let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
8964 let fs = _fs.clone();
8965
8966 // Set up git worktree infrastructure.
8967 fs.insert_tree(
8968 "/my-project/.git/worktrees/wt-0",
8969 serde_json::json!({
8970 "commondir": "../../",
8971 "HEAD": "ref: refs/heads/wt-0",
8972 }),
8973 )
8974 .await;
8975 fs.insert_tree(
8976 "/worktrees/wt-0",
8977 serde_json::json!({
8978 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8979 "src": {},
8980 }),
8981 )
8982 .await;
8983 fs.add_linked_worktree_for_repo(
8984 Path::new("/my-project/.git"),
8985 false,
8986 git::repository::Worktree {
8987 path: PathBuf::from("/worktrees/wt-0"),
8988 ref_name: Some("refs/heads/wt-0".into()),
8989 sha: "aaa".into(),
8990 is_main: false,
8991 is_bare: false,
8992 },
8993 )
8994 .await;
8995
8996 // Re-scan so the main project discovers the linked worktree.
8997 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8998
8999 let (multi_workspace, cx) =
9000 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9001 let sidebar = setup_sidebar(&multi_workspace, cx);
9002
9003 // Open the linked worktree as its own workspace.
9004 let worktree_project = project::Project::test(
9005 fs.clone() as Arc<dyn fs::Fs>,
9006 ["/worktrees/wt-0".as_ref()],
9007 cx,
9008 )
9009 .await;
9010 worktree_project
9011 .update(cx, |p, cx| p.git_scans_complete(cx))
9012 .await;
9013 multi_workspace.update_in(cx, |mw, window, cx| {
9014 mw.test_add_workspace(worktree_project.clone(), window, cx);
9015 });
9016 cx.run_until_parked();
9017
9018 // Both workspaces should be reachable.
9019 let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
9020 assert_eq!(workspace_count, 2, "should have 2 workspaces");
9021
9022 // Add a new folder to the main project, changing the project group key.
9023 fs.insert_tree(
9024 "/other-project",
9025 serde_json::json!({ ".git": {}, "src": {} }),
9026 )
9027 .await;
9028 project
9029 .update(cx, |project, cx| {
9030 project.find_or_create_worktree("/other-project", true, cx)
9031 })
9032 .await
9033 .expect("should add worktree");
9034 cx.run_until_parked();
9035
9036 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9037 cx.run_until_parked();
9038
9039 // The linked worktree workspace must still be reachable.
9040 let entries = visible_entries_as_strings(&sidebar, cx);
9041 let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
9042 mw.workspaces().map(|ws| ws.entity_id()).collect()
9043 });
9044 sidebar.read_with(cx, |sidebar, cx| {
9045 let multi_workspace = multi_workspace.read(cx);
9046 let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
9047 .contents
9048 .entries
9049 .iter()
9050 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
9051 .map(|ws| ws.entity_id())
9052 .collect();
9053 let all: std::collections::HashSet<gpui::EntityId> =
9054 mw_workspaces.iter().copied().collect();
9055 let unreachable = &all - &reachable;
9056 assert!(
9057 unreachable.is_empty(),
9058 "all workspaces should be reachable after adding folder; \
9059 unreachable: {:?}, entries: {:?}",
9060 unreachable,
9061 entries,
9062 );
9063 });
9064}
9065
9066mod property_test {
9067 use super::*;
9068 use gpui::proptest::prelude::*;
9069
9070 struct UnopenedWorktree {
9071 path: String,
9072 main_workspace_path: String,
9073 }
9074
9075 struct TestState {
9076 fs: Arc<FakeFs>,
9077 thread_counter: u32,
9078 workspace_counter: u32,
9079 worktree_counter: u32,
9080 saved_thread_ids: Vec<acp::SessionId>,
9081 unopened_worktrees: Vec<UnopenedWorktree>,
9082 }
9083
9084 impl TestState {
9085 fn new(fs: Arc<FakeFs>) -> Self {
9086 Self {
9087 fs,
9088 thread_counter: 0,
9089 workspace_counter: 1,
9090 worktree_counter: 0,
9091 saved_thread_ids: Vec::new(),
9092 unopened_worktrees: Vec::new(),
9093 }
9094 }
9095
9096 fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
9097 let id = self.thread_counter;
9098 self.thread_counter += 1;
9099 acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
9100 }
9101
9102 fn next_workspace_path(&mut self) -> String {
9103 let id = self.workspace_counter;
9104 self.workspace_counter += 1;
9105 format!("/prop-project-{id}")
9106 }
9107
9108 fn next_worktree_name(&mut self) -> String {
9109 let id = self.worktree_counter;
9110 self.worktree_counter += 1;
9111 format!("wt-{id}")
9112 }
9113 }
9114
9115 #[derive(Debug)]
9116 enum Operation {
9117 SaveThread { project_group_index: usize },
9118 SaveWorktreeThread { worktree_index: usize },
9119 ToggleAgentPanel,
9120 CreateDraftThread,
9121 AddProject { use_worktree: bool },
9122 ArchiveThread { index: usize },
9123 SwitchToThread { index: usize },
9124 SwitchToProjectGroup { index: usize },
9125 AddLinkedWorktree { project_group_index: usize },
9126 AddWorktreeToProject { project_group_index: usize },
9127 RemoveWorktreeFromProject { project_group_index: usize },
9128 }
9129
9130 // Distribution (out of 24 slots):
9131 // SaveThread: 5 slots (~21%)
9132 // SaveWorktreeThread: 2 slots (~8%)
9133 // ToggleAgentPanel: 1 slot (~4%)
9134 // CreateDraftThread: 1 slot (~4%)
9135 // AddProject: 1 slot (~4%)
9136 // ArchiveThread: 2 slots (~8%)
9137 // SwitchToThread: 2 slots (~8%)
9138 // SwitchToProjectGroup: 2 slots (~8%)
9139 // AddLinkedWorktree: 4 slots (~17%)
9140 // AddWorktreeToProject: 2 slots (~8%)
9141 // RemoveWorktreeFromProject: 2 slots (~8%)
9142 const DISTRIBUTION_SLOTS: u32 = 24;
9143
9144 impl TestState {
9145 fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
9146 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
9147
9148 match raw % DISTRIBUTION_SLOTS {
9149 0..=4 => Operation::SaveThread {
9150 project_group_index: extra % project_group_count,
9151 },
9152 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
9153 worktree_index: extra % self.unopened_worktrees.len(),
9154 },
9155 5..=6 => Operation::SaveThread {
9156 project_group_index: extra % project_group_count,
9157 },
9158 7 => Operation::ToggleAgentPanel,
9159 8 => Operation::CreateDraftThread,
9160 9 => Operation::AddProject {
9161 use_worktree: !self.unopened_worktrees.is_empty(),
9162 },
9163 10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
9164 index: extra % self.saved_thread_ids.len(),
9165 },
9166 10..=11 => Operation::AddProject {
9167 use_worktree: !self.unopened_worktrees.is_empty(),
9168 },
9169 12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
9170 index: extra % self.saved_thread_ids.len(),
9171 },
9172 12..=13 => Operation::SwitchToProjectGroup {
9173 index: extra % project_group_count,
9174 },
9175 14..=15 => Operation::SwitchToProjectGroup {
9176 index: extra % project_group_count,
9177 },
9178 16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
9179 project_group_index: extra % project_group_count,
9180 },
9181 16..=19 => Operation::SaveThread {
9182 project_group_index: extra % project_group_count,
9183 },
9184 20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
9185 project_group_index: extra % project_group_count,
9186 },
9187 20..=21 => Operation::SaveThread {
9188 project_group_index: extra % project_group_count,
9189 },
9190 22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
9191 project_group_index: extra % project_group_count,
9192 },
9193 22..=23 => Operation::SaveThread {
9194 project_group_index: extra % project_group_count,
9195 },
9196 _ => unreachable!(),
9197 }
9198 }
9199 }
9200
9201 fn save_thread_to_path_with_main(
9202 state: &mut TestState,
9203 path_list: PathList,
9204 main_worktree_paths: PathList,
9205 cx: &mut gpui::VisualTestContext,
9206 ) {
9207 let session_id = state.next_metadata_only_thread_id();
9208 let title: SharedString = format!("Thread {}", session_id).into();
9209 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9210 .unwrap()
9211 + chrono::Duration::seconds(state.thread_counter as i64);
9212 let metadata = ThreadMetadata {
9213 thread_id: ThreadId::new(),
9214 session_id: Some(session_id),
9215 agent_id: agent::ZED_AGENT_ID.clone(),
9216 title: Some(title),
9217 updated_at,
9218 created_at: None,
9219 interacted_at: None,
9220 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
9221 archived: false,
9222 remote_connection: None,
9223 };
9224 cx.update(|_, cx| {
9225 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
9226 });
9227 cx.run_until_parked();
9228 }
9229
9230 async fn perform_operation(
9231 operation: Operation,
9232 state: &mut TestState,
9233 multi_workspace: &Entity<MultiWorkspace>,
9234 sidebar: &Entity<Sidebar>,
9235 cx: &mut gpui::VisualTestContext,
9236 ) {
9237 match operation {
9238 Operation::SaveThread {
9239 project_group_index,
9240 } => {
9241 // Find a workspace for this project group and create a real
9242 // thread via its agent panel.
9243 let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
9244 let keys = mw.project_group_keys();
9245 let key = &keys[project_group_index];
9246 let ws = mw
9247 .workspaces_for_project_group(key, cx)
9248 .and_then(|ws| ws.first().cloned())
9249 .unwrap_or_else(|| mw.workspace().clone());
9250 let project = ws.read(cx).project().clone();
9251 (ws, project)
9252 });
9253
9254 let panel =
9255 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9256 if let Some(panel) = panel {
9257 let connection = StubAgentConnection::new();
9258 connection.set_next_prompt_updates(vec![
9259 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
9260 "Done".into(),
9261 )),
9262 ]);
9263 open_thread_with_connection(&panel, connection, cx);
9264 send_message(&panel, cx);
9265 let session_id = active_session_id(&panel, cx);
9266 state.saved_thread_ids.push(session_id.clone());
9267
9268 let title: SharedString = format!("Thread {}", state.thread_counter).into();
9269 state.thread_counter += 1;
9270 let updated_at =
9271 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9272 .unwrap()
9273 + chrono::Duration::seconds(state.thread_counter as i64);
9274 save_thread_metadata(
9275 session_id,
9276 Some(title),
9277 updated_at,
9278 None,
9279 None,
9280 &project,
9281 cx,
9282 );
9283 }
9284 }
9285 Operation::SaveWorktreeThread { worktree_index } => {
9286 let worktree = &state.unopened_worktrees[worktree_index];
9287 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
9288 let main_worktree_paths =
9289 PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
9290 save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
9291 }
9292
9293 Operation::ToggleAgentPanel => {
9294 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9295 let panel_open =
9296 workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
9297 workspace.update_in(cx, |workspace, window, cx| {
9298 if panel_open {
9299 workspace.close_panel::<AgentPanel>(window, cx);
9300 } else {
9301 workspace.open_panel::<AgentPanel>(window, cx);
9302 }
9303 });
9304 }
9305 Operation::CreateDraftThread => {
9306 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9307 let panel =
9308 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9309 if let Some(panel) = panel {
9310 panel.update_in(cx, |panel, window, cx| {
9311 panel.new_thread(&NewThread, window, cx);
9312 });
9313 cx.run_until_parked();
9314 }
9315 workspace.update_in(cx, |workspace, window, cx| {
9316 workspace.focus_panel::<AgentPanel>(window, cx);
9317 });
9318 }
9319 Operation::AddProject { use_worktree } => {
9320 let path = if use_worktree {
9321 // Open an existing linked worktree as a project (simulates Cmd+O
9322 // on a worktree directory).
9323 state.unopened_worktrees.remove(0).path
9324 } else {
9325 // Create a brand new project.
9326 let path = state.next_workspace_path();
9327 state
9328 .fs
9329 .insert_tree(
9330 &path,
9331 serde_json::json!({
9332 ".git": {},
9333 "src": {},
9334 }),
9335 )
9336 .await;
9337 path
9338 };
9339 let project = project::Project::test(
9340 state.fs.clone() as Arc<dyn fs::Fs>,
9341 [path.as_ref()],
9342 cx,
9343 )
9344 .await;
9345 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9346 multi_workspace.update_in(cx, |mw, window, cx| {
9347 mw.test_add_workspace(project.clone(), window, cx)
9348 });
9349 }
9350
9351 Operation::ArchiveThread { index } => {
9352 let session_id = state.saved_thread_ids[index].clone();
9353 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
9354 sidebar.archive_thread(&session_id, window, cx);
9355 });
9356 cx.run_until_parked();
9357 state.saved_thread_ids.remove(index);
9358 }
9359 Operation::SwitchToThread { index } => {
9360 let session_id = state.saved_thread_ids[index].clone();
9361 // Find the thread's position in the sidebar entries and select it.
9362 let thread_index = sidebar.read_with(cx, |sidebar, _| {
9363 sidebar.contents.entries.iter().position(|entry| {
9364 matches!(
9365 entry,
9366 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
9367 )
9368 })
9369 });
9370 if let Some(ix) = thread_index {
9371 sidebar.update_in(cx, |sidebar, window, cx| {
9372 sidebar.selection = Some(ix);
9373 sidebar.confirm(&Confirm, window, cx);
9374 });
9375 cx.run_until_parked();
9376 }
9377 }
9378 Operation::SwitchToProjectGroup { index } => {
9379 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9380 let keys = mw.project_group_keys();
9381 let key = &keys[index];
9382 mw.workspaces_for_project_group(key, cx)
9383 .and_then(|ws| ws.first().cloned())
9384 .unwrap_or_else(|| mw.workspace().clone())
9385 });
9386 multi_workspace.update_in(cx, |mw, window, cx| {
9387 mw.activate(workspace, None, window, cx);
9388 });
9389 }
9390 Operation::AddLinkedWorktree {
9391 project_group_index,
9392 } => {
9393 // Get the main worktree path from the project group key.
9394 let main_path = multi_workspace.read_with(cx, |mw, _| {
9395 let keys = mw.project_group_keys();
9396 let key = &keys[project_group_index];
9397 key.path_list()
9398 .paths()
9399 .first()
9400 .unwrap()
9401 .to_string_lossy()
9402 .to_string()
9403 });
9404 let dot_git = format!("{}/.git", main_path);
9405 let worktree_name = state.next_worktree_name();
9406 let worktree_path = format!("/worktrees/{}", worktree_name);
9407
9408 state.fs
9409 .insert_tree(
9410 &worktree_path,
9411 serde_json::json!({
9412 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
9413 "src": {},
9414 }),
9415 )
9416 .await;
9417
9418 // Also create the worktree metadata dir inside the main repo's .git
9419 state
9420 .fs
9421 .insert_tree(
9422 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
9423 serde_json::json!({
9424 "commondir": "../../",
9425 "HEAD": format!("ref: refs/heads/{}", worktree_name),
9426 }),
9427 )
9428 .await;
9429
9430 let dot_git_path = std::path::Path::new(&dot_git);
9431 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
9432 state
9433 .fs
9434 .add_linked_worktree_for_repo(
9435 dot_git_path,
9436 false,
9437 git::repository::Worktree {
9438 path: worktree_pathbuf,
9439 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
9440 sha: "aaa".into(),
9441 is_main: false,
9442 is_bare: false,
9443 },
9444 )
9445 .await;
9446
9447 // Re-scan the main workspace's project so it discovers the new worktree.
9448 let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
9449 let keys = mw.project_group_keys();
9450 let key = &keys[project_group_index];
9451 mw.workspaces_for_project_group(key, cx)
9452 .and_then(|ws| ws.first().cloned())
9453 .unwrap()
9454 });
9455 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
9456 main_project
9457 .update(cx, |p, cx| p.git_scans_complete(cx))
9458 .await;
9459
9460 state.unopened_worktrees.push(UnopenedWorktree {
9461 path: worktree_path,
9462 main_workspace_path: main_path.clone(),
9463 });
9464 }
9465 Operation::AddWorktreeToProject {
9466 project_group_index,
9467 } => {
9468 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9469 let keys = mw.project_group_keys();
9470 let key = &keys[project_group_index];
9471 mw.workspaces_for_project_group(key, cx)
9472 .and_then(|ws| ws.first().cloned())
9473 });
9474 let Some(workspace) = workspace else { return };
9475 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9476
9477 let new_path = state.next_workspace_path();
9478 state
9479 .fs
9480 .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
9481 .await;
9482
9483 let result = project
9484 .update(cx, |project, cx| {
9485 project.find_or_create_worktree(&new_path, true, cx)
9486 })
9487 .await;
9488 if result.is_err() {
9489 return;
9490 }
9491 cx.run_until_parked();
9492 }
9493 Operation::RemoveWorktreeFromProject {
9494 project_group_index,
9495 } => {
9496 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9497 let keys = mw.project_group_keys();
9498 let key = &keys[project_group_index];
9499 mw.workspaces_for_project_group(key, cx)
9500 .and_then(|ws| ws.first().cloned())
9501 });
9502 let Some(workspace) = workspace else { return };
9503 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9504
9505 let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
9506 if worktree_count <= 1 {
9507 return;
9508 }
9509
9510 let worktree_id = project.read_with(cx, |p, cx| {
9511 p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
9512 });
9513 if let Some(worktree_id) = worktree_id {
9514 project.update(cx, |project, cx| {
9515 project.remove_worktree(worktree_id, cx);
9516 });
9517 cx.run_until_parked();
9518 }
9519 }
9520 }
9521 }
9522
9523 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
9524 sidebar.update_in(cx, |sidebar, _window, cx| {
9525 if let Some(mw) = sidebar.multi_workspace.upgrade() {
9526 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
9527 }
9528 sidebar.update_entries(cx);
9529 });
9530 }
9531
9532 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9533 verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
9534 verify_no_duplicate_threads(sidebar)?;
9535 verify_all_threads_are_shown(sidebar, cx)?;
9536 verify_active_state_matches_current_workspace(sidebar, cx)?;
9537 verify_all_workspaces_are_reachable(sidebar, cx)?;
9538 verify_workspace_group_key_integrity(sidebar, cx)?;
9539 Ok(())
9540 }
9541
9542 fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
9543 let mut seen: HashSet<acp::SessionId> = HashSet::default();
9544 let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
9545
9546 for entry in &sidebar.contents.entries {
9547 if let Some(session_id) = entry.session_id() {
9548 if !seen.insert(session_id.clone()) {
9549 let title = match entry {
9550 ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
9551 _ => "<unknown>".to_string(),
9552 };
9553 duplicates.push((session_id.clone(), title));
9554 }
9555 }
9556 }
9557
9558 anyhow::ensure!(
9559 duplicates.is_empty(),
9560 "threads appear more than once in sidebar: {:?}",
9561 duplicates,
9562 );
9563 Ok(())
9564 }
9565
9566 fn verify_every_group_in_multiworkspace_is_shown(
9567 sidebar: &Sidebar,
9568 cx: &App,
9569 ) -> anyhow::Result<()> {
9570 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9571 anyhow::bail!("sidebar should still have an associated multi-workspace");
9572 };
9573
9574 let mw = multi_workspace.read(cx);
9575
9576 // Every project group key in the multi-workspace that has a
9577 // non-empty path list should appear as a ProjectHeader in the
9578 // sidebar.
9579 let all_keys = mw.project_group_keys();
9580 let expected_keys: HashSet<&ProjectGroupKey> = all_keys
9581 .iter()
9582 .filter(|k| !k.path_list().paths().is_empty())
9583 .collect();
9584
9585 let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
9586 .contents
9587 .entries
9588 .iter()
9589 .filter_map(|entry| match entry {
9590 ListEntry::ProjectHeader { key, .. } => Some(key),
9591 _ => None,
9592 })
9593 .collect();
9594
9595 let missing = &expected_keys - &sidebar_keys;
9596 let stray = &sidebar_keys - &expected_keys;
9597
9598 anyhow::ensure!(
9599 missing.is_empty() && stray.is_empty(),
9600 "sidebar project groups don't match multi-workspace.\n\
9601 Only in multi-workspace (missing): {:?}\n\
9602 Only in sidebar (stray): {:?}",
9603 missing,
9604 stray,
9605 );
9606
9607 Ok(())
9608 }
9609
9610 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9611 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9612 anyhow::bail!("sidebar should still have an associated multi-workspace");
9613 };
9614 let workspaces = multi_workspace
9615 .read(cx)
9616 .workspaces()
9617 .cloned()
9618 .collect::<Vec<_>>();
9619 let thread_store = ThreadMetadataStore::global(cx);
9620
9621 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
9622 .contents
9623 .entries
9624 .iter()
9625 .filter_map(|entry| entry.session_id().cloned())
9626 .collect();
9627
9628 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
9629
9630 // Query using the same approach as the sidebar: iterate project
9631 // group keys, then do main + legacy queries per group.
9632 let mw = multi_workspace.read(cx);
9633 let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
9634 HashMap::default();
9635 for workspace in &workspaces {
9636 let key = workspace.read(cx).project_group_key(cx);
9637 workspaces_by_group
9638 .entry(key)
9639 .or_default()
9640 .push(workspace.clone());
9641 }
9642
9643 for group_key in mw.project_group_keys() {
9644 let path_list = group_key.path_list().clone();
9645 if path_list.paths().is_empty() {
9646 continue;
9647 }
9648
9649 let group_workspaces = workspaces_by_group
9650 .get(&group_key)
9651 .map(|ws| ws.as_slice())
9652 .unwrap_or_default();
9653
9654 // Main code path queries (run for all groups, even without workspaces).
9655 // Skip drafts (session_id: None) — they are not shown in the
9656 // sidebar entries.
9657 for metadata in thread_store
9658 .read(cx)
9659 .entries_for_main_worktree_path(&path_list, None)
9660 {
9661 if let Some(sid) = metadata.session_id.clone() {
9662 metadata_thread_ids.insert(sid);
9663 }
9664 }
9665 for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
9666 if let Some(sid) = metadata.session_id.clone() {
9667 metadata_thread_ids.insert(sid);
9668 }
9669 }
9670
9671 // Legacy: per-workspace queries for different root paths.
9672 let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
9673 .iter()
9674 .flat_map(|ws| {
9675 ws.read(cx)
9676 .root_paths(cx)
9677 .into_iter()
9678 .map(|p| p.to_path_buf())
9679 })
9680 .collect();
9681
9682 for workspace in group_workspaces {
9683 let ws_path_list = workspace_path_list(workspace, cx);
9684 if ws_path_list != path_list {
9685 for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
9686 if let Some(sid) = metadata.session_id.clone() {
9687 metadata_thread_ids.insert(sid);
9688 }
9689 }
9690 }
9691 }
9692
9693 for workspace in group_workspaces {
9694 for snapshot in root_repository_snapshots(workspace, cx) {
9695 let Some(main_worktree_abs_path) = snapshot.main_worktree_abs_path() else {
9696 continue;
9697 };
9698 let repo_path_list = PathList::new(&[main_worktree_abs_path.to_path_buf()]);
9699 if repo_path_list != path_list {
9700 continue;
9701 }
9702 for linked_worktree in snapshot.linked_worktrees() {
9703 if covered_paths.contains(&*linked_worktree.path) {
9704 continue;
9705 }
9706 let worktree_path_list =
9707 PathList::new(std::slice::from_ref(&linked_worktree.path));
9708 for metadata in thread_store
9709 .read(cx)
9710 .entries_for_path(&worktree_path_list, None)
9711 {
9712 if let Some(sid) = metadata.session_id.clone() {
9713 metadata_thread_ids.insert(sid);
9714 }
9715 }
9716 }
9717 }
9718 }
9719 }
9720
9721 anyhow::ensure!(
9722 sidebar_thread_ids == metadata_thread_ids,
9723 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
9724 sidebar_thread_ids,
9725 metadata_thread_ids,
9726 );
9727 Ok(())
9728 }
9729
9730 fn verify_active_state_matches_current_workspace(
9731 sidebar: &Sidebar,
9732 cx: &App,
9733 ) -> anyhow::Result<()> {
9734 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9735 anyhow::bail!("sidebar should still have an associated multi-workspace");
9736 };
9737
9738 let active_workspace = multi_workspace.read(cx).workspace();
9739
9740 // 1. active_entry should be Some when the panel has content.
9741 // It may be None when the panel is uninitialized (no drafts,
9742 // no threads), which is fine.
9743 // It may also temporarily point at a different workspace
9744 // when the workspace just changed and the new panel has no
9745 // content yet.
9746 let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
9747 let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
9748 || panel.read(cx).active_conversation_view().is_some();
9749
9750 let Some(entry) = sidebar.active_entry.as_ref() else {
9751 if panel_has_content {
9752 anyhow::bail!("active_entry is None but panel has content (draft or thread)");
9753 }
9754 return Ok(());
9755 };
9756
9757 // If the entry workspace doesn't match the active workspace
9758 // and the panel has no content, this is a transient state that
9759 // will resolve when the panel gets content.
9760 if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
9761 return Ok(());
9762 }
9763
9764 // 2. The entry's workspace must agree with the multi-workspace's
9765 // active workspace.
9766 anyhow::ensure!(
9767 entry.workspace().entity_id() == active_workspace.entity_id(),
9768 "active_entry workspace ({:?}) != active workspace ({:?})",
9769 entry.workspace().entity_id(),
9770 active_workspace.entity_id(),
9771 );
9772
9773 // 3. The entry must match the agent panel's current state.
9774 if panel.read(cx).active_thread_id(cx).is_some() {
9775 anyhow::ensure!(
9776 matches!(entry, ActiveEntry { .. }),
9777 "panel shows a tracked draft but active_entry is {:?}",
9778 entry,
9779 );
9780 } else if let Some(thread_id) = panel
9781 .read(cx)
9782 .active_conversation_view()
9783 .map(|cv| cv.read(cx).parent_id())
9784 {
9785 anyhow::ensure!(
9786 matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
9787 "panel has thread {:?} but active_entry is {:?}",
9788 thread_id,
9789 entry,
9790 );
9791 }
9792
9793 // 4. Exactly one entry in sidebar contents must be uniquely
9794 // identified by the active_entry — unless the panel is showing
9795 // a draft, which is represented by the + button's active state
9796 // rather than a sidebar row.
9797 // TODO: Make this check more complete
9798 let is_draft = panel.read(cx).active_thread_is_draft(cx)
9799 || panel.read(cx).active_conversation_view().is_none();
9800 if is_draft {
9801 return Ok(());
9802 }
9803 let matching_count = sidebar
9804 .contents
9805 .entries
9806 .iter()
9807 .filter(|e| entry.matches_entry(e))
9808 .count();
9809 if matching_count != 1 {
9810 let thread_entries: Vec<_> = sidebar
9811 .contents
9812 .entries
9813 .iter()
9814 .filter_map(|e| match e {
9815 ListEntry::Thread(t) => Some(format!(
9816 "tid={:?} sid={:?}",
9817 t.metadata.thread_id, t.metadata.session_id
9818 )),
9819 _ => None,
9820 })
9821 .collect();
9822 let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
9823 let store_entries: Vec<_> = store
9824 .entries()
9825 .map(|m| {
9826 format!(
9827 "tid={:?} sid={:?} archived={} paths={:?}",
9828 m.thread_id,
9829 m.session_id,
9830 m.archived,
9831 m.folder_paths()
9832 )
9833 })
9834 .collect();
9835 anyhow::bail!(
9836 "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
9837 entry,
9838 matching_count,
9839 thread_entries,
9840 store_entries,
9841 );
9842 }
9843
9844 Ok(())
9845 }
9846
9847 /// Every workspace in the multi-workspace should be "reachable" from
9848 /// the sidebar — meaning there is at least one entry (thread, draft,
9849 /// new-thread, or project header) that, when clicked, would activate
9850 /// that workspace.
9851 fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9852 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9853 anyhow::bail!("sidebar should still have an associated multi-workspace");
9854 };
9855
9856 let multi_workspace = multi_workspace.read(cx);
9857
9858 let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
9859 .contents
9860 .entries
9861 .iter()
9862 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
9863 .map(|ws| ws.entity_id())
9864 .collect();
9865
9866 let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
9867 .workspaces()
9868 .map(|ws| ws.entity_id())
9869 .collect();
9870
9871 let unreachable = &all_workspace_ids - &reachable_workspaces;
9872
9873 anyhow::ensure!(
9874 unreachable.is_empty(),
9875 "The following workspaces are not reachable from any sidebar entry: {:?}",
9876 unreachable,
9877 );
9878
9879 Ok(())
9880 }
9881
9882 fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9883 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9884 anyhow::bail!("sidebar should still have an associated multi-workspace");
9885 };
9886 multi_workspace
9887 .read(cx)
9888 .assert_project_group_key_integrity(cx)
9889 }
9890
9891 #[gpui::property_test(config = ProptestConfig {
9892 cases: 20,
9893 ..Default::default()
9894 })]
9895 async fn test_sidebar_invariants(
9896 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
9897 raw_operations: Vec<u32>,
9898 cx: &mut TestAppContext,
9899 ) {
9900 use std::sync::atomic::{AtomicUsize, Ordering};
9901 static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
9902
9903 agent_ui::test_support::init_test(cx);
9904 cx.update(|cx| {
9905 cx.set_global(db::AppDatabase::test_new());
9906 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
9907 cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
9908 format!(
9909 "PROPTEST_THREAD_METADATA_{}",
9910 NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
9911 ),
9912 ));
9913
9914 ThreadStore::init_global(cx);
9915 ThreadMetadataStore::init_global(cx);
9916 language_model::LanguageModelRegistry::test(cx);
9917 prompt_store::init(cx);
9918
9919 // Auto-add an AgentPanel to every workspace so that implicitly
9920 // created workspaces (e.g. from thread activation) also have one.
9921 cx.observe_new(
9922 |workspace: &mut Workspace,
9923 window: Option<&mut Window>,
9924 cx: &mut gpui::Context<Workspace>| {
9925 if let Some(window) = window {
9926 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
9927 workspace.add_panel(panel, window, cx);
9928 }
9929 },
9930 )
9931 .detach();
9932 });
9933
9934 let fs = FakeFs::new(cx.executor());
9935 fs.insert_tree(
9936 "/my-project",
9937 serde_json::json!({
9938 ".git": {},
9939 "src": {},
9940 }),
9941 )
9942 .await;
9943 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
9944 let project =
9945 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
9946 .await;
9947 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9948
9949 let (multi_workspace, cx) =
9950 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9951 let sidebar = setup_sidebar(&multi_workspace, cx);
9952
9953 let mut state = TestState::new(fs);
9954 let mut executed: Vec<String> = Vec::new();
9955
9956 for &raw_op in &raw_operations {
9957 let project_group_count =
9958 multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
9959 let operation = state.generate_operation(raw_op, project_group_count);
9960 executed.push(format!("{:?}", operation));
9961 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
9962 cx.run_until_parked();
9963
9964 update_sidebar(&sidebar, cx);
9965 cx.run_until_parked();
9966
9967 let result =
9968 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
9969 if let Err(err) = result {
9970 let log = executed.join("\n ");
9971 panic!(
9972 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
9973 executed.len(),
9974 );
9975 }
9976 }
9977 }
9978}
9979
9980#[gpui::test]
9981async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
9982 cx: &mut TestAppContext,
9983 server_cx: &mut TestAppContext,
9984) {
9985 init_test(cx);
9986
9987 cx.update(|cx| {
9988 release_channel::init(semver::Version::new(0, 0, 0), cx);
9989 });
9990
9991 let app_state = cx.update(|cx| {
9992 let app_state = workspace::AppState::test(cx);
9993 workspace::init(app_state.clone(), cx);
9994 app_state
9995 });
9996
9997 // Set up the remote server side.
9998 let server_fs = FakeFs::new(server_cx.executor());
9999 server_fs
10000 .insert_tree(
10001 "/project",
10002 serde_json::json!({
10003 ".git": {},
10004 "src": { "main.rs": "fn main() {}" }
10005 }),
10006 )
10007 .await;
10008 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10009
10010 // Create the linked worktree checkout path on the remote server,
10011 // but do not yet register it as a git-linked worktree. The real
10012 // regrouping update in this test should happen only after the
10013 // sidebar opens the closed remote thread.
10014 server_fs
10015 .insert_tree(
10016 "/project-wt-1",
10017 serde_json::json!({
10018 "src": { "main.rs": "fn main() {}" }
10019 }),
10020 )
10021 .await;
10022
10023 server_cx.update(|cx| {
10024 release_channel::init(semver::Version::new(0, 0, 0), cx);
10025 });
10026
10027 let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
10028
10029 server_cx.update(remote_server::HeadlessProject::init);
10030 let server_executor = server_cx.executor();
10031 let _headless = server_cx.new(|cx| {
10032 remote_server::HeadlessProject::new(
10033 remote_server::HeadlessAppState {
10034 session: server_session,
10035 fs: server_fs.clone(),
10036 http_client: Arc::new(http_client::BlockedHttpClient),
10037 node_runtime: node_runtime::NodeRuntime::unavailable(),
10038 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10039 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10040 startup_time: std::time::Instant::now(),
10041 },
10042 false,
10043 cx,
10044 )
10045 });
10046
10047 // Connect the client side and build a remote project.
10048 let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
10049 let project = cx.update(|cx| {
10050 let project_client = client::Client::new(
10051 Arc::new(clock::FakeSystemClock::new()),
10052 http_client::FakeHttpClient::with_404_response(),
10053 cx,
10054 );
10055 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
10056 project::Project::remote(
10057 remote_client,
10058 project_client,
10059 node_runtime::NodeRuntime::unavailable(),
10060 user_store,
10061 app_state.languages.clone(),
10062 app_state.fs.clone(),
10063 false,
10064 cx,
10065 )
10066 });
10067
10068 // Open the remote worktree.
10069 project
10070 .update(cx, |project, cx| {
10071 project.find_or_create_worktree(Path::new("/project"), true, cx)
10072 })
10073 .await
10074 .expect("should open remote worktree");
10075 cx.run_until_parked();
10076
10077 // Verify the project is remote.
10078 project.read_with(cx, |project, cx| {
10079 assert!(!project.is_local(), "project should be remote");
10080 assert!(
10081 project.remote_connection_options(cx).is_some(),
10082 "project should have remote connection options"
10083 );
10084 });
10085
10086 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10087
10088 // Create MultiWorkspace with the remote project.
10089 let (multi_workspace, cx) =
10090 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10091 let sidebar = setup_sidebar(&multi_workspace, cx);
10092
10093 cx.run_until_parked();
10094
10095 // Save a thread for the main remote workspace (folder_paths match
10096 // the open workspace, so it will be classified as Open).
10097 let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
10098 save_thread_metadata(
10099 main_thread_id.clone(),
10100 Some("Main Thread".into()),
10101 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10102 None,
10103 None,
10104 &project,
10105 cx,
10106 );
10107 cx.run_until_parked();
10108
10109 // Save a thread whose folder_paths point to a linked worktree path
10110 // that doesn't have an open workspace ("/project-wt-1"), but whose
10111 // main_worktree_paths match the project group key so it appears
10112 // in the sidebar under the same remote group. This simulates a
10113 // linked worktree workspace that was closed.
10114 let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10115 let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
10116 (
10117 p.project_group_key(cx).path_list().clone(),
10118 p.remote_connection_options(cx),
10119 )
10120 });
10121 cx.update(|_window, cx| {
10122 let metadata = ThreadMetadata {
10123 thread_id: ThreadId::new(),
10124 session_id: Some(remote_thread_id.clone()),
10125 agent_id: agent::ZED_AGENT_ID.clone(),
10126 title: Some("Worktree Thread".into()),
10127 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10128 created_at: None,
10129 interacted_at: None,
10130 worktree_paths: WorktreePaths::from_path_lists(
10131 main_worktree_paths,
10132 PathList::new(&[PathBuf::from("/project-wt-1")]),
10133 )
10134 .unwrap(),
10135 archived: false,
10136 remote_connection,
10137 };
10138 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10139 });
10140 cx.run_until_parked();
10141
10142 focus_sidebar(&sidebar, cx);
10143 sidebar.update_in(cx, |sidebar, _window, _cx| {
10144 sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10145 matches!(
10146 entry,
10147 ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10148 )
10149 });
10150 });
10151
10152 let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10153 let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10154
10155 sidebar
10156 .update(cx, |_, cx| {
10157 cx.observe_self(move |sidebar, _cx| {
10158 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10159 if let ListEntry::ProjectHeader { label, .. } = entry {
10160 Some(label.as_ref())
10161 } else {
10162 None
10163 }
10164 });
10165
10166 let Some(project_header) = project_headers.next() else {
10167 saw_separate_project_header_for_observer
10168 .store(true, std::sync::atomic::Ordering::SeqCst);
10169 return;
10170 };
10171
10172 if project_header != "project" || project_headers.next().is_some() {
10173 saw_separate_project_header_for_observer
10174 .store(true, std::sync::atomic::Ordering::SeqCst);
10175 }
10176 })
10177 })
10178 .detach();
10179
10180 multi_workspace.update(cx, |multi_workspace, cx| {
10181 let workspace = multi_workspace.workspace().clone();
10182 workspace.update(cx, |workspace: &mut Workspace, cx| {
10183 let remote_client = workspace
10184 .project()
10185 .read(cx)
10186 .remote_client()
10187 .expect("main remote project should have a remote client");
10188 remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10189 remote_client.force_server_not_running(cx);
10190 });
10191 });
10192 });
10193 cx.run_until_parked();
10194
10195 let (server_session_2, connect_guard_2) =
10196 remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10197 let _headless_2 = server_cx.new(|cx| {
10198 remote_server::HeadlessProject::new(
10199 remote_server::HeadlessAppState {
10200 session: server_session_2,
10201 fs: server_fs.clone(),
10202 http_client: Arc::new(http_client::BlockedHttpClient),
10203 node_runtime: node_runtime::NodeRuntime::unavailable(),
10204 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10205 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10206 startup_time: std::time::Instant::now(),
10207 },
10208 false,
10209 cx,
10210 )
10211 });
10212 drop(connect_guard_2);
10213
10214 let window = cx.windows()[0];
10215 cx.update_window(window, |_, window, cx| {
10216 window.dispatch_action(Confirm.boxed_clone(), cx);
10217 })
10218 .unwrap();
10219
10220 cx.run_until_parked();
10221
10222 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10223 assert_eq!(
10224 mw.workspaces().count(),
10225 2,
10226 "confirming a closed remote thread should open a second workspace"
10227 );
10228 mw.workspaces()
10229 .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10230 .unwrap()
10231 .clone()
10232 });
10233
10234 server_fs
10235 .add_linked_worktree_for_repo(
10236 Path::new("/project/.git"),
10237 true,
10238 git::repository::Worktree {
10239 path: PathBuf::from("/project-wt-1"),
10240 ref_name: Some("refs/heads/feature-wt".into()),
10241 sha: "abc123".into(),
10242 is_main: false,
10243 is_bare: false,
10244 },
10245 )
10246 .await;
10247
10248 server_cx.run_until_parked();
10249 cx.run_until_parked();
10250 server_cx.run_until_parked();
10251 cx.run_until_parked();
10252
10253 let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10254 let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10255 workspace.project().read(cx).project_group_key(cx)
10256 });
10257
10258 assert_eq!(
10259 group_after_update,
10260 project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10261 "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10262 final sidebar entries: {:?}",
10263 entries_after_update,
10264 );
10265
10266 sidebar.update(cx, |sidebar, _cx| {
10267 assert_remote_project_integration_sidebar_state(
10268 sidebar,
10269 &main_thread_id,
10270 &remote_thread_id,
10271 );
10272 });
10273
10274 assert!(
10275 !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10276 "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10277 final group: {:?}; final sidebar entries: {:?}",
10278 group_after_update,
10279 entries_after_update,
10280 );
10281}
10282
10283#[gpui::test]
10284async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10285 // When the thread's folder_paths don't exactly match any workspace's
10286 // root paths (e.g. because a folder was added to the workspace after
10287 // the thread was created), workspace_to_remove is None. But the linked
10288 // worktree workspace still needs to be removed so that its worktree
10289 // entities are released, allowing git worktree removal to proceed.
10290 //
10291 // With the fix, archive_thread scans roots_to_archive for any linked
10292 // worktree workspaces and includes them in the removal set, even when
10293 // the thread's folder_paths don't match the workspace's root paths.
10294 init_test(cx);
10295 let fs = FakeFs::new(cx.executor());
10296
10297 fs.insert_tree(
10298 "/project",
10299 serde_json::json!({
10300 ".git": {
10301 "worktrees": {
10302 "feature-a": {
10303 "commondir": "../../",
10304 "HEAD": "ref: refs/heads/feature-a",
10305 },
10306 },
10307 },
10308 "src": {},
10309 }),
10310 )
10311 .await;
10312
10313 fs.insert_tree(
10314 "/worktrees/project/feature-a/project",
10315 serde_json::json!({
10316 ".git": "gitdir: /project/.git/worktrees/feature-a",
10317 "src": {
10318 "main.rs": "fn main() {}",
10319 },
10320 }),
10321 )
10322 .await;
10323
10324 fs.add_linked_worktree_for_repo(
10325 Path::new("/project/.git"),
10326 false,
10327 git::repository::Worktree {
10328 path: PathBuf::from("/worktrees/project/feature-a/project"),
10329 ref_name: Some("refs/heads/feature-a".into()),
10330 sha: "abc".into(),
10331 is_main: false,
10332 is_bare: false,
10333 },
10334 )
10335 .await;
10336
10337 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10338
10339 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10340 let worktree_project = project::Project::test(
10341 fs.clone(),
10342 ["/worktrees/project/feature-a/project".as_ref()],
10343 cx,
10344 )
10345 .await;
10346
10347 main_project
10348 .update(cx, |p, cx| p.git_scans_complete(cx))
10349 .await;
10350 worktree_project
10351 .update(cx, |p, cx| p.git_scans_complete(cx))
10352 .await;
10353
10354 let (multi_workspace, cx) =
10355 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10356 let sidebar = setup_sidebar(&multi_workspace, cx);
10357
10358 multi_workspace.update_in(cx, |mw, window, cx| {
10359 mw.test_add_workspace(worktree_project.clone(), window, cx)
10360 });
10361
10362 // Save thread metadata using folder_paths that DON'T match the
10363 // workspace's root paths. This simulates the case where the workspace's
10364 // paths diverged (e.g. a folder was added after thread creation).
10365 // This causes workspace_to_remove to be None because
10366 // workspace_for_paths can't find a workspace with these exact paths.
10367 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10368 save_thread_metadata_with_main_paths(
10369 "worktree-thread",
10370 "Worktree Thread",
10371 PathList::new(&[
10372 PathBuf::from("/worktrees/project/feature-a/project"),
10373 PathBuf::from("/nonexistent"),
10374 ]),
10375 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10376 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10377 cx,
10378 );
10379
10380 // Also save a main thread so the sidebar has something to show.
10381 save_thread_metadata(
10382 acp::SessionId::new(Arc::from("main-thread")),
10383 Some("Main Thread".into()),
10384 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10385 None,
10386 None,
10387 &main_project,
10388 cx,
10389 );
10390 cx.run_until_parked();
10391
10392 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10393 cx.run_until_parked();
10394
10395 assert_eq!(
10396 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10397 2,
10398 "should start with 2 workspaces (main + linked worktree)"
10399 );
10400
10401 // Archive the worktree thread.
10402 sidebar.update_in(cx, |sidebar, window, cx| {
10403 sidebar.archive_thread(&wt_thread_id, window, cx);
10404 });
10405
10406 cx.run_until_parked();
10407
10408 // The linked worktree workspace should have been removed, even though
10409 // workspace_to_remove was None (paths didn't match).
10410 assert_eq!(
10411 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10412 1,
10413 "linked worktree workspace should be removed after archiving, \
10414 even when folder_paths don't match workspace root paths"
10415 );
10416
10417 // The thread should still be archived (not unarchived due to an error).
10418 let still_archived = cx.update(|_, cx| {
10419 ThreadMetadataStore::global(cx)
10420 .read(cx)
10421 .entry_by_session(&wt_thread_id)
10422 .map(|t| t.archived)
10423 });
10424 assert_eq!(
10425 still_archived,
10426 Some(true),
10427 "thread should still be archived (not rolled back due to error)"
10428 );
10429
10430 // The linked worktree directory should be removed from disk.
10431 assert!(
10432 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
10433 .await,
10434 "linked worktree directory should be removed from disk"
10435 );
10436}
10437
10438#[gpui::test]
10439async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10440 // When a workspace contains both a worktree being archived and other
10441 // worktrees that should remain, only the editor items referencing the
10442 // archived worktree should be closed — the workspace itself must be
10443 // preserved.
10444 init_test(cx);
10445 let fs = FakeFs::new(cx.executor());
10446
10447 fs.insert_tree(
10448 "/main-repo",
10449 serde_json::json!({
10450 ".git": {
10451 "worktrees": {
10452 "feature-b": {
10453 "commondir": "../../",
10454 "HEAD": "ref: refs/heads/feature-b",
10455 },
10456 },
10457 },
10458 "src": {
10459 "lib.rs": "pub fn hello() {}",
10460 },
10461 }),
10462 )
10463 .await;
10464
10465 fs.insert_tree(
10466 "/worktrees/main-repo/feature-b/main-repo",
10467 serde_json::json!({
10468 ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10469 "src": {
10470 "main.rs": "fn main() { hello(); }",
10471 },
10472 }),
10473 )
10474 .await;
10475
10476 fs.add_linked_worktree_for_repo(
10477 Path::new("/main-repo/.git"),
10478 false,
10479 git::repository::Worktree {
10480 path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10481 ref_name: Some("refs/heads/feature-b".into()),
10482 sha: "def".into(),
10483 is_main: false,
10484 is_bare: false,
10485 },
10486 )
10487 .await;
10488
10489 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10490
10491 // Create a single project that contains BOTH the main repo and the
10492 // linked worktree — this makes it a "mixed" workspace.
10493 let mixed_project = project::Project::test(
10494 fs.clone(),
10495 [
10496 "/main-repo".as_ref(),
10497 "/worktrees/main-repo/feature-b/main-repo".as_ref(),
10498 ],
10499 cx,
10500 )
10501 .await;
10502
10503 mixed_project
10504 .update(cx, |p, cx| p.git_scans_complete(cx))
10505 .await;
10506
10507 let (multi_workspace, cx) = cx
10508 .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10509 let sidebar = setup_sidebar(&multi_workspace, cx);
10510
10511 // Open editor items in both worktrees so we can verify which ones
10512 // get closed.
10513 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10514
10515 let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10516 ws.project()
10517 .read(cx)
10518 .visible_worktrees(cx)
10519 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10520 .collect()
10521 });
10522
10523 let main_repo_wt_id = worktree_ids
10524 .iter()
10525 .find(|(_, path)| path.as_ref() == Path::new("/main-repo"))
10526 .map(|(id, _)| *id)
10527 .expect("should find main-repo worktree");
10528
10529 let feature_b_wt_id = worktree_ids
10530 .iter()
10531 .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo"))
10532 .map(|(id, _)| *id)
10533 .expect("should find feature-b worktree");
10534
10535 // Open files from both worktrees.
10536 let main_repo_path = project::ProjectPath {
10537 worktree_id: main_repo_wt_id,
10538 path: Arc::from(rel_path("src/lib.rs")),
10539 };
10540 let feature_b_path = project::ProjectPath {
10541 worktree_id: feature_b_wt_id,
10542 path: Arc::from(rel_path("src/main.rs")),
10543 };
10544
10545 workspace
10546 .update_in(cx, |ws, window, cx| {
10547 ws.open_path(main_repo_path.clone(), None, true, window, cx)
10548 })
10549 .await
10550 .expect("should open main-repo file");
10551 workspace
10552 .update_in(cx, |ws, window, cx| {
10553 ws.open_path(feature_b_path.clone(), None, true, window, cx)
10554 })
10555 .await
10556 .expect("should open feature-b file");
10557
10558 cx.run_until_parked();
10559
10560 // Verify both items are open.
10561 let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10562 ws.panes()
10563 .iter()
10564 .flat_map(|pane| {
10565 pane.read(cx)
10566 .items()
10567 .filter_map(|item| item.project_path(cx))
10568 })
10569 .collect()
10570 });
10571 assert!(
10572 open_paths_before
10573 .iter()
10574 .any(|pp| pp.worktree_id == main_repo_wt_id),
10575 "main-repo file should be open"
10576 );
10577 assert!(
10578 open_paths_before
10579 .iter()
10580 .any(|pp| pp.worktree_id == feature_b_wt_id),
10581 "feature-b file should be open"
10582 );
10583
10584 // Save thread metadata for the linked worktree with deliberately
10585 // mismatched folder_paths to trigger the scan-based detection.
10586 save_thread_metadata_with_main_paths(
10587 "feature-b-thread",
10588 "Feature B Thread",
10589 PathList::new(&[
10590 PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10591 PathBuf::from("/nonexistent"),
10592 ]),
10593 PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10594 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10595 cx,
10596 );
10597
10598 // Save another thread that references only the main repo (not the
10599 // linked worktree) so archiving the feature-b thread's worktree isn't
10600 // blocked by another unarchived thread referencing the same path.
10601 save_thread_metadata_with_main_paths(
10602 "other-thread",
10603 "Other Thread",
10604 PathList::new(&[PathBuf::from("/main-repo")]),
10605 PathList::new(&[PathBuf::from("/main-repo")]),
10606 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10607 cx,
10608 );
10609 cx.run_until_parked();
10610
10611 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10612 cx.run_until_parked();
10613
10614 // There should still be exactly 1 workspace.
10615 assert_eq!(
10616 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10617 1,
10618 "should have 1 workspace (the mixed workspace)"
10619 );
10620
10621 // Archive the feature-b thread.
10622 let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10623 sidebar.update_in(cx, |sidebar, window, cx| {
10624 sidebar.archive_thread(&fb_session_id, window, cx);
10625 });
10626
10627 cx.run_until_parked();
10628
10629 // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10630 assert_eq!(
10631 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10632 1,
10633 "mixed workspace should be preserved"
10634 );
10635
10636 // Only the feature-b editor item should have been closed.
10637 let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10638 ws.panes()
10639 .iter()
10640 .flat_map(|pane| {
10641 pane.read(cx)
10642 .items()
10643 .filter_map(|item| item.project_path(cx))
10644 })
10645 .collect()
10646 });
10647 assert!(
10648 open_paths_after
10649 .iter()
10650 .any(|pp| pp.worktree_id == main_repo_wt_id),
10651 "main-repo file should still be open"
10652 );
10653 assert!(
10654 !open_paths_after
10655 .iter()
10656 .any(|pp| pp.worktree_id == feature_b_wt_id),
10657 "feature-b file should have been closed"
10658 );
10659}
10660
10661#[test]
10662fn test_worktree_info_branch_names_for_main_worktrees() {
10663 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10664 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10665
10666 let branch_by_path: HashMap<PathBuf, SharedString> =
10667 [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10668 .into_iter()
10669 .collect();
10670
10671 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10672 assert_eq!(infos.len(), 1);
10673 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10674 assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10675 assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10676}
10677
10678#[test]
10679fn test_worktree_info_branch_names_for_linked_worktrees() {
10680 let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10681 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10682 let worktree_paths =
10683 WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10684
10685 let branch_by_path: HashMap<PathBuf, SharedString> = [(
10686 PathBuf::from("/projects/myapp-feature"),
10687 "feature-branch".into(),
10688 )]
10689 .into_iter()
10690 .collect();
10691
10692 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10693 assert_eq!(infos.len(), 1);
10694 assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10695 assert_eq!(
10696 infos[0].branch_name,
10697 Some(SharedString::from("feature-branch"))
10698 );
10699}
10700
10701#[test]
10702fn test_worktree_info_missing_branch_returns_none() {
10703 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10704 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10705
10706 let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10707
10708 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10709 assert_eq!(infos.len(), 1);
10710 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10711 assert_eq!(infos[0].branch_name, None);
10712 assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10713}
10714
10715#[gpui::test]
10716async fn test_remote_archive_thread_with_active_connection(
10717 cx: &mut TestAppContext,
10718 server_cx: &mut TestAppContext,
10719) {
10720 // End-to-end test of archiving a remote thread tied to a linked git
10721 // worktree. Archival should:
10722 // 1. Persist the worktree's git state via the remote repository RPCs
10723 // (head_sha / create_archive_checkpoint / update_ref).
10724 // 2. Remove the linked worktree directory from the *remote* filesystem
10725 // via the GitRemoveWorktree RPC.
10726 // 3. Mark the thread metadata archived and hide it from the sidebar.
10727 //
10728 // The mock remote transport only supports one live `RemoteClient` per
10729 // connection at a time (each client's `start_proxy` replaces the
10730 // previous server channel), so we can't split the main repo and the
10731 // linked worktree across two remote projects the way Zed does in
10732 // production. Opening both as visible worktrees of a single remote
10733 // project still exercises every interesting path of the archive flow
10734 // while staying within the mock's multiplexing limits.
10735 init_test(cx);
10736
10737 cx.update(|cx| {
10738 release_channel::init(semver::Version::new(0, 0, 0), cx);
10739 });
10740
10741 let app_state = cx.update(|cx| {
10742 let app_state = workspace::AppState::test(cx);
10743 workspace::init(app_state.clone(), cx);
10744 app_state
10745 });
10746
10747 server_cx.update(|cx| {
10748 release_channel::init(semver::Version::new(0, 0, 0), cx);
10749 });
10750
10751 // Set up the remote filesystem with a main repo and one linked worktree.
10752 let server_fs = FakeFs::new(server_cx.executor());
10753 server_fs
10754 .insert_tree(
10755 "/project",
10756 serde_json::json!({
10757 ".git": {
10758 "worktrees": {
10759 "feature-a": {
10760 "commondir": "../../",
10761 "HEAD": "ref: refs/heads/feature-a",
10762 },
10763 },
10764 },
10765 "src": { "main.rs": "fn main() {}" },
10766 }),
10767 )
10768 .await;
10769 server_fs
10770 .insert_tree(
10771 "/worktrees/project/feature-a/project",
10772 serde_json::json!({
10773 ".git": "gitdir: /project/.git/worktrees/feature-a",
10774 "src": { "lib.rs": "// feature" },
10775 }),
10776 )
10777 .await;
10778 server_fs
10779 .add_linked_worktree_for_repo(
10780 Path::new("/project/.git"),
10781 false,
10782 git::repository::Worktree {
10783 path: PathBuf::from("/worktrees/project/feature-a/project"),
10784 ref_name: Some("refs/heads/feature-a".into()),
10785 sha: "abc".into(),
10786 is_main: false,
10787 is_bare: false,
10788 },
10789 )
10790 .await;
10791 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10792 server_fs.set_head_for_repo(
10793 Path::new("/project/.git"),
10794 &[("src/main.rs", "fn main() {}".into())],
10795 "head-sha",
10796 );
10797
10798 // Open a single remote project with both the main repo and the linked
10799 // worktree as visible worktrees. The mock transport doesn't multiplex
10800 // multiple `RemoteClient`s over one pooled connection cleanly (each
10801 // client's `start_proxy` clobbers the previous one's server channel),
10802 // so we can't build two separate `Project::remote` instances in this
10803 // test. Folding both worktrees into one project still exercises the
10804 // archive flow's interesting paths: `build_root_plan` classifies the
10805 // linked worktree correctly, and `find_or_create_repository` finds
10806 // the main repo live on that same project — avoiding the temp-project
10807 // fallback that would also run into the multiplexing limitation.
10808 let (project, _headless, _opts) = start_remote_project(
10809 &server_fs,
10810 Path::new("/project"),
10811 &app_state,
10812 None,
10813 cx,
10814 server_cx,
10815 )
10816 .await;
10817 project
10818 .update(cx, |project, cx| {
10819 project.find_or_create_worktree(
10820 Path::new("/worktrees/project/feature-a/project"),
10821 true,
10822 cx,
10823 )
10824 })
10825 .await
10826 .expect("should open linked worktree on remote");
10827 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10828 cx.run_until_parked();
10829
10830 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10831
10832 let (multi_workspace, cx) =
10833 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10834 let sidebar = setup_sidebar(&multi_workspace, cx);
10835
10836 // The worktree thread's (main_worktree_path, folder_path) pair points
10837 // the folder at the linked worktree checkout and the main at the
10838 // parent repo, so `build_root_plan` targets the linked worktree
10839 // specifically and knows which main repo owns it.
10840 let remote_connection = project.read_with(cx, |p, cx| p.remote_connection_options(cx));
10841 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10842 cx.update(|_window, cx| {
10843 let metadata = ThreadMetadata {
10844 thread_id: ThreadId::new(),
10845 session_id: Some(wt_thread_id.clone()),
10846 agent_id: agent::ZED_AGENT_ID.clone(),
10847 title: Some("Worktree Thread".into()),
10848 updated_at: chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
10849 .unwrap(),
10850 created_at: None,
10851 interacted_at: None,
10852 worktree_paths: WorktreePaths::from_path_lists(
10853 PathList::new(&[PathBuf::from("/project")]),
10854 PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]),
10855 )
10856 .unwrap(),
10857 archived: false,
10858 remote_connection,
10859 };
10860 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10861 });
10862 cx.run_until_parked();
10863
10864 assert!(
10865 server_fs
10866 .is_dir(Path::new("/worktrees/project/feature-a/project"))
10867 .await,
10868 "linked worktree directory should exist on remote before archiving"
10869 );
10870
10871 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
10872 sidebar.archive_thread(&wt_thread_id, window, cx);
10873 });
10874 cx.run_until_parked();
10875 server_cx.run_until_parked();
10876
10877 let is_archived = cx.update(|_window, cx| {
10878 ThreadMetadataStore::global(cx)
10879 .read(cx)
10880 .entry_by_session(&wt_thread_id)
10881 .map(|t| t.archived)
10882 .unwrap_or(false)
10883 });
10884 assert!(is_archived, "worktree thread should be archived");
10885
10886 assert!(
10887 !server_fs
10888 .is_dir(Path::new("/worktrees/project/feature-a/project"))
10889 .await,
10890 "linked worktree directory should be removed from remote fs \
10891 (the GitRemoveWorktree RPC runs `Repository::remove_worktree` \
10892 on the headless server, which deletes the directory via `Fs::remove_dir` \
10893 before running `git worktree remove --force`)"
10894 );
10895
10896 let entries = visible_entries_as_strings(&sidebar, cx);
10897 assert!(
10898 !entries.iter().any(|e| e.contains("Worktree Thread")),
10899 "archived worktree thread should be hidden from sidebar: {entries:?}"
10900 );
10901}
10902
10903#[gpui::test]
10904async fn test_remote_archive_thread_with_disconnected_remote(
10905 cx: &mut TestAppContext,
10906 server_cx: &mut TestAppContext,
10907) {
10908 // When a remote thread has no linked-worktree state to archive (only
10909 // a main worktree), archival is a pure metadata operation: no RPCs
10910 // are issued against the remote server. This must succeed even when
10911 // the connection has dropped out, because losing connectivity should
10912 // not block users from cleaning up their thread list.
10913 //
10914 // Threads that *do* have linked-worktree state require a live
10915 // connection to run the git worktree removal on the server; that
10916 // path is covered by `test_remote_archive_thread_with_active_connection`.
10917 init_test(cx);
10918
10919 cx.update(|cx| {
10920 release_channel::init(semver::Version::new(0, 0, 0), cx);
10921 });
10922
10923 let app_state = cx.update(|cx| {
10924 let app_state = workspace::AppState::test(cx);
10925 workspace::init(app_state.clone(), cx);
10926 app_state
10927 });
10928
10929 server_cx.update(|cx| {
10930 release_channel::init(semver::Version::new(0, 0, 0), cx);
10931 });
10932
10933 let server_fs = FakeFs::new(server_cx.executor());
10934 server_fs
10935 .insert_tree(
10936 "/project",
10937 serde_json::json!({
10938 ".git": {},
10939 "src": { "main.rs": "fn main() {}" },
10940 }),
10941 )
10942 .await;
10943 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10944
10945 let (project, _headless, _opts) = start_remote_project(
10946 &server_fs,
10947 Path::new("/project"),
10948 &app_state,
10949 None,
10950 cx,
10951 server_cx,
10952 )
10953 .await;
10954 let remote_client = project
10955 .read_with(cx, |project, _cx| project.remote_client())
10956 .expect("remote project should expose its client");
10957
10958 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10959
10960 let (multi_workspace, cx) =
10961 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10962 let sidebar = setup_sidebar(&multi_workspace, cx);
10963
10964 let thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10965 save_thread_metadata(
10966 thread_id.clone(),
10967 Some("Remote Thread".into()),
10968 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10969 None,
10970 None,
10971 &project,
10972 cx,
10973 );
10974 cx.run_until_parked();
10975
10976 // Sanity-check: there is nothing on the remote fs outside the main
10977 // repo, so archival should not need to touch the server.
10978 assert!(
10979 !server_fs.is_dir(Path::new("/worktrees")).await,
10980 "no linked worktrees on the server before archiving"
10981 );
10982
10983 // Disconnect the remote connection before archiving. We don't
10984 // `run_until_parked` here because the disconnect itself triggers
10985 // reconnection work that can't complete in the test environment.
10986 remote_client.update(cx, |client, cx| {
10987 client.simulate_disconnect(cx).detach();
10988 });
10989
10990 sidebar.update_in(cx, |sidebar, window, cx| {
10991 sidebar.archive_thread(&thread_id, window, cx);
10992 });
10993 cx.run_until_parked();
10994
10995 let is_archived = cx.update(|_window, cx| {
10996 ThreadMetadataStore::global(cx)
10997 .read(cx)
10998 .entry_by_session(&thread_id)
10999 .map(|t| t.archived)
11000 .unwrap_or(false)
11001 });
11002 assert!(
11003 is_archived,
11004 "thread should be archived even when remote is disconnected"
11005 );
11006
11007 let entries = visible_entries_as_strings(&sidebar, cx);
11008 assert!(
11009 !entries.iter().any(|e| e.contains("Remote Thread")),
11010 "archived thread should be hidden from sidebar: {entries:?}"
11011 );
11012}
11013
11014#[gpui::test]
11015async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) {
11016 init_test(cx);
11017 let fs = FakeFs::new(cx.executor());
11018 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
11019 .await;
11020 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11021 .await;
11022 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11023 let project = project::Project::test(fs, ["/project-a".as_ref()], cx).await;
11024
11025 let (multi_workspace, cx) =
11026 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11027
11028 // Set up the sidebar while the project is local. This registers the
11029 // WorktreePathsChanged subscription for the project.
11030 let _sidebar = setup_sidebar(&multi_workspace, cx);
11031
11032 let session_id = acp::SessionId::new(Arc::from("test-thread"));
11033 save_named_thread_metadata("test-thread", "My Thread", &project, cx).await;
11034
11035 let thread_id = cx.update(|_window, cx| {
11036 ThreadMetadataStore::global(cx)
11037 .read(cx)
11038 .entry_by_session(&session_id)
11039 .map(|e| e.thread_id)
11040 .expect("thread must be in the store")
11041 });
11042
11043 cx.update(|_window, cx| {
11044 let store = ThreadMetadataStore::global(cx);
11045 let entry = store.read(cx).entry(thread_id).unwrap();
11046 assert_eq!(
11047 entry.folder_paths().paths(),
11048 &[PathBuf::from("/project-a")],
11049 "thread must be saved with /project-a before collab"
11050 );
11051 });
11052
11053 // Transition the project into collab mode. The sidebar's subscription is
11054 // still active from when the project was local.
11055 project.update(cx, |project, _cx| {
11056 project.mark_as_collab_for_testing();
11057 });
11058
11059 // Adding a worktree fires WorktreePathsChanged with old_paths = {/project-a}.
11060 // The sidebar's subscription is still active, so move_thread_paths is called.
11061 // Without the is_via_collab() guard inside move_thread_paths, this would
11062 // update the stored thread paths from {/project-a} to {/project-a, /project-b}.
11063 project
11064 .update(cx, |project, cx| {
11065 project.find_or_create_worktree("/project-b", true, cx)
11066 })
11067 .await
11068 .expect("should add worktree");
11069 cx.run_until_parked();
11070
11071 cx.update(|_window, cx| {
11072 let store = ThreadMetadataStore::global(cx);
11073 let entry = store
11074 .read(cx)
11075 .entry(thread_id)
11076 .expect("thread must still exist");
11077 assert_eq!(
11078 entry.folder_paths().paths(),
11079 &[PathBuf::from("/project-a")],
11080 "thread path must not change when project is via collab"
11081 );
11082 });
11083}
11084
11085#[gpui::test]
11086async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace(
11087 cx: &mut TestAppContext,
11088) {
11089 // Regression test for: cmd-clicking a project group header should return
11090 // the user to the workspace they most recently had active in that group,
11091 // including workspaces rooted at a linked worktree.
11092 init_test(cx);
11093 let fs = FakeFs::new(cx.executor());
11094
11095 fs.insert_tree(
11096 "/project-a",
11097 serde_json::json!({
11098 ".git": {},
11099 "src": {},
11100 }),
11101 )
11102 .await;
11103 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11104 .await;
11105
11106 fs.add_linked_worktree_for_repo(
11107 Path::new("/project-a/.git"),
11108 false,
11109 git::repository::Worktree {
11110 path: std::path::PathBuf::from("/wt-feature-a"),
11111 ref_name: Some("refs/heads/feature-a".into()),
11112 sha: "aaa".into(),
11113 is_main: false,
11114 is_bare: false,
11115 },
11116 )
11117 .await;
11118
11119 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11120
11121 let main_project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
11122 let worktree_project_a =
11123 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
11124 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
11125
11126 main_project_a
11127 .update(cx, |p, cx| p.git_scans_complete(cx))
11128 .await;
11129 worktree_project_a
11130 .update(cx, |p, cx| p.git_scans_complete(cx))
11131 .await;
11132
11133 // The multi-workspace starts with the main-paths workspace of group A
11134 // as the initially active workspace.
11135 let (multi_workspace, cx) = cx
11136 .add_window_view(|window, cx| MultiWorkspace::test_new(main_project_a.clone(), window, cx));
11137
11138 let sidebar = setup_sidebar(&multi_workspace, cx);
11139
11140 // Capture the initially active workspace (group A's main-paths workspace)
11141 // *before* registering additional workspaces, since `workspaces()` returns
11142 // retained workspaces in registration order — not activation order — and
11143 // the multi-workspace's starting workspace may not be retained yet.
11144 let main_workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11145
11146 // Register the linked-worktree workspace (group A) and the group-B
11147 // workspace. Both get retained by the multi-workspace.
11148 let worktree_workspace_a = multi_workspace.update_in(cx, |mw, window, cx| {
11149 mw.test_add_workspace(worktree_project_a.clone(), window, cx)
11150 });
11151 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
11152 mw.test_add_workspace(project_b.clone(), window, cx)
11153 });
11154
11155 cx.run_until_parked();
11156
11157 // Step 1: activate the linked-worktree workspace. The MultiWorkspace
11158 // records this as the last-active workspace for group A on its
11159 // ProjectGroupState. (We don't assert on the initial active workspace
11160 // because `test_add_workspace` may auto-activate newly registered
11161 // workspaces — what matters for this test is the explicit sequence of
11162 // activations below.)
11163 multi_workspace.update_in(cx, |mw, window, cx| {
11164 mw.activate(worktree_workspace_a.clone(), None, window, cx);
11165 });
11166 cx.run_until_parked();
11167 assert_eq!(
11168 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11169 worktree_workspace_a,
11170 "linked-worktree workspace should be active after step 1"
11171 );
11172
11173 // Step 2: switch to the workspace for group B. Group A's last-active
11174 // workspace remains the linked-worktree one (group B getting activated
11175 // records *its own* last-active workspace, not group A's).
11176 multi_workspace.update_in(cx, |mw, window, cx| {
11177 mw.activate(workspace_b.clone(), None, window, cx);
11178 });
11179 cx.run_until_parked();
11180 assert_eq!(
11181 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11182 workspace_b,
11183 "group B's workspace should be active after step 2"
11184 );
11185
11186 // Step 3: simulate cmd-click on group A's header. The project group key
11187 // for group A is derived from the *main-paths* workspace (linked-worktree
11188 // workspaces share the same key because it normalizes to main-worktree
11189 // paths).
11190 let group_a_key = main_workspace_a.read_with(cx, |ws, cx| ws.project_group_key(cx));
11191 sidebar.update_in(cx, |sidebar, window, cx| {
11192 sidebar.activate_or_open_workspace_for_group(&group_a_key, window, cx);
11193 });
11194 cx.run_until_parked();
11195
11196 // Expected: we're back in the linked-worktree workspace, not the
11197 // main-paths one.
11198 let active_after_cmd_click = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11199 assert_eq!(
11200 active_after_cmd_click, worktree_workspace_a,
11201 "cmd-click on group A's header should return to the last-active \
11202 linked-worktree workspace, not the main-paths workspace"
11203 );
11204 assert_ne!(
11205 active_after_cmd_click, main_workspace_a,
11206 "cmd-click must not fall back to the main-paths workspace when a \
11207 linked-worktree workspace was the last-active one for the group"
11208 );
11209}