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
36fn enable_agent_panel_terminal(cx: &mut TestAppContext) {
37 cx.update(|cx| {
38 cx.update_flags(true, vec!["agent-panel-terminal".to_string()]);
39 });
40}
41
42#[track_caller]
43fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
44 let active = sidebar.active_entry.as_ref();
45 let matches = active.is_some_and(|entry| {
46 matches!(entry, ActiveEntry::Thread { session_id: Some(active_session_id), .. } if active_session_id == session_id)
47 || sidebar.contents.entries.iter().any(|list_entry| {
48 matches!(list_entry, ListEntry::Thread(t)
49 if t.metadata.session_id.as_ref() == Some(session_id)
50 && entry.matches_entry(list_entry))
51 })
52 });
53 assert!(
54 matches,
55 "{msg}: expected active_entry for session {session_id:?}, got {:?}",
56 active,
57 );
58}
59
60#[track_caller]
61fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
62 let thread_id = sidebar
63 .contents
64 .entries
65 .iter()
66 .find_map(|entry| match entry {
67 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
68 Some(t.metadata.thread_id)
69 }
70 _ => None,
71 });
72 match thread_id {
73 Some(tid) => {
74 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { thread_id, .. }) if *thread_id == tid)
75 }
76 // Thread not in sidebar entries — can't confirm it's active.
77 None => false,
78 }
79}
80
81#[track_caller]
82fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
83 assert!(
84 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == workspace),
85 "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
86 workspace.entity_id(),
87 sidebar.active_entry,
88 );
89}
90
91fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
92 sidebar
93 .contents
94 .entries
95 .iter()
96 .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
97}
98
99#[track_caller]
100fn assert_remote_project_integration_sidebar_state(
101 sidebar: &mut Sidebar,
102 main_thread_id: &acp::SessionId,
103 remote_thread_id: &acp::SessionId,
104) {
105 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
106 if let ListEntry::ProjectHeader { label, .. } = entry {
107 Some(label.as_ref())
108 } else {
109 None
110 }
111 });
112
113 let Some(project_header) = project_headers.next() else {
114 panic!("expected exactly one sidebar project header named `project`, found none");
115 };
116 assert_eq!(
117 project_header, "project",
118 "expected the only sidebar project header to be `project`"
119 );
120 if let Some(unexpected_header) = project_headers.next() {
121 panic!(
122 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
123 );
124 }
125
126 let mut saw_main_thread = false;
127 let mut saw_remote_thread = false;
128 for entry in &sidebar.contents.entries {
129 match entry {
130 ListEntry::ProjectHeader { label, .. } => {
131 assert_eq!(
132 label.as_ref(),
133 "project",
134 "expected the only sidebar project header to be `project`"
135 );
136 }
137 ListEntry::Thread(thread)
138 if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
139 {
140 saw_main_thread = true;
141 }
142 ListEntry::Thread(thread)
143 if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
144 {
145 saw_remote_thread = true;
146 }
147 ListEntry::Thread(thread) => {
148 let title = thread.metadata.display_title();
149 panic!(
150 "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
151 title
152 );
153 }
154 ListEntry::Terminal(terminal) => {
155 panic!(
156 "unexpected sidebar terminal while simulating remote project integration flicker: title=`{}`",
157 terminal.title
158 );
159 }
160 }
161 }
162
163 assert!(
164 saw_main_thread,
165 "expected the sidebar to keep showing `Main Thread` under `project`"
166 );
167 assert!(
168 saw_remote_thread,
169 "expected the sidebar to keep showing `Worktree Thread` under `project`"
170 );
171}
172
173async fn init_test_project(
174 worktree_path: &str,
175 cx: &mut TestAppContext,
176) -> Entity<project::Project> {
177 init_test(cx);
178 let fs = FakeFs::new(cx.executor());
179 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
180 .await;
181 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
182 project::Project::test(fs, [worktree_path.as_ref()], cx).await
183}
184
185fn setup_sidebar(
186 multi_workspace: &Entity<MultiWorkspace>,
187 cx: &mut gpui::VisualTestContext,
188) -> Entity<Sidebar> {
189 let sidebar = setup_sidebar_closed(multi_workspace, cx);
190 multi_workspace.update_in(cx, |mw, window, cx| {
191 mw.toggle_sidebar(window, cx);
192 });
193 cx.run_until_parked();
194 sidebar
195}
196
197fn setup_sidebar_closed(
198 multi_workspace: &Entity<MultiWorkspace>,
199 cx: &mut gpui::VisualTestContext,
200) -> Entity<Sidebar> {
201 let multi_workspace = multi_workspace.clone();
202 let sidebar =
203 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
204 multi_workspace.update(cx, |mw, cx| {
205 mw.register_sidebar(sidebar.clone(), cx);
206 });
207 cx.run_until_parked();
208 sidebar
209}
210
211async fn save_n_test_threads(
212 count: u32,
213 project: &Entity<project::Project>,
214 cx: &mut gpui::VisualTestContext,
215) {
216 for i in 0..count {
217 save_thread_metadata(
218 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
219 Some(format!("Thread {}", i + 1).into()),
220 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
221 None,
222 None,
223 project,
224 cx,
225 )
226 }
227 cx.run_until_parked();
228}
229
230async fn save_test_thread_metadata(
231 session_id: &acp::SessionId,
232 project: &Entity<project::Project>,
233 cx: &mut TestAppContext,
234) {
235 save_thread_metadata(
236 session_id.clone(),
237 Some("Test".into()),
238 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
239 None,
240 None,
241 project,
242 cx,
243 )
244}
245
246async fn save_named_thread_metadata(
247 session_id: &str,
248 title: &str,
249 project: &Entity<project::Project>,
250 cx: &mut gpui::VisualTestContext,
251) {
252 save_thread_metadata(
253 acp::SessionId::new(Arc::from(session_id)),
254 Some(SharedString::from(title.to_string())),
255 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
256 None,
257 None,
258 project,
259 cx,
260 );
261 cx.run_until_parked();
262}
263
264/// Spins up a fresh remote project backed by a headless server sharing
265/// `server_fs`, opens the given worktree path on it, and returns the
266/// project together with the headless entity (which the caller must keep
267/// alive for the duration of the test) and the `RemoteConnectionOptions`
268/// used for the fake server. Passing those options back into
269/// `reuse_opts` on a subsequent call makes the new project share the
270/// same `RemoteConnectionIdentity`, matching how Zed treats multiple
271/// projects on the same SSH host.
272async fn start_remote_project(
273 server_fs: &Arc<FakeFs>,
274 worktree_path: &Path,
275 app_state: &Arc<workspace::AppState>,
276 reuse_opts: Option<&remote::RemoteConnectionOptions>,
277 cx: &mut TestAppContext,
278 server_cx: &mut TestAppContext,
279) -> (
280 Entity<project::Project>,
281 Entity<remote_server::HeadlessProject>,
282 remote::RemoteConnectionOptions,
283) {
284 // Bare `_` on the guard so it's dropped immediately; holding onto it
285 // would deadlock `connect_mock` below since the client waits on the
286 // guard before completing the mock handshake.
287 let (opts, server_session) = match reuse_opts {
288 Some(existing) => {
289 let (session, _) = remote::RemoteClient::fake_server_with_opts(existing, cx, server_cx);
290 (existing.clone(), session)
291 }
292 None => {
293 let (opts, session, _) = remote::RemoteClient::fake_server(cx, server_cx);
294 (opts, session)
295 }
296 };
297
298 server_cx.update(remote_server::HeadlessProject::init);
299 let server_executor = server_cx.executor();
300 let fs = server_fs.clone();
301 let headless = server_cx.new(|cx| {
302 remote_server::HeadlessProject::new(
303 remote_server::HeadlessAppState {
304 session: server_session,
305 fs,
306 http_client: Arc::new(http_client::BlockedHttpClient),
307 node_runtime: node_runtime::NodeRuntime::unavailable(),
308 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
309 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
310 startup_time: std::time::Instant::now(),
311 },
312 false,
313 cx,
314 )
315 });
316
317 let remote_client = remote::RemoteClient::connect_mock(opts.clone(), cx).await;
318 let project = cx.update(|cx| {
319 let project_client = client::Client::new(
320 Arc::new(clock::FakeSystemClock::new()),
321 http_client::FakeHttpClient::with_404_response(),
322 cx,
323 );
324 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
325 project::Project::remote(
326 remote_client,
327 project_client,
328 node_runtime::NodeRuntime::unavailable(),
329 user_store,
330 app_state.languages.clone(),
331 app_state.fs.clone(),
332 false,
333 cx,
334 )
335 });
336
337 project
338 .update(cx, |project, cx| {
339 project.find_or_create_worktree(worktree_path, true, cx)
340 })
341 .await
342 .expect("should open remote worktree");
343 cx.run_until_parked();
344
345 (project, headless, opts)
346}
347
348fn save_thread_metadata(
349 session_id: acp::SessionId,
350 title: Option<SharedString>,
351 updated_at: DateTime<Utc>,
352 created_at: Option<DateTime<Utc>>,
353 interacted_at: Option<DateTime<Utc>>,
354 project: &Entity<project::Project>,
355 cx: &mut TestAppContext,
356) {
357 cx.update(|cx| {
358 let worktree_paths = project.read(cx).worktree_paths(cx);
359 let remote_connection = project.read(cx).remote_connection_options(cx);
360 let thread_id = ThreadMetadataStore::global(cx)
361 .read(cx)
362 .entries()
363 .find(|e| e.session_id.as_ref() == Some(&session_id))
364 .map(|e| e.thread_id)
365 .unwrap_or_else(ThreadId::new);
366 let metadata = ThreadMetadata {
367 thread_id,
368 session_id: Some(session_id),
369 agent_id: agent::ZED_AGENT_ID.clone(),
370 title,
371 updated_at,
372 created_at,
373 interacted_at,
374 worktree_paths,
375 archived: false,
376 remote_connection,
377 };
378 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
379 });
380 cx.run_until_parked();
381}
382
383fn save_thread_metadata_with_main_paths(
384 session_id: &str,
385 title: &str,
386 folder_paths: PathList,
387 main_worktree_paths: PathList,
388 updated_at: DateTime<Utc>,
389 cx: &mut TestAppContext,
390) {
391 let session_id = acp::SessionId::new(Arc::from(session_id));
392 let title = SharedString::from(title.to_string());
393 let thread_id = cx.update(|cx| {
394 ThreadMetadataStore::global(cx)
395 .read(cx)
396 .entries()
397 .find(|e| e.session_id.as_ref() == Some(&session_id))
398 .map(|e| e.thread_id)
399 .unwrap_or_else(ThreadId::new)
400 });
401 let metadata = ThreadMetadata {
402 thread_id,
403 session_id: Some(session_id),
404 agent_id: agent::ZED_AGENT_ID.clone(),
405 title: Some(title),
406 updated_at,
407 created_at: None,
408 interacted_at: None,
409 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
410 archived: false,
411 remote_connection: None,
412 };
413 cx.update(|cx| {
414 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
415 });
416 cx.run_until_parked();
417}
418
419fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
420 sidebar.update_in(cx, |_, window, cx| {
421 cx.focus_self(window);
422 });
423 cx.run_until_parked();
424}
425
426fn request_test_tool_authorization(
427 thread: &Entity<AcpThread>,
428 tool_call_id: &str,
429 option_id: &str,
430 cx: &mut gpui::VisualTestContext,
431) {
432 let tool_call_id = acp::ToolCallId::new(tool_call_id);
433 let label = format!("Tool {tool_call_id}");
434 let option_id = acp::PermissionOptionId::new(option_id);
435 let _authorization_task = cx.update(|_, cx| {
436 thread.update(cx, |thread, cx| {
437 thread
438 .request_tool_call_authorization(
439 acp::ToolCall::new(tool_call_id, label)
440 .kind(acp::ToolKind::Edit)
441 .into(),
442 PermissionOptions::Flat(vec![acp::PermissionOption::new(
443 option_id,
444 "Allow",
445 acp::PermissionOptionKind::AllowOnce,
446 )]),
447 acp_thread::AuthorizationKind::PermissionGrant,
448 cx,
449 )
450 .unwrap()
451 })
452 });
453 cx.run_until_parked();
454}
455
456fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
457 let mut seen = Vec::new();
458 let mut chips = Vec::new();
459 for wt in worktrees {
460 if wt.kind == ui::WorktreeKind::Main {
461 continue;
462 }
463 let Some(name) = wt.worktree_name.as_ref() else {
464 continue;
465 };
466 if !seen.contains(name) {
467 seen.push(name.clone());
468 chips.push(format!("{{{}}}", name));
469 }
470 }
471 if chips.is_empty() {
472 String::new()
473 } else {
474 format!(" {}", chips.join(", "))
475 }
476}
477
478fn visible_entries_as_strings(
479 sidebar: &Entity<Sidebar>,
480 cx: &mut gpui::VisualTestContext,
481) -> Vec<String> {
482 sidebar.read_with(cx, |sidebar, cx| {
483 sidebar
484 .contents
485 .entries
486 .iter()
487 .enumerate()
488 .map(|(ix, entry)| {
489 let selected = if sidebar.selection == Some(ix) {
490 " <== selected"
491 } else {
492 ""
493 };
494 match entry {
495 ListEntry::ProjectHeader {
496 label,
497 key,
498 highlight_positions: _,
499 ..
500 } => {
501 let icon = if sidebar.is_group_collapsed(key, cx) {
502 ">"
503 } else {
504 "v"
505 };
506 format!("{} [{}]{}", icon, label, selected)
507 }
508 ListEntry::Thread(thread) => {
509 let title = thread.metadata.display_title();
510 let worktree = format_linked_worktree_chips(&thread.worktrees);
511
512 {
513 let live = if thread.is_live { " *" } else { "" };
514 let status_str = match thread.status {
515 AgentThreadStatus::Running => " (running)",
516 AgentThreadStatus::Error => " (error)",
517 AgentThreadStatus::WaitingForConfirmation => " (waiting)",
518 _ => "",
519 };
520 let notified = if sidebar
521 .contents
522 .is_thread_notified(&thread.metadata.thread_id)
523 {
524 " (!)"
525 } else {
526 ""
527 };
528 format!(" {title}{worktree}{live}{status_str}{notified}{selected}")
529 }
530 }
531 ListEntry::Terminal(terminal) => {
532 let title = &terminal.title;
533 format!(" {title}{selected}")
534 }
535 }
536 })
537 .collect()
538 })
539}
540
541#[gpui::test]
542async fn test_serialization_round_trip(cx: &mut TestAppContext) {
543 let project = init_test_project("/my-project", cx).await;
544 let (multi_workspace, cx) =
545 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
546 let sidebar = setup_sidebar(&multi_workspace, cx);
547
548 save_n_test_threads(3, &project, cx).await;
549
550 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
551
552 // Set a custom width and collapse the group.
553 sidebar.update_in(cx, |sidebar, window, cx| {
554 sidebar.set_width(Some(px(420.0)), cx);
555 sidebar.toggle_collapse(&project_group_key, window, cx);
556 });
557 cx.run_until_parked();
558
559 // Capture the serialized state from the first sidebar.
560 let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
561 let serialized = serialized.expect("serialized_state should return Some");
562
563 // Create a fresh sidebar and restore into it.
564 let sidebar2 =
565 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
566 cx.run_until_parked();
567
568 sidebar2.update_in(cx, |sidebar, window, cx| {
569 sidebar.restore_serialized_state(&serialized, window, cx);
570 });
571 cx.run_until_parked();
572
573 // Assert all serialized fields match.
574 let width1 = sidebar.read_with(cx, |s, _| s.width);
575 let width2 = sidebar2.read_with(cx, |s, _| s.width);
576
577 assert_eq!(width1, width2);
578 assert_eq!(width1, px(420.0));
579}
580
581#[gpui::test]
582async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
583 // A regression test to ensure that restoring a serialized archive view does not panic.
584 let project = init_test_project_with_agent_panel("/my-project", cx).await;
585 let (multi_workspace, cx) =
586 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
587 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
588 cx.update(|_window, cx| {
589 AgentRegistryStore::init_test_global(cx, vec![]);
590 });
591
592 let serialized = serde_json::to_string(&SerializedSidebar {
593 width: Some(400.0),
594 active_view: SerializedSidebarView::History,
595 })
596 .expect("serialization should succeed");
597
598 multi_workspace.update_in(cx, |multi_workspace, window, cx| {
599 if let Some(sidebar) = multi_workspace.sidebar() {
600 sidebar.restore_serialized_state(&serialized, window, cx);
601 }
602 });
603 cx.run_until_parked();
604
605 // After the deferred `show_archive` runs, the view should be Archive.
606 sidebar.read_with(cx, |sidebar, _cx| {
607 assert!(
608 matches!(sidebar.view, SidebarView::Archive(_)),
609 "expected sidebar view to be Archive after restore, got ThreadList"
610 );
611 });
612}
613
614#[gpui::test]
615async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
616 let project = init_test_project("/my-project", cx).await;
617 let (multi_workspace, cx) =
618 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
619 let sidebar = setup_sidebar(&multi_workspace, cx);
620
621 let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
622 let weak_sidebar = sidebar.downgrade();
623 let weak_multi_workspace = multi_workspace.downgrade();
624
625 drop(sidebar);
626 drop(multi_workspace);
627 cx.update(|window, _cx| window.remove_window());
628 cx.run_until_parked();
629
630 weak_multi_workspace.assert_released();
631 weak_sidebar.assert_released();
632 weak_workspace.assert_released();
633}
634
635#[gpui::test]
636async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
637 let project = init_test_project_with_agent_panel("/my-project", cx).await;
638 let (multi_workspace, cx) =
639 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
640 let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
641
642 assert_eq!(
643 visible_entries_as_strings(&_sidebar, cx),
644 vec!["v [my-project]"]
645 );
646}
647
648#[gpui::test]
649async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
650 let project = init_test_project("/my-project", cx).await;
651 let (multi_workspace, cx) =
652 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
653 let sidebar = setup_sidebar(&multi_workspace, cx);
654
655 save_thread_metadata(
656 acp::SessionId::new(Arc::from("thread-1")),
657 Some("Fix crash in project panel".into()),
658 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
659 None,
660 None,
661 &project,
662 cx,
663 );
664
665 save_thread_metadata(
666 acp::SessionId::new(Arc::from("thread-2")),
667 Some("Add inline diff view".into()),
668 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
669 None,
670 None,
671 &project,
672 cx,
673 );
674 cx.run_until_parked();
675
676 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
677 cx.run_until_parked();
678
679 assert_eq!(
680 visible_entries_as_strings(&sidebar, cx),
681 vec![
682 //
683 "v [my-project]",
684 " Fix crash in project panel",
685 " Add inline diff view",
686 ]
687 );
688}
689
690#[gpui::test]
691async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
692 let project = init_test_project("/project-a", cx).await;
693 let (multi_workspace, cx) =
694 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
695 let sidebar = setup_sidebar(&multi_workspace, cx);
696
697 // Single workspace with a thread
698 save_thread_metadata(
699 acp::SessionId::new(Arc::from("thread-a1")),
700 Some("Thread A1".into()),
701 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
702 None,
703 None,
704 &project,
705 cx,
706 );
707 cx.run_until_parked();
708
709 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
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 // Add a second workspace
722 multi_workspace.update_in(cx, |mw, window, cx| {
723 mw.create_test_workspace(window, cx).detach();
724 });
725 cx.run_until_parked();
726
727 assert_eq!(
728 visible_entries_as_strings(&sidebar, cx),
729 vec![
730 //
731 "v [project-a]",
732 " Thread A1",
733 ]
734 );
735}
736
737#[gpui::test]
738async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
739 let project = init_test_project("/my-project", cx).await;
740 let (multi_workspace, cx) =
741 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
742 let sidebar = setup_sidebar(&multi_workspace, cx);
743
744 save_n_test_threads(1, &project, cx).await;
745
746 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
747
748 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
749 cx.run_until_parked();
750
751 assert_eq!(
752 visible_entries_as_strings(&sidebar, cx),
753 vec![
754 //
755 "v [my-project]",
756 " Thread 1",
757 ]
758 );
759
760 // Collapse
761 sidebar.update_in(cx, |s, window, cx| {
762 s.toggle_collapse(&project_group_key, window, cx);
763 });
764 cx.run_until_parked();
765
766 assert_eq!(
767 visible_entries_as_strings(&sidebar, cx),
768 vec![
769 //
770 "> [my-project]",
771 ]
772 );
773
774 // Expand
775 sidebar.update_in(cx, |s, window, cx| {
776 s.toggle_collapse(&project_group_key, window, cx);
777 });
778 cx.run_until_parked();
779
780 assert_eq!(
781 visible_entries_as_strings(&sidebar, cx),
782 vec![
783 //
784 "v [my-project]",
785 " Thread 1",
786 ]
787 );
788}
789
790#[gpui::test]
791async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
792 // When a worktree is added to a project, the project group key changes.
793 // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
794 // UI state must survive the key change.
795 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
796 let (multi_workspace, cx) =
797 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
798 let sidebar = setup_sidebar(&multi_workspace, cx);
799
800 save_n_test_threads(2, &project, cx).await;
801 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
802 cx.run_until_parked();
803
804 assert_eq!(
805 visible_entries_as_strings(&sidebar, cx),
806 vec!["v [project-a]", " Thread 2", " Thread 1",]
807 );
808
809 // Collapse the group.
810 let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
811 sidebar.update_in(cx, |sidebar, window, cx| {
812 sidebar.toggle_collapse(&old_key, window, cx);
813 });
814 cx.run_until_parked();
815
816 assert_eq!(
817 visible_entries_as_strings(&sidebar, cx),
818 vec!["> [project-a]"]
819 );
820
821 // Add a second worktree — the key changes from [/project-a] to
822 // [/project-a, /project-b].
823 project
824 .update(cx, |project, cx| {
825 project.find_or_create_worktree("/project-b", true, cx)
826 })
827 .await
828 .expect("should add worktree");
829 cx.run_until_parked();
830
831 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
832 cx.run_until_parked();
833
834 // The group should still be collapsed under the new key.
835 assert_eq!(
836 visible_entries_as_strings(&sidebar, cx),
837 vec!["> [project-a, project-b]"]
838 );
839}
840
841#[gpui::test]
842async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
843 use workspace::ProjectGroup;
844
845 let project = init_test_project("/my-project", cx).await;
846 let (multi_workspace, cx) =
847 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
848 let sidebar = setup_sidebar(&multi_workspace, cx);
849
850 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
851 let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
852 let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
853
854 // Set the collapsed group state through multi_workspace
855 multi_workspace.update(cx, |mw, _cx| {
856 mw.test_add_project_group(ProjectGroup {
857 key: ProjectGroupKey::new(None, collapsed_path.clone()),
858 workspaces: Vec::new(),
859 expanded: false,
860 });
861 });
862
863 sidebar.update_in(cx, |s, _window, _cx| {
864 let notified_thread_id = ThreadId::new();
865 s.contents.notified_threads.insert(notified_thread_id);
866 s.contents.entries = vec![
867 // Expanded project header
868 ListEntry::ProjectHeader {
869 key: ProjectGroupKey::new(None, expanded_path.clone()),
870 label: "expanded-project".into(),
871 highlight_positions: Vec::new(),
872 has_running_threads: false,
873 waiting_thread_count: 0,
874 is_active: true,
875 has_threads: true,
876 },
877 ListEntry::Thread(ThreadEntry {
878 metadata: ThreadMetadata {
879 thread_id: ThreadId::new(),
880 session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
881 agent_id: AgentId::new("zed-agent"),
882 worktree_paths: WorktreePaths::default(),
883 title: Some("Completed thread".into()),
884 updated_at: Utc::now(),
885 created_at: Some(Utc::now()),
886 interacted_at: None,
887 archived: false,
888 remote_connection: None,
889 },
890 icon: IconName::ZedAgent,
891 icon_from_external_svg: None,
892 status: AgentThreadStatus::Completed,
893 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
894 is_live: false,
895 is_background: false,
896 is_title_generating: false,
897 highlight_positions: Vec::new(),
898 worktrees: Vec::new(),
899 diff_stats: DiffStats::default(),
900 }),
901 // Active thread with Running status
902 ListEntry::Thread(ThreadEntry {
903 metadata: ThreadMetadata {
904 thread_id: ThreadId::new(),
905 session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
906 agent_id: AgentId::new("zed-agent"),
907 worktree_paths: WorktreePaths::default(),
908 title: Some("Running thread".into()),
909 updated_at: Utc::now(),
910 created_at: Some(Utc::now()),
911 interacted_at: None,
912 archived: false,
913 remote_connection: None,
914 },
915 icon: IconName::ZedAgent,
916 icon_from_external_svg: None,
917 status: AgentThreadStatus::Running,
918 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
919 is_live: true,
920 is_background: false,
921 is_title_generating: false,
922 highlight_positions: Vec::new(),
923 worktrees: Vec::new(),
924 diff_stats: DiffStats::default(),
925 }),
926 // Active thread with Error status
927 ListEntry::Thread(ThreadEntry {
928 metadata: ThreadMetadata {
929 thread_id: ThreadId::new(),
930 session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
931 agent_id: AgentId::new("zed-agent"),
932 worktree_paths: WorktreePaths::default(),
933 title: Some("Error thread".into()),
934 updated_at: Utc::now(),
935 created_at: Some(Utc::now()),
936 interacted_at: None,
937 archived: false,
938 remote_connection: None,
939 },
940 icon: IconName::ZedAgent,
941 icon_from_external_svg: None,
942 status: AgentThreadStatus::Error,
943 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
944 is_live: true,
945 is_background: false,
946 is_title_generating: false,
947 highlight_positions: Vec::new(),
948 worktrees: Vec::new(),
949 diff_stats: DiffStats::default(),
950 }),
951 // Thread with WaitingForConfirmation status, not active
952 // remote_connection: None,
953 ListEntry::Thread(ThreadEntry {
954 metadata: ThreadMetadata {
955 thread_id: ThreadId::new(),
956 session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
957 agent_id: AgentId::new("zed-agent"),
958 worktree_paths: WorktreePaths::default(),
959 title: Some("Waiting thread".into()),
960 updated_at: Utc::now(),
961 created_at: Some(Utc::now()),
962 interacted_at: None,
963 archived: false,
964 remote_connection: None,
965 },
966 icon: IconName::ZedAgent,
967 icon_from_external_svg: None,
968 status: AgentThreadStatus::WaitingForConfirmation,
969 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
970 is_live: false,
971 is_background: false,
972 is_title_generating: false,
973 highlight_positions: Vec::new(),
974 worktrees: Vec::new(),
975 diff_stats: DiffStats::default(),
976 }),
977 // Background thread that completed (should show notification)
978 // remote_connection: None,
979 ListEntry::Thread(ThreadEntry {
980 metadata: ThreadMetadata {
981 thread_id: notified_thread_id,
982 session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
983 agent_id: AgentId::new("zed-agent"),
984 worktree_paths: WorktreePaths::default(),
985 title: Some("Notified thread".into()),
986 updated_at: Utc::now(),
987 created_at: Some(Utc::now()),
988 interacted_at: None,
989 archived: false,
990 remote_connection: None,
991 },
992 icon: IconName::ZedAgent,
993 icon_from_external_svg: None,
994 status: AgentThreadStatus::Completed,
995 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
996 is_live: true,
997 is_background: true,
998 is_title_generating: false,
999 highlight_positions: Vec::new(),
1000 worktrees: Vec::new(),
1001 diff_stats: DiffStats::default(),
1002 }),
1003 // Collapsed project header
1004 ListEntry::ProjectHeader {
1005 key: ProjectGroupKey::new(None, collapsed_path.clone()),
1006 label: "collapsed-project".into(),
1007 highlight_positions: Vec::new(),
1008 has_running_threads: false,
1009 waiting_thread_count: 0,
1010 is_active: false,
1011 has_threads: false,
1012 },
1013 ];
1014
1015 // Select the Running thread (index 2)
1016 s.selection = Some(2);
1017 });
1018
1019 assert_eq!(
1020 visible_entries_as_strings(&sidebar, cx),
1021 vec![
1022 //
1023 "v [expanded-project]",
1024 " Completed thread",
1025 " Running thread * (running) <== selected",
1026 " Error thread * (error)",
1027 " Waiting thread (waiting)",
1028 " Notified thread * (!)",
1029 "> [collapsed-project]",
1030 ]
1031 );
1032
1033 // Move selection to the collapsed header
1034 sidebar.update_in(cx, |s, _window, _cx| {
1035 s.selection = Some(6);
1036 });
1037
1038 assert_eq!(
1039 visible_entries_as_strings(&sidebar, cx).last().cloned(),
1040 Some("> [collapsed-project] <== selected".to_string()),
1041 );
1042
1043 // Clear selection
1044 sidebar.update_in(cx, |s, _window, _cx| {
1045 s.selection = None;
1046 });
1047
1048 // No entry should have the selected marker
1049 let entries = visible_entries_as_strings(&sidebar, cx);
1050 for entry in &entries {
1051 assert!(
1052 !entry.contains("<== selected"),
1053 "unexpected selection marker in: {}",
1054 entry
1055 );
1056 }
1057}
1058
1059#[gpui::test]
1060async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
1061 let project = init_test_project("/my-project", cx).await;
1062 let (multi_workspace, cx) =
1063 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1064 let sidebar = setup_sidebar(&multi_workspace, cx);
1065
1066 save_n_test_threads(3, &project, cx).await;
1067
1068 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1069 cx.run_until_parked();
1070
1071 // Entries: [header, thread3, thread2, thread1]
1072 // Focusing the sidebar does not set a selection; select_next/select_previous
1073 // handle None gracefully by starting from the first or last entry.
1074 focus_sidebar(&sidebar, cx);
1075 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1076
1077 // First SelectNext from None starts at index 0
1078 cx.dispatch_action(SelectNext);
1079 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1080
1081 // Move down through remaining entries
1082 cx.dispatch_action(SelectNext);
1083 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1084
1085 cx.dispatch_action(SelectNext);
1086 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1087
1088 cx.dispatch_action(SelectNext);
1089 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1090
1091 // At the end, wraps back to first entry
1092 cx.dispatch_action(SelectNext);
1093 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1094
1095 // Navigate back to the end
1096 cx.dispatch_action(SelectNext);
1097 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1098 cx.dispatch_action(SelectNext);
1099 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1100 cx.dispatch_action(SelectNext);
1101 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1102
1103 // Move back up
1104 cx.dispatch_action(SelectPrevious);
1105 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1106
1107 cx.dispatch_action(SelectPrevious);
1108 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1109
1110 cx.dispatch_action(SelectPrevious);
1111 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1112
1113 // At the top, selection clears (focus returns to editor)
1114 cx.dispatch_action(SelectPrevious);
1115 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1116}
1117
1118#[gpui::test]
1119async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
1120 let project = init_test_project("/my-project", cx).await;
1121 let (multi_workspace, cx) =
1122 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1123 let sidebar = setup_sidebar(&multi_workspace, cx);
1124
1125 save_n_test_threads(3, &project, cx).await;
1126 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1127 cx.run_until_parked();
1128
1129 focus_sidebar(&sidebar, cx);
1130
1131 // SelectLast jumps to the end
1132 cx.dispatch_action(SelectLast);
1133 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1134
1135 // SelectFirst jumps to the beginning
1136 cx.dispatch_action(SelectFirst);
1137 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1138}
1139
1140#[gpui::test]
1141async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
1142 let project = init_test_project("/my-project", cx).await;
1143 let (multi_workspace, cx) =
1144 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1145 let sidebar = setup_sidebar(&multi_workspace, cx);
1146
1147 // Initially no selection
1148 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1149
1150 // Open the sidebar so it's rendered, then focus it to trigger focus_in.
1151 // focus_in no longer sets a default selection.
1152 focus_sidebar(&sidebar, cx);
1153 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1154
1155 // Manually set a selection, blur, then refocus — selection should be preserved
1156 sidebar.update_in(cx, |sidebar, _window, _cx| {
1157 sidebar.selection = Some(0);
1158 });
1159
1160 cx.update(|window, _cx| {
1161 window.blur();
1162 });
1163 cx.run_until_parked();
1164
1165 sidebar.update_in(cx, |_, window, cx| {
1166 cx.focus_self(window);
1167 });
1168 cx.run_until_parked();
1169 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1170}
1171
1172#[gpui::test]
1173async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
1174 let project = init_test_project("/my-project", cx).await;
1175 let (multi_workspace, cx) =
1176 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1177 let sidebar = setup_sidebar(&multi_workspace, cx);
1178
1179 save_n_test_threads(1, &project, cx).await;
1180 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1181 cx.run_until_parked();
1182
1183 assert_eq!(
1184 visible_entries_as_strings(&sidebar, cx),
1185 vec![
1186 //
1187 "v [my-project]",
1188 " Thread 1",
1189 ]
1190 );
1191
1192 // Focus the sidebar and select the header
1193 focus_sidebar(&sidebar, cx);
1194 sidebar.update_in(cx, |sidebar, _window, _cx| {
1195 sidebar.selection = Some(0);
1196 });
1197
1198 // Confirm on project header collapses the group
1199 cx.dispatch_action(Confirm);
1200 cx.run_until_parked();
1201
1202 assert_eq!(
1203 visible_entries_as_strings(&sidebar, cx),
1204 vec![
1205 //
1206 "> [my-project] <== selected",
1207 ]
1208 );
1209
1210 // Confirm again expands the group
1211 cx.dispatch_action(Confirm);
1212 cx.run_until_parked();
1213
1214 assert_eq!(
1215 visible_entries_as_strings(&sidebar, cx),
1216 vec![
1217 //
1218 "v [my-project] <== selected",
1219 " Thread 1",
1220 ]
1221 );
1222}
1223
1224#[gpui::test]
1225async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
1226 let project = init_test_project("/my-project", cx).await;
1227 let (multi_workspace, cx) =
1228 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1229 let sidebar = setup_sidebar(&multi_workspace, cx);
1230
1231 save_n_test_threads(1, &project, cx).await;
1232 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1233 cx.run_until_parked();
1234
1235 assert_eq!(
1236 visible_entries_as_strings(&sidebar, cx),
1237 vec![
1238 //
1239 "v [my-project]",
1240 " Thread 1",
1241 ]
1242 );
1243
1244 // Focus sidebar and manually select the header (index 0). Press left to collapse.
1245 focus_sidebar(&sidebar, cx);
1246 sidebar.update_in(cx, |sidebar, _window, _cx| {
1247 sidebar.selection = Some(0);
1248 });
1249
1250 cx.dispatch_action(SelectParent);
1251 cx.run_until_parked();
1252
1253 assert_eq!(
1254 visible_entries_as_strings(&sidebar, cx),
1255 vec![
1256 //
1257 "> [my-project] <== selected",
1258 ]
1259 );
1260
1261 // Press right to expand
1262 cx.dispatch_action(SelectChild);
1263 cx.run_until_parked();
1264
1265 assert_eq!(
1266 visible_entries_as_strings(&sidebar, cx),
1267 vec![
1268 //
1269 "v [my-project] <== selected",
1270 " Thread 1",
1271 ]
1272 );
1273
1274 // Press right again on already-expanded header moves selection down
1275 cx.dispatch_action(SelectChild);
1276 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1277}
1278
1279#[gpui::test]
1280async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
1281 let project = init_test_project("/my-project", cx).await;
1282 let (multi_workspace, cx) =
1283 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1284 let sidebar = setup_sidebar(&multi_workspace, cx);
1285
1286 save_n_test_threads(1, &project, cx).await;
1287 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1288 cx.run_until_parked();
1289
1290 // Focus sidebar (selection starts at None), then navigate down to the thread (child)
1291 focus_sidebar(&sidebar, cx);
1292 cx.dispatch_action(SelectNext);
1293 cx.dispatch_action(SelectNext);
1294 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1295
1296 assert_eq!(
1297 visible_entries_as_strings(&sidebar, cx),
1298 vec![
1299 //
1300 "v [my-project]",
1301 " Thread 1 <== selected",
1302 ]
1303 );
1304
1305 // Pressing left on a child collapses the parent group and selects it
1306 cx.dispatch_action(SelectParent);
1307 cx.run_until_parked();
1308
1309 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1310 assert_eq!(
1311 visible_entries_as_strings(&sidebar, cx),
1312 vec![
1313 //
1314 "> [my-project] <== selected",
1315 ]
1316 );
1317}
1318
1319#[gpui::test]
1320async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
1321 let project = init_test_project_with_agent_panel("/empty-project", cx).await;
1322 let (multi_workspace, cx) =
1323 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1324 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1325
1326 // An empty project has only the header (no auto-created draft).
1327 assert_eq!(
1328 visible_entries_as_strings(&sidebar, cx),
1329 vec!["v [empty-project]"]
1330 );
1331
1332 // Focus sidebar — focus_in does not set a selection
1333 focus_sidebar(&sidebar, cx);
1334 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1335
1336 // First SelectNext from None starts at index 0 (header)
1337 cx.dispatch_action(SelectNext);
1338 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1339
1340 // SelectNext with only one entry stays at index 0
1341 cx.dispatch_action(SelectNext);
1342 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1343
1344 // SelectPrevious from first entry clears selection (returns to editor)
1345 cx.dispatch_action(SelectPrevious);
1346 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1347
1348 // SelectPrevious from None selects the last entry
1349 cx.dispatch_action(SelectPrevious);
1350 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1351}
1352
1353#[gpui::test]
1354async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
1355 let project = init_test_project("/my-project", cx).await;
1356 let (multi_workspace, cx) =
1357 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1358 let sidebar = setup_sidebar(&multi_workspace, cx);
1359
1360 save_n_test_threads(1, &project, cx).await;
1361 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1362 cx.run_until_parked();
1363
1364 // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
1365 focus_sidebar(&sidebar, cx);
1366 cx.dispatch_action(SelectNext);
1367 cx.dispatch_action(SelectNext);
1368 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1369
1370 // Collapse the group, which removes the thread from the list
1371 cx.dispatch_action(SelectParent);
1372 cx.run_until_parked();
1373
1374 // Selection should be clamped to the last valid index (0 = header)
1375 let selection = sidebar.read_with(cx, |s, _| s.selection);
1376 let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
1377 assert!(
1378 selection.unwrap_or(0) < entry_count,
1379 "selection {} should be within bounds (entries: {})",
1380 selection.unwrap_or(0),
1381 entry_count,
1382 );
1383}
1384
1385async fn init_test_project_with_agent_panel(
1386 worktree_path: &str,
1387 cx: &mut TestAppContext,
1388) -> Entity<project::Project> {
1389 agent_ui::test_support::init_test(cx);
1390 cx.update(|cx| {
1391 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
1392 ThreadStore::init_global(cx);
1393 ThreadMetadataStore::init_global(cx);
1394 language_model::LanguageModelRegistry::test(cx);
1395 prompt_store::init(cx);
1396 });
1397
1398 let fs = FakeFs::new(cx.executor());
1399 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1400 .await;
1401 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1402 project::Project::test(fs, [worktree_path.as_ref()], cx).await
1403}
1404
1405fn add_agent_panel(
1406 workspace: &Entity<Workspace>,
1407 cx: &mut gpui::VisualTestContext,
1408) -> Entity<AgentPanel> {
1409 workspace.update_in(cx, |workspace, window, cx| {
1410 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
1411 workspace.add_panel(panel.clone(), window, cx);
1412 panel
1413 })
1414}
1415
1416fn setup_sidebar_with_agent_panel(
1417 multi_workspace: &Entity<MultiWorkspace>,
1418 cx: &mut gpui::VisualTestContext,
1419) -> (Entity<Sidebar>, Entity<AgentPanel>) {
1420 let sidebar = setup_sidebar(multi_workspace, cx);
1421 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
1422 let panel = add_agent_panel(&workspace, cx);
1423 (sidebar, panel)
1424}
1425
1426#[gpui::test]
1427async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAppContext) {
1428 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1429 enable_agent_panel_terminal(cx);
1430 let (multi_workspace, cx) =
1431 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1432 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1433
1434 let terminal_id = panel
1435 .update_in(cx, |panel, window, cx| {
1436 panel.insert_test_terminal("Dev Server", true, window, cx)
1437 })
1438 .expect("test terminal should be inserted");
1439 cx.run_until_parked();
1440
1441 assert_eq!(
1442 visible_entries_as_strings(&sidebar, cx),
1443 vec!["v [my-project]", " Dev Server"]
1444 );
1445 sidebar.read_with(cx, |sidebar, _cx| {
1446 assert!(
1447 matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) if *active_terminal_id == terminal_id),
1448 "expected active terminal entry, got {:?}",
1449 sidebar.active_entry,
1450 );
1451 assert!(
1452 sidebar.contents.entries.iter().any(|entry| {
1453 matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id && terminal.title.as_ref() == "Dev Server")
1454 }),
1455 "expected the inserted terminal to appear in sidebar contents",
1456 );
1457 });
1458
1459 type_in_search(&sidebar, "server", cx);
1460 assert_eq!(
1461 visible_entries_as_strings(&sidebar, cx),
1462 vec!["v [my-project]", " Dev Server <== selected"]
1463 );
1464
1465 type_in_search(&sidebar, "missing", cx);
1466 assert_eq!(
1467 visible_entries_as_strings(&sidebar, cx),
1468 Vec::<String>::new()
1469 );
1470}
1471
1472#[gpui::test]
1473async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestAppContext) {
1474 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1475 enable_agent_panel_terminal(cx);
1476 let (multi_workspace, cx) =
1477 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1478 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1479
1480 let build_terminal_id = panel
1481 .update_in(cx, |panel, window, cx| {
1482 panel.insert_test_terminal("Build", true, window, cx)
1483 })
1484 .expect("build test terminal should be inserted");
1485 let server_terminal_id = panel
1486 .update_in(cx, |panel, window, cx| {
1487 panel.insert_test_terminal("Server", true, window, cx)
1488 })
1489 .expect("server test terminal should be inserted");
1490 cx.run_until_parked();
1491
1492 panel.read_with(cx, |panel, _cx| {
1493 assert_eq!(panel.active_terminal_id(), Some(server_terminal_id));
1494 });
1495
1496 panel.update(cx, |panel, cx| {
1497 panel.emit_test_terminal_bell(build_terminal_id, cx);
1498 });
1499 cx.run_until_parked();
1500
1501 sidebar.read_with(cx, |sidebar, cx| {
1502 assert!(sidebar.has_notifications(cx));
1503 assert!(sidebar.contents.notified_terminals.contains(&build_terminal_id));
1504 assert!(sidebar.contents.entries.iter().any(|entry| {
1505 matches!(entry, ListEntry::Terminal(terminal) if terminal.id == build_terminal_id && terminal.has_notification)
1506 }));
1507 });
1508
1509 panel.update_in(cx, |panel, window, cx| {
1510 panel.activate_terminal(build_terminal_id, true, window, cx);
1511 });
1512 cx.run_until_parked();
1513
1514 sidebar.read_with(cx, |sidebar, cx| {
1515 assert!(!sidebar.has_notifications(cx));
1516 assert!(
1517 !sidebar
1518 .contents
1519 .notified_terminals
1520 .contains(&build_terminal_id)
1521 );
1522 });
1523}
1524
1525#[gpui::test]
1526async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut TestAppContext) {
1527 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1528 enable_agent_panel_terminal(cx);
1529 let (multi_workspace, cx) =
1530 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1531 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1532 let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| {
1533 multi_workspace.workspace().clone()
1534 });
1535
1536 let build_terminal_id = panel
1537 .update_in(cx, |panel, window, cx| {
1538 panel.insert_test_terminal("Build", true, window, cx)
1539 })
1540 .expect("build test terminal should be inserted");
1541 let server_terminal_id = panel
1542 .update_in(cx, |panel, window, cx| {
1543 panel.insert_test_terminal("Server", true, window, cx)
1544 })
1545 .expect("server test terminal should be inserted");
1546 cx.run_until_parked();
1547
1548 sidebar.update_in(cx, |sidebar, window, cx| {
1549 sidebar.close_terminal(&workspace, server_terminal_id, window, cx);
1550 });
1551 cx.run_until_parked();
1552
1553 panel.read_with(cx, |panel, _cx| {
1554 assert!(!panel.has_terminal(server_terminal_id));
1555 assert_eq!(panel.active_terminal_id(), Some(build_terminal_id));
1556 });
1557 sidebar.read_with(cx, |sidebar, _cx| {
1558 assert!(
1559 matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id, .. }) if *terminal_id == build_terminal_id),
1560 "expected remaining terminal to become active, got {:?}",
1561 sidebar.active_entry,
1562 );
1563 });
1564 assert_eq!(
1565 visible_entries_as_strings(&sidebar, cx),
1566 vec!["v [my-project]", " Build"]
1567 );
1568}
1569
1570#[gpui::test]
1571async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
1572 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1573 let (multi_workspace, cx) =
1574 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1575 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1576
1577 // Open thread A and keep it generating.
1578 let connection = StubAgentConnection::new();
1579 open_thread_with_connection(&panel, connection.clone(), cx);
1580 send_message(&panel, cx);
1581
1582 let session_id_a = active_session_id(&panel, cx);
1583 save_test_thread_metadata(&session_id_a, &project, cx).await;
1584
1585 cx.update(|_, cx| {
1586 connection.send_update(
1587 session_id_a.clone(),
1588 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
1589 cx,
1590 );
1591 });
1592 cx.run_until_parked();
1593
1594 // Open thread B (idle, default response) — thread A goes to background.
1595 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1596 acp::ContentChunk::new("Done".into()),
1597 )]);
1598 open_thread_with_connection(&panel, connection, cx);
1599 send_message(&panel, cx);
1600
1601 let session_id_b = active_session_id(&panel, cx);
1602 save_test_thread_metadata(&session_id_b, &project, cx).await;
1603
1604 cx.run_until_parked();
1605
1606 let mut entries = visible_entries_as_strings(&sidebar, cx);
1607 entries[1..].sort();
1608 assert_eq!(
1609 entries,
1610 vec![
1611 //
1612 "v [my-project]",
1613 " Hello *",
1614 " Hello * (running)",
1615 ]
1616 );
1617}
1618
1619#[gpui::test]
1620async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
1621 cx: &mut TestAppContext,
1622) {
1623 let project = init_test_project_with_agent_panel("/my-project", cx).await;
1624 let (multi_workspace, cx) =
1625 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1626 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1627
1628 let connection = StubAgentConnection::new().with_supports_load_session(true);
1629 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1630 acp::ContentChunk::new("Done".into()),
1631 )]);
1632 open_thread_with_connection(&panel, connection, cx);
1633 send_message(&panel, cx);
1634
1635 let parent_session_id = active_session_id(&panel, cx);
1636 save_test_thread_metadata(&parent_session_id, &project, cx).await;
1637
1638 let subagent_session_id = acp::SessionId::new("subagent-session");
1639 cx.update(|_, cx| {
1640 let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
1641 parent_thread.update(cx, |thread: &mut AcpThread, cx| {
1642 thread.subagent_spawned(subagent_session_id.clone(), cx);
1643 });
1644 });
1645 cx.run_until_parked();
1646
1647 let subagent_thread = panel.read_with(cx, |panel, cx| {
1648 panel
1649 .active_conversation_view()
1650 .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id))
1651 .map(|thread_view| thread_view.read(cx).thread.clone())
1652 .expect("Expected subagent thread to be loaded into the conversation")
1653 });
1654 request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
1655
1656 let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
1657 sidebar
1658 .contents
1659 .entries
1660 .iter()
1661 .find_map(|entry| match entry {
1662 ListEntry::Thread(thread)
1663 if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
1664 {
1665 Some(thread.status)
1666 }
1667 _ => None,
1668 })
1669 .expect("Expected parent thread entry in sidebar")
1670 });
1671
1672 assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
1673}
1674
1675#[gpui::test]
1676async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
1677 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
1678 let (multi_workspace, cx) =
1679 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1680 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1681
1682 // Open thread on workspace A and keep it generating.
1683 let connection_a = StubAgentConnection::new();
1684 open_thread_with_connection(&panel_a, connection_a.clone(), cx);
1685 send_message(&panel_a, cx);
1686
1687 let session_id_a = active_session_id(&panel_a, cx);
1688 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
1689
1690 cx.update(|_, cx| {
1691 connection_a.send_update(
1692 session_id_a.clone(),
1693 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
1694 cx,
1695 );
1696 });
1697 cx.run_until_parked();
1698
1699 // Add a second workspace and activate it (making workspace A the background).
1700 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
1701 let project_b = project::Project::test(fs, [], cx).await;
1702 multi_workspace.update_in(cx, |mw, window, cx| {
1703 mw.test_add_workspace(project_b, window, cx);
1704 });
1705 cx.run_until_parked();
1706
1707 // Thread A is still running; no notification yet.
1708 assert_eq!(
1709 visible_entries_as_strings(&sidebar, cx),
1710 vec![
1711 //
1712 "v [project-a]",
1713 " Hello * (running)",
1714 ]
1715 );
1716
1717 // Complete thread A's turn (transition Running → Completed).
1718 connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
1719 cx.run_until_parked();
1720
1721 // The completed background thread shows a notification indicator.
1722 assert_eq!(
1723 visible_entries_as_strings(&sidebar, cx),
1724 vec![
1725 //
1726 "v [project-a]",
1727 " Hello * (!)",
1728 ]
1729 );
1730}
1731
1732fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
1733 sidebar.update_in(cx, |sidebar, window, cx| {
1734 window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
1735 sidebar.filter_editor.update(cx, |editor, cx| {
1736 editor.set_text(query, window, cx);
1737 });
1738 });
1739 cx.run_until_parked();
1740}
1741
1742#[gpui::test]
1743async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
1744 let project = init_test_project("/my-project", cx).await;
1745 let (multi_workspace, cx) =
1746 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1747 let sidebar = setup_sidebar(&multi_workspace, cx);
1748
1749 for (id, title, hour) in [
1750 ("t-1", "Fix crash in project panel", 3),
1751 ("t-2", "Add inline diff view", 2),
1752 ("t-3", "Refactor settings module", 1),
1753 ] {
1754 save_thread_metadata(
1755 acp::SessionId::new(Arc::from(id)),
1756 Some(title.into()),
1757 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1758 None,
1759 None,
1760 &project,
1761 cx,
1762 );
1763 }
1764 cx.run_until_parked();
1765
1766 assert_eq!(
1767 visible_entries_as_strings(&sidebar, cx),
1768 vec![
1769 //
1770 "v [my-project]",
1771 " Fix crash in project panel",
1772 " Add inline diff view",
1773 " Refactor settings module",
1774 ]
1775 );
1776
1777 // User types "diff" in the search box — only the matching thread remains,
1778 // with its workspace header preserved for context.
1779 type_in_search(&sidebar, "diff", cx);
1780 assert_eq!(
1781 visible_entries_as_strings(&sidebar, cx),
1782 vec![
1783 //
1784 "v [my-project]",
1785 " Add inline diff view <== selected",
1786 ]
1787 );
1788
1789 // User changes query to something with no matches — list is empty.
1790 type_in_search(&sidebar, "nonexistent", cx);
1791 assert_eq!(
1792 visible_entries_as_strings(&sidebar, cx),
1793 Vec::<String>::new()
1794 );
1795}
1796
1797#[gpui::test]
1798async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
1799 // Scenario: A user remembers a thread title but not the exact casing.
1800 // Search should match case-insensitively so they can still find it.
1801 let project = init_test_project("/my-project", cx).await;
1802 let (multi_workspace, cx) =
1803 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1804 let sidebar = setup_sidebar(&multi_workspace, cx);
1805
1806 save_thread_metadata(
1807 acp::SessionId::new(Arc::from("thread-1")),
1808 Some("Fix Crash In Project Panel".into()),
1809 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1810 None,
1811 None,
1812 &project,
1813 cx,
1814 );
1815 cx.run_until_parked();
1816
1817 // Lowercase query matches mixed-case title.
1818 type_in_search(&sidebar, "fix crash", cx);
1819 assert_eq!(
1820 visible_entries_as_strings(&sidebar, cx),
1821 vec![
1822 //
1823 "v [my-project]",
1824 " Fix Crash In Project Panel <== selected",
1825 ]
1826 );
1827
1828 // Uppercase query also matches the same title.
1829 type_in_search(&sidebar, "FIX CRASH", cx);
1830 assert_eq!(
1831 visible_entries_as_strings(&sidebar, cx),
1832 vec![
1833 //
1834 "v [my-project]",
1835 " Fix Crash In Project Panel <== selected",
1836 ]
1837 );
1838}
1839
1840#[gpui::test]
1841async fn test_escape_from_search_focuses_first_thread(cx: &mut TestAppContext) {
1842 // Scenario: A user searches, finds what they need, then presses Escape
1843 // in the search field to hand keyboard control back to the thread list.
1844 let project = init_test_project("/my-project", cx).await;
1845 let (multi_workspace, cx) =
1846 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1847 let sidebar = setup_sidebar(&multi_workspace, cx);
1848
1849 for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
1850 save_thread_metadata(
1851 acp::SessionId::new(Arc::from(id)),
1852 Some(title.into()),
1853 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1854 None,
1855 None,
1856 &project,
1857 cx,
1858 )
1859 }
1860 cx.run_until_parked();
1861
1862 // Confirm the full list is showing.
1863 assert_eq!(
1864 visible_entries_as_strings(&sidebar, cx),
1865 vec![
1866 //
1867 "v [my-project]",
1868 " Alpha thread",
1869 " Beta thread",
1870 ]
1871 );
1872
1873 // User types a search query to filter down.
1874 focus_sidebar(&sidebar, cx);
1875 type_in_search(&sidebar, "alpha", cx);
1876 assert_eq!(
1877 visible_entries_as_strings(&sidebar, cx),
1878 vec![
1879 //
1880 "v [my-project]",
1881 " Alpha thread <== selected",
1882 ]
1883 );
1884
1885 // First Escape clears the search text, restoring the full list.
1886 // Focus stays on the filter editor.
1887 cx.dispatch_action(Cancel);
1888 cx.run_until_parked();
1889 assert_eq!(
1890 visible_entries_as_strings(&sidebar, cx),
1891 vec![
1892 //
1893 "v [my-project]",
1894 " Alpha thread",
1895 " Beta thread",
1896 ]
1897 );
1898 sidebar.update_in(cx, |sidebar, window, cx| {
1899 assert!(sidebar.filter_editor.read(cx).is_focused(window));
1900 assert!(!sidebar.focus_handle.is_focused(window));
1901 });
1902
1903 // Second Escape moves focus from the empty search field to the thread list.
1904 cx.dispatch_action(Cancel);
1905 cx.run_until_parked();
1906 sidebar.update_in(cx, |sidebar, window, cx| {
1907 assert_eq!(sidebar.selection, Some(1));
1908 assert!(sidebar.focus_handle.is_focused(window));
1909 assert!(!sidebar.filter_editor.read(cx).is_focused(window));
1910 });
1911}
1912
1913#[gpui::test]
1914async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
1915 let project_a = init_test_project("/project-a", cx).await;
1916 let (multi_workspace, cx) =
1917 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1918 let sidebar = setup_sidebar(&multi_workspace, cx);
1919
1920 for (id, title, hour) in [
1921 ("a1", "Fix bug in sidebar", 2),
1922 ("a2", "Add tests for editor", 1),
1923 ] {
1924 save_thread_metadata(
1925 acp::SessionId::new(Arc::from(id)),
1926 Some(title.into()),
1927 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1928 None,
1929 None,
1930 &project_a,
1931 cx,
1932 )
1933 }
1934
1935 // Add a second workspace.
1936 multi_workspace.update_in(cx, |mw, window, cx| {
1937 mw.create_test_workspace(window, cx).detach();
1938 });
1939 cx.run_until_parked();
1940
1941 let project_b = multi_workspace.read_with(cx, |mw, cx| {
1942 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1943 });
1944
1945 for (id, title, hour) in [
1946 ("b1", "Refactor sidebar layout", 3),
1947 ("b2", "Fix typo in README", 1),
1948 ] {
1949 save_thread_metadata(
1950 acp::SessionId::new(Arc::from(id)),
1951 Some(title.into()),
1952 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1953 None,
1954 None,
1955 &project_b,
1956 cx,
1957 )
1958 }
1959 cx.run_until_parked();
1960
1961 assert_eq!(
1962 visible_entries_as_strings(&sidebar, cx),
1963 vec![
1964 //
1965 "v [project-a]",
1966 " Fix bug in sidebar",
1967 " Add tests for editor",
1968 ]
1969 );
1970
1971 // "sidebar" matches a thread in each workspace — both headers stay visible.
1972 type_in_search(&sidebar, "sidebar", cx);
1973 assert_eq!(
1974 visible_entries_as_strings(&sidebar, cx),
1975 vec![
1976 //
1977 "v [project-a]",
1978 " Fix bug in sidebar <== selected",
1979 ]
1980 );
1981
1982 // "typo" only matches in the second workspace — the first header disappears.
1983 type_in_search(&sidebar, "typo", cx);
1984 assert_eq!(
1985 visible_entries_as_strings(&sidebar, cx),
1986 Vec::<String>::new()
1987 );
1988
1989 // "project-a" matches the first workspace name — the header appears
1990 // with all child threads included.
1991 type_in_search(&sidebar, "project-a", cx);
1992 assert_eq!(
1993 visible_entries_as_strings(&sidebar, cx),
1994 vec![
1995 //
1996 "v [project-a]",
1997 " Fix bug in sidebar <== selected",
1998 " Add tests for editor",
1999 ]
2000 );
2001}
2002
2003#[gpui::test]
2004async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
2005 let project_a = init_test_project("/alpha-project", cx).await;
2006 let (multi_workspace, cx) =
2007 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2008 let sidebar = setup_sidebar(&multi_workspace, cx);
2009
2010 for (id, title, hour) in [
2011 ("a1", "Fix bug in sidebar", 2),
2012 ("a2", "Add tests for editor", 1),
2013 ] {
2014 save_thread_metadata(
2015 acp::SessionId::new(Arc::from(id)),
2016 Some(title.into()),
2017 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2018 None,
2019 None,
2020 &project_a,
2021 cx,
2022 )
2023 }
2024
2025 // Add a second workspace.
2026 multi_workspace.update_in(cx, |mw, window, cx| {
2027 mw.create_test_workspace(window, cx).detach();
2028 });
2029 cx.run_until_parked();
2030
2031 let project_b = multi_workspace.read_with(cx, |mw, cx| {
2032 mw.workspaces().nth(1).unwrap().read(cx).project().clone()
2033 });
2034
2035 for (id, title, hour) in [
2036 ("b1", "Refactor sidebar layout", 3),
2037 ("b2", "Fix typo in README", 1),
2038 ] {
2039 save_thread_metadata(
2040 acp::SessionId::new(Arc::from(id)),
2041 Some(title.into()),
2042 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2043 None,
2044 None,
2045 &project_b,
2046 cx,
2047 )
2048 }
2049 cx.run_until_parked();
2050
2051 // "alpha" matches the workspace name "alpha-project" but no thread titles.
2052 // The workspace header should appear with all child threads included.
2053 type_in_search(&sidebar, "alpha", cx);
2054 assert_eq!(
2055 visible_entries_as_strings(&sidebar, cx),
2056 vec![
2057 //
2058 "v [alpha-project]",
2059 " Fix bug in sidebar <== selected",
2060 " Add tests for editor",
2061 ]
2062 );
2063
2064 // "sidebar" matches thread titles in both workspaces but not workspace names.
2065 // Both headers appear with their matching threads.
2066 type_in_search(&sidebar, "sidebar", cx);
2067 assert_eq!(
2068 visible_entries_as_strings(&sidebar, cx),
2069 vec![
2070 //
2071 "v [alpha-project]",
2072 " Fix bug in sidebar <== selected",
2073 ]
2074 );
2075
2076 // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
2077 // doesn't match) — but does not match either workspace name or any thread.
2078 // Actually let's test something simpler: a query that matches both a workspace
2079 // name AND some threads in that workspace. Matching threads should still appear.
2080 type_in_search(&sidebar, "fix", cx);
2081 assert_eq!(
2082 visible_entries_as_strings(&sidebar, cx),
2083 vec![
2084 //
2085 "v [alpha-project]",
2086 " Fix bug in sidebar <== selected",
2087 ]
2088 );
2089
2090 // A query that matches a workspace name AND a thread in that same workspace.
2091 // Both the header (highlighted) and all child threads should appear.
2092 type_in_search(&sidebar, "alpha", cx);
2093 assert_eq!(
2094 visible_entries_as_strings(&sidebar, cx),
2095 vec![
2096 //
2097 "v [alpha-project]",
2098 " Fix bug in sidebar <== selected",
2099 " Add tests for editor",
2100 ]
2101 );
2102
2103 // Now search for something that matches only a workspace name when there
2104 // are also threads with matching titles — the non-matching workspace's
2105 // threads should still appear if their titles match.
2106 type_in_search(&sidebar, "alp", cx);
2107 assert_eq!(
2108 visible_entries_as_strings(&sidebar, cx),
2109 vec![
2110 //
2111 "v [alpha-project]",
2112 " Fix bug in sidebar <== selected",
2113 " Add tests for editor",
2114 ]
2115 );
2116}
2117
2118#[gpui::test]
2119async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
2120 let project = init_test_project("/my-project", cx).await;
2121 let (multi_workspace, cx) =
2122 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2123 let sidebar = setup_sidebar(&multi_workspace, cx);
2124
2125 save_thread_metadata(
2126 acp::SessionId::new(Arc::from("thread-1")),
2127 Some("Important thread".into()),
2128 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2129 None,
2130 None,
2131 &project,
2132 cx,
2133 );
2134 cx.run_until_parked();
2135
2136 // User focuses the sidebar and collapses the group using keyboard:
2137 // manually select the header, then press SelectParent to collapse.
2138 focus_sidebar(&sidebar, cx);
2139 sidebar.update_in(cx, |sidebar, _window, _cx| {
2140 sidebar.selection = Some(0);
2141 });
2142 cx.dispatch_action(SelectParent);
2143 cx.run_until_parked();
2144
2145 assert_eq!(
2146 visible_entries_as_strings(&sidebar, cx),
2147 vec![
2148 //
2149 "> [my-project] <== selected",
2150 ]
2151 );
2152
2153 // User types a search — the thread appears even though its group is collapsed.
2154 type_in_search(&sidebar, "important", cx);
2155 assert_eq!(
2156 visible_entries_as_strings(&sidebar, cx),
2157 vec![
2158 //
2159 "> [my-project]",
2160 " Important thread <== selected",
2161 ]
2162 );
2163}
2164
2165#[gpui::test]
2166async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
2167 let project = init_test_project("/my-project", cx).await;
2168 let (multi_workspace, cx) =
2169 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2170 let sidebar = setup_sidebar(&multi_workspace, cx);
2171
2172 for (id, title, hour) in [
2173 ("t-1", "Fix crash in panel", 3),
2174 ("t-2", "Fix lint warnings", 2),
2175 ("t-3", "Add new feature", 1),
2176 ] {
2177 save_thread_metadata(
2178 acp::SessionId::new(Arc::from(id)),
2179 Some(title.into()),
2180 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2181 None,
2182 None,
2183 &project,
2184 cx,
2185 )
2186 }
2187 cx.run_until_parked();
2188
2189 focus_sidebar(&sidebar, cx);
2190
2191 // User types "fix" — two threads match.
2192 type_in_search(&sidebar, "fix", cx);
2193 assert_eq!(
2194 visible_entries_as_strings(&sidebar, cx),
2195 vec![
2196 //
2197 "v [my-project]",
2198 " Fix crash in panel <== selected",
2199 " Fix lint warnings",
2200 ]
2201 );
2202
2203 // Selection starts on the first matching thread. User presses
2204 // SelectNext to move to the second match.
2205 cx.dispatch_action(SelectNext);
2206 assert_eq!(
2207 visible_entries_as_strings(&sidebar, cx),
2208 vec![
2209 //
2210 "v [my-project]",
2211 " Fix crash in panel",
2212 " Fix lint warnings <== selected",
2213 ]
2214 );
2215
2216 // User can also jump back with SelectPrevious.
2217 cx.dispatch_action(SelectPrevious);
2218 assert_eq!(
2219 visible_entries_as_strings(&sidebar, cx),
2220 vec![
2221 //
2222 "v [my-project]",
2223 " Fix crash in panel <== selected",
2224 " Fix lint warnings",
2225 ]
2226 );
2227}
2228
2229#[gpui::test]
2230async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
2231 let project = init_test_project("/my-project", cx).await;
2232 let (multi_workspace, cx) =
2233 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2234 let sidebar = setup_sidebar(&multi_workspace, cx);
2235
2236 multi_workspace.update_in(cx, |mw, window, cx| {
2237 mw.create_test_workspace(window, cx).detach();
2238 });
2239 cx.run_until_parked();
2240
2241 let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
2242 (
2243 mw.workspaces().next().unwrap().clone(),
2244 mw.workspaces().nth(1).unwrap().clone(),
2245 )
2246 });
2247
2248 save_thread_metadata(
2249 acp::SessionId::new(Arc::from("hist-1")),
2250 Some("Historical Thread".into()),
2251 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2252 None,
2253 None,
2254 &project,
2255 cx,
2256 );
2257 cx.run_until_parked();
2258 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2259 cx.run_until_parked();
2260
2261 assert_eq!(
2262 visible_entries_as_strings(&sidebar, cx),
2263 vec![
2264 //
2265 "v [my-project]",
2266 " Historical Thread",
2267 ]
2268 );
2269
2270 // Switch to workspace 1 so we can verify the confirm switches back.
2271 multi_workspace.update_in(cx, |mw, window, cx| {
2272 let workspace = mw.workspaces().nth(1).unwrap().clone();
2273 mw.activate(workspace, None, window, cx);
2274 });
2275 cx.run_until_parked();
2276 assert_eq!(
2277 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2278 workspace_1
2279 );
2280
2281 // Confirm on the historical (non-live) thread at index 1.
2282 // Before a previous fix, the workspace field was Option<usize> and
2283 // historical threads had None, so activate_thread early-returned
2284 // without switching the workspace.
2285 sidebar.update_in(cx, |sidebar, window, cx| {
2286 sidebar.selection = Some(1);
2287 sidebar.confirm(&Confirm, window, cx);
2288 });
2289 cx.run_until_parked();
2290
2291 assert_eq!(
2292 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2293 workspace_0
2294 );
2295}
2296
2297#[gpui::test]
2298async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
2299 cx: &mut TestAppContext,
2300) {
2301 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2302 let (multi_workspace, cx) =
2303 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2304 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2305
2306 let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
2307 let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
2308 save_thread_metadata(
2309 newer_session_id,
2310 Some("Newer Historical Thread".into()),
2311 newer_timestamp,
2312 Some(newer_timestamp),
2313 None,
2314 &project,
2315 cx,
2316 );
2317
2318 let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
2319 let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
2320 save_thread_metadata(
2321 older_session_id.clone(),
2322 Some("Older Historical Thread".into()),
2323 older_timestamp,
2324 Some(older_timestamp),
2325 None,
2326 &project,
2327 cx,
2328 );
2329
2330 cx.run_until_parked();
2331 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2332 cx.run_until_parked();
2333
2334 let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2335 .into_iter()
2336 .filter(|entry| entry.contains("Historical Thread"))
2337 .collect();
2338 assert_eq!(
2339 historical_entries_before,
2340 vec![
2341 " Newer Historical Thread".to_string(),
2342 " Older Historical Thread".to_string(),
2343 ],
2344 "expected the sidebar to sort historical threads by their saved timestamp before activation"
2345 );
2346
2347 let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
2348 sidebar
2349 .contents
2350 .entries
2351 .iter()
2352 .position(|entry| {
2353 matches!(entry, ListEntry::Thread(thread)
2354 if thread.metadata.session_id.as_ref() == Some(&older_session_id))
2355 })
2356 .expect("expected Older Historical Thread to appear in the sidebar")
2357 });
2358
2359 sidebar.update_in(cx, |sidebar, window, cx| {
2360 sidebar.selection = Some(older_entry_index);
2361 sidebar.confirm(&Confirm, window, cx);
2362 });
2363 cx.run_until_parked();
2364
2365 let older_metadata = cx.update(|_, cx| {
2366 ThreadMetadataStore::global(cx)
2367 .read(cx)
2368 .entry_by_session(&older_session_id)
2369 .cloned()
2370 .expect("expected metadata for Older Historical Thread after activation")
2371 });
2372 assert_eq!(
2373 older_metadata.created_at,
2374 Some(older_timestamp),
2375 "activating a historical thread should not rewrite its saved created_at timestamp"
2376 );
2377
2378 let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
2379 .into_iter()
2380 .filter(|entry| entry.contains("Historical Thread"))
2381 .collect();
2382 assert_eq!(
2383 historical_entries_after,
2384 vec![
2385 " Newer Historical Thread".to_string(),
2386 " Older Historical Thread <== selected".to_string(),
2387 ],
2388 "activating an older historical thread should not reorder it ahead of a newer historical thread"
2389 );
2390}
2391
2392#[gpui::test]
2393async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
2394 cx: &mut TestAppContext,
2395) {
2396 use workspace::ProjectGroup;
2397
2398 agent_ui::test_support::init_test(cx);
2399 cx.update(|cx| {
2400 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
2401 ThreadStore::init_global(cx);
2402 ThreadMetadataStore::init_global(cx);
2403 language_model::LanguageModelRegistry::test(cx);
2404 prompt_store::init(cx);
2405 });
2406
2407 let fs = FakeFs::new(cx.executor());
2408 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
2409 .await;
2410 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
2411 .await;
2412 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2413
2414 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
2415 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
2416
2417 let (multi_workspace, cx) =
2418 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2419 let sidebar = setup_sidebar(&multi_workspace, cx);
2420
2421 let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
2422 multi_workspace.update(cx, |mw, _cx| {
2423 mw.test_add_project_group(ProjectGroup {
2424 key: project_b_key.clone(),
2425 workspaces: Vec::new(),
2426 expanded: true,
2427 });
2428 });
2429
2430 let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
2431 save_thread_metadata(
2432 session_id.clone(),
2433 Some("Historical Thread in New Group".into()),
2434 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2435 None,
2436 None,
2437 &project_b,
2438 cx,
2439 );
2440 cx.run_until_parked();
2441
2442 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2443 cx.run_until_parked();
2444
2445 let entries_before = visible_entries_as_strings(&sidebar, cx);
2446 assert_eq!(
2447 entries_before,
2448 vec![
2449 "v [project-a]",
2450 "v [project-b]",
2451 " Historical Thread in New Group",
2452 ],
2453 "expected the closed project group to show the historical thread before first open"
2454 );
2455
2456 assert_eq!(
2457 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2458 1,
2459 "should start without an open workspace for the new project group"
2460 );
2461
2462 sidebar.update_in(cx, |sidebar, window, cx| {
2463 sidebar.selection = Some(2);
2464 sidebar.confirm(&Confirm, window, cx);
2465 });
2466
2467 cx.run_until_parked();
2468
2469 assert_eq!(
2470 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
2471 2,
2472 "confirming the historical thread should open a workspace for the new project group"
2473 );
2474
2475 let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
2476 mw.workspaces()
2477 .find(|workspace| {
2478 PathList::new(&workspace.read(cx).root_paths(cx))
2479 == project_b_key.path_list().clone()
2480 })
2481 .cloned()
2482 .expect("expected workspace for project-b after opening the historical thread")
2483 });
2484
2485 assert_eq!(
2486 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2487 workspace_b,
2488 "opening the historical thread should activate the new project's workspace"
2489 );
2490
2491 let panel = workspace_b.read_with(cx, |workspace, cx| {
2492 workspace
2493 .panel::<AgentPanel>(cx)
2494 .expect("expected first-open activation to bootstrap the agent panel")
2495 });
2496
2497 let expected_thread_id = cx.update(|_, cx| {
2498 ThreadMetadataStore::global(cx)
2499 .read(cx)
2500 .entries()
2501 .find(|e| e.session_id.as_ref() == Some(&session_id))
2502 .map(|e| e.thread_id)
2503 .expect("metadata should still map session id to thread id")
2504 });
2505
2506 assert_eq!(
2507 panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
2508 Some(expected_thread_id),
2509 "expected the agent panel to activate the real historical thread rather than a draft"
2510 );
2511
2512 let entries_after = visible_entries_as_strings(&sidebar, cx);
2513 let matching_rows: Vec<_> = entries_after
2514 .iter()
2515 .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
2516 .cloned()
2517 .collect();
2518 assert_eq!(
2519 matching_rows.len(),
2520 1,
2521 "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
2522 );
2523 assert!(
2524 matching_rows[0].contains("Historical Thread in New Group"),
2525 "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
2526 );
2527 assert!(
2528 !matching_rows[0].contains("Draft"),
2529 "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
2530 );
2531}
2532
2533#[gpui::test]
2534async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
2535 let project = init_test_project("/my-project", cx).await;
2536 let (multi_workspace, cx) =
2537 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2538 let sidebar = setup_sidebar(&multi_workspace, cx);
2539
2540 save_thread_metadata(
2541 acp::SessionId::new(Arc::from("t-1")),
2542 Some("Thread A".into()),
2543 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2544 None,
2545 None,
2546 &project,
2547 cx,
2548 );
2549
2550 save_thread_metadata(
2551 acp::SessionId::new(Arc::from("t-2")),
2552 Some("Thread B".into()),
2553 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2554 None,
2555 None,
2556 &project,
2557 cx,
2558 );
2559
2560 cx.run_until_parked();
2561 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2562 cx.run_until_parked();
2563
2564 assert_eq!(
2565 visible_entries_as_strings(&sidebar, cx),
2566 vec![
2567 //
2568 "v [my-project]",
2569 " Thread A",
2570 " Thread B",
2571 ]
2572 );
2573
2574 // Keyboard confirm preserves selection.
2575 sidebar.update_in(cx, |sidebar, window, cx| {
2576 sidebar.selection = Some(1);
2577 sidebar.confirm(&Confirm, window, cx);
2578 });
2579 assert_eq!(
2580 sidebar.read_with(cx, |sidebar, _| sidebar.selection),
2581 Some(1)
2582 );
2583
2584 // Click handlers clear selection to None so no highlight lingers
2585 // after a click regardless of focus state. The hover style provides
2586 // visual feedback during mouse interaction instead.
2587 sidebar.update_in(cx, |sidebar, window, cx| {
2588 sidebar.selection = None;
2589 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2590 let project_group_key = ProjectGroupKey::new(None, path_list);
2591 sidebar.toggle_collapse(&project_group_key, window, cx);
2592 });
2593 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2594
2595 // When the user tabs back into the sidebar, focus_in no longer
2596 // restores selection — it stays None.
2597 sidebar.update_in(cx, |sidebar, window, cx| {
2598 sidebar.focus_in(window, cx);
2599 });
2600 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2601}
2602
2603#[gpui::test]
2604async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
2605 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2606 let (multi_workspace, cx) =
2607 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2608 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2609
2610 let connection = StubAgentConnection::new();
2611 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2612 acp::ContentChunk::new("Hi there!".into()),
2613 )]);
2614 open_thread_with_connection(&panel, connection, cx);
2615 send_message(&panel, cx);
2616
2617 let session_id = active_session_id(&panel, cx);
2618 save_test_thread_metadata(&session_id, &project, cx).await;
2619 cx.run_until_parked();
2620
2621 assert_eq!(
2622 visible_entries_as_strings(&sidebar, cx),
2623 vec![
2624 //
2625 "v [my-project]",
2626 " Hello *",
2627 ]
2628 );
2629
2630 // Simulate the agent generating a title. The notification chain is:
2631 // AcpThread::set_title emits TitleUpdated →
2632 // ConnectionView::handle_thread_event calls cx.notify() →
2633 // AgentPanel observer fires and emits AgentPanelEvent →
2634 // Sidebar subscription calls update_entries / rebuild_contents.
2635 //
2636 // Before the fix, handle_thread_event did NOT call cx.notify() for
2637 // TitleUpdated, so the AgentPanel observer never fired and the
2638 // sidebar kept showing the old title.
2639 let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2640 thread.update(cx, |thread, cx| {
2641 thread
2642 .set_title("Friendly Greeting with AI".into(), cx)
2643 .detach();
2644 });
2645 cx.run_until_parked();
2646
2647 assert_eq!(
2648 visible_entries_as_strings(&sidebar, cx),
2649 vec![
2650 //
2651 "v [my-project]",
2652 " Friendly Greeting with AI *",
2653 ]
2654 );
2655}
2656
2657#[gpui::test]
2658async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
2659 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2660 let (multi_workspace, cx) =
2661 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2662 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2663
2664 // Save a thread so it appears in the list.
2665 let connection_a = StubAgentConnection::new();
2666 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2667 acp::ContentChunk::new("Done".into()),
2668 )]);
2669 open_thread_with_connection(&panel_a, connection_a, cx);
2670 send_message(&panel_a, cx);
2671 let session_id_a = active_session_id(&panel_a, cx);
2672 save_test_thread_metadata(&session_id_a, &project_a, cx).await;
2673
2674 // Add a second workspace with its own agent panel.
2675 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2676 fs.as_fake()
2677 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2678 .await;
2679 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2680 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2681 mw.test_add_workspace(project_b.clone(), window, cx)
2682 });
2683 let panel_b = add_agent_panel(&workspace_b, cx);
2684 cx.run_until_parked();
2685
2686 let workspace_a =
2687 multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2688
2689 // ── 1. Initial state: focused thread derived from active panel ─────
2690 sidebar.read_with(cx, |sidebar, _cx| {
2691 assert_active_thread(
2692 sidebar,
2693 &session_id_a,
2694 "The active panel's thread should be focused on startup",
2695 );
2696 });
2697
2698 let thread_metadata_a = cx.update(|_window, cx| {
2699 ThreadMetadataStore::global(cx)
2700 .read(cx)
2701 .entry_by_session(&session_id_a)
2702 .cloned()
2703 .expect("session_id_a should exist in metadata store")
2704 });
2705 sidebar.update_in(cx, |sidebar, window, cx| {
2706 sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
2707 });
2708 cx.run_until_parked();
2709
2710 sidebar.read_with(cx, |sidebar, _cx| {
2711 assert_active_thread(
2712 sidebar,
2713 &session_id_a,
2714 "After clicking a thread, it should be the focused thread",
2715 );
2716 assert!(
2717 has_thread_entry(sidebar, &session_id_a),
2718 "The clicked thread should be present in the entries"
2719 );
2720 });
2721
2722 workspace_a.read_with(cx, |workspace, cx| {
2723 assert!(
2724 workspace.panel::<AgentPanel>(cx).is_some(),
2725 "Agent panel should exist"
2726 );
2727 let dock = workspace.left_dock().read(cx);
2728 assert!(
2729 dock.is_open(),
2730 "Clicking a thread should open the agent panel dock"
2731 );
2732 });
2733
2734 let connection_b = StubAgentConnection::new();
2735 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2736 acp::ContentChunk::new("Thread B".into()),
2737 )]);
2738 open_thread_with_connection(&panel_b, connection_b, cx);
2739 send_message(&panel_b, cx);
2740 let session_id_b = active_session_id(&panel_b, cx);
2741 save_test_thread_metadata(&session_id_b, &project_b, cx).await;
2742 cx.run_until_parked();
2743
2744 // Workspace A is currently active. Click a thread in workspace B,
2745 // which also triggers a workspace switch.
2746 let thread_metadata_b = cx.update(|_window, cx| {
2747 ThreadMetadataStore::global(cx)
2748 .read(cx)
2749 .entry_by_session(&session_id_b)
2750 .cloned()
2751 .expect("session_id_b should exist in metadata store")
2752 });
2753 sidebar.update_in(cx, |sidebar, window, cx| {
2754 sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
2755 });
2756 cx.run_until_parked();
2757
2758 sidebar.read_with(cx, |sidebar, _cx| {
2759 assert_active_thread(
2760 sidebar,
2761 &session_id_b,
2762 "Clicking a thread in another workspace should focus that thread",
2763 );
2764 assert!(
2765 has_thread_entry(sidebar, &session_id_b),
2766 "The cross-workspace thread should be present in the entries"
2767 );
2768 });
2769
2770 multi_workspace.update_in(cx, |mw, window, cx| {
2771 let workspace = mw.workspaces().next().unwrap().clone();
2772 mw.activate(workspace, None, window, cx);
2773 });
2774 cx.run_until_parked();
2775
2776 sidebar.read_with(cx, |sidebar, _cx| {
2777 assert_active_thread(
2778 sidebar,
2779 &session_id_a,
2780 "Switching workspace should seed focused_thread from the new active panel",
2781 );
2782 assert!(
2783 has_thread_entry(sidebar, &session_id_a),
2784 "The seeded thread should be present in the entries"
2785 );
2786 });
2787
2788 let connection_b2 = StubAgentConnection::new();
2789 connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2790 acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
2791 )]);
2792 open_thread_with_connection(&panel_b, connection_b2, cx);
2793 send_message(&panel_b, cx);
2794 let session_id_b2 = active_session_id(&panel_b, cx);
2795 save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
2796 cx.run_until_parked();
2797
2798 // Panel B is not the active workspace's panel (workspace A is
2799 // active), so opening a thread there should not change focused_thread.
2800 // This prevents running threads in background workspaces from causing
2801 // the selection highlight to jump around.
2802 sidebar.read_with(cx, |sidebar, _cx| {
2803 assert_active_thread(
2804 sidebar,
2805 &session_id_a,
2806 "Opening a thread in a non-active panel should not change focused_thread",
2807 );
2808 });
2809
2810 workspace_b.update_in(cx, |workspace, window, cx| {
2811 workspace.focus_handle(cx).focus(window, cx);
2812 });
2813 cx.run_until_parked();
2814
2815 sidebar.read_with(cx, |sidebar, _cx| {
2816 assert_active_thread(
2817 sidebar,
2818 &session_id_a,
2819 "Defocusing the sidebar should not change focused_thread",
2820 );
2821 });
2822
2823 // Switching workspaces via the multi_workspace (simulates clicking
2824 // a workspace header) should clear focused_thread.
2825 multi_workspace.update_in(cx, |mw, window, cx| {
2826 let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
2827 if let Some(workspace) = workspace {
2828 mw.activate(workspace, None, window, cx);
2829 }
2830 });
2831 cx.run_until_parked();
2832
2833 sidebar.read_with(cx, |sidebar, _cx| {
2834 assert_active_thread(
2835 sidebar,
2836 &session_id_b2,
2837 "Switching workspace should seed focused_thread from the new active panel",
2838 );
2839 assert!(
2840 has_thread_entry(sidebar, &session_id_b2),
2841 "The seeded thread should be present in the entries"
2842 );
2843 });
2844
2845 // ── 8. Focusing the agent panel thread keeps focused_thread ────
2846 // Workspace B still has session_id_b2 loaded in the agent panel.
2847 // Clicking into the thread (simulated by focusing its view) should
2848 // keep focused_thread since it was already seeded on workspace switch.
2849 panel_b.update_in(cx, |panel, window, cx| {
2850 if let Some(thread_view) = panel.active_conversation_view() {
2851 thread_view.read(cx).focus_handle(cx).focus(window, cx);
2852 }
2853 });
2854 cx.run_until_parked();
2855
2856 sidebar.read_with(cx, |sidebar, _cx| {
2857 assert_active_thread(
2858 sidebar,
2859 &session_id_b2,
2860 "Focusing the agent panel thread should set focused_thread",
2861 );
2862 assert!(
2863 has_thread_entry(sidebar, &session_id_b2),
2864 "The focused thread should be present in the entries"
2865 );
2866 });
2867}
2868
2869#[gpui::test]
2870async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
2871 let project = init_test_project_with_agent_panel("/project-a", cx).await;
2872 let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
2873 let (multi_workspace, cx) =
2874 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2875 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2876
2877 // Start a thread and send a message so it has history.
2878 let connection = StubAgentConnection::new();
2879 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2880 acp::ContentChunk::new("Done".into()),
2881 )]);
2882 open_thread_with_connection(&panel, connection, cx);
2883 send_message(&panel, cx);
2884 let session_id = active_session_id(&panel, cx);
2885 save_test_thread_metadata(&session_id, &project, cx).await;
2886 cx.run_until_parked();
2887
2888 // Verify the thread appears in the sidebar.
2889 assert_eq!(
2890 visible_entries_as_strings(&sidebar, cx),
2891 vec![
2892 //
2893 "v [project-a]",
2894 " Hello *",
2895 ]
2896 );
2897
2898 // The "New Thread" button should NOT be in "active/draft" state
2899 // because the panel has a thread with messages.
2900 sidebar.read_with(cx, |sidebar, _cx| {
2901 assert!(
2902 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
2903 "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
2904 sidebar.active_entry,
2905 );
2906 });
2907
2908 // Now add a second folder to the workspace, changing the path_list.
2909 fs.as_fake()
2910 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2911 .await;
2912 project
2913 .update(cx, |project, cx| {
2914 project.find_or_create_worktree("/project-b", true, cx)
2915 })
2916 .await
2917 .expect("should add worktree");
2918 cx.run_until_parked();
2919
2920 // The workspace path_list is now [project-a, project-b]. The active
2921 // thread's metadata was re-saved with the new paths by the agent panel's
2922 // project subscription. The old [project-a] key is replaced by the new
2923 // key since no other workspace claims it.
2924 let entries = visible_entries_as_strings(&sidebar, cx);
2925 // After adding a worktree, the thread migrates to the new group key.
2926 // A reconciliation draft may appear during the transition.
2927 assert!(
2928 entries.contains(&" Hello *".to_string()),
2929 "thread should still be present after adding folder: {entries:?}"
2930 );
2931 assert_eq!(entries[0], "v [project-a, project-b]");
2932
2933 // The "New Thread" button must still be clickable (not stuck in
2934 // "active/draft" state). Verify that `active_thread_is_draft` is
2935 // false — the panel still has the old thread with messages.
2936 sidebar.read_with(cx, |sidebar, _cx| {
2937 assert!(
2938 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
2939 "After adding a folder the panel still has a thread with messages, \
2940 so active_entry should be Thread, got {:?}",
2941 sidebar.active_entry,
2942 );
2943 });
2944
2945 // Actually click "New Thread" by calling create_new_thread and
2946 // verify a new draft is created.
2947 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2948 sidebar.update_in(cx, |sidebar, window, cx| {
2949 sidebar.create_new_thread(&workspace, window, cx);
2950 });
2951 cx.run_until_parked();
2952
2953 // After creating a new thread, the panel should now be in draft
2954 // state (no messages on the new thread).
2955 sidebar.read_with(cx, |sidebar, _cx| {
2956 assert_active_draft(
2957 sidebar,
2958 &workspace,
2959 "After creating a new thread active_entry should be Draft",
2960 );
2961 });
2962}
2963#[gpui::test]
2964async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
2965 // When the user presses Cmd-N (NewThread action) while viewing a
2966 // non-empty thread, the panel should switch to the draft thread.
2967 // Drafts are not shown as sidebar rows.
2968 let project = init_test_project_with_agent_panel("/my-project", cx).await;
2969 let (multi_workspace, cx) =
2970 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2971 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2972
2973 // Create a non-empty thread (has messages).
2974 let connection = StubAgentConnection::new();
2975 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2976 acp::ContentChunk::new("Done".into()),
2977 )]);
2978 open_thread_with_connection(&panel, connection, cx);
2979 send_message(&panel, cx);
2980
2981 let session_id = active_session_id(&panel, cx);
2982 save_test_thread_metadata(&session_id, &project, cx).await;
2983 cx.run_until_parked();
2984
2985 assert_eq!(
2986 visible_entries_as_strings(&sidebar, cx),
2987 vec![
2988 //
2989 "v [my-project]",
2990 " Hello *",
2991 ]
2992 );
2993
2994 // Simulate cmd-n
2995 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2996 panel.update_in(cx, |panel, window, cx| {
2997 panel.new_thread(&NewThread, window, cx);
2998 });
2999 workspace.update_in(cx, |workspace, window, cx| {
3000 workspace.focus_panel::<AgentPanel>(window, cx);
3001 });
3002 cx.run_until_parked();
3003
3004 // Drafts are not shown as sidebar rows, so entries stay the same.
3005 assert_eq!(
3006 visible_entries_as_strings(&sidebar, cx),
3007 vec!["v [my-project]", " Hello *"],
3008 "After Cmd-N the sidebar should not show a Draft entry"
3009 );
3010
3011 // The panel should be on the draft and active_entry should track it.
3012 panel.read_with(cx, |panel, cx| {
3013 assert!(
3014 panel.active_thread_is_draft(cx),
3015 "panel should be showing the draft after Cmd-N",
3016 );
3017 });
3018 sidebar.read_with(cx, |sidebar, _cx| {
3019 assert_active_draft(
3020 sidebar,
3021 &workspace,
3022 "active_entry should be Draft after Cmd-N",
3023 );
3024 });
3025}
3026
3027#[gpui::test]
3028async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
3029 // When the active workspace is an absorbed git worktree, cmd-n
3030 // should activate the draft thread in the panel. Drafts are not
3031 // shown as sidebar rows.
3032 agent_ui::test_support::init_test(cx);
3033 cx.update(|cx| {
3034 ThreadStore::init_global(cx);
3035 ThreadMetadataStore::init_global(cx);
3036 language_model::LanguageModelRegistry::test(cx);
3037 prompt_store::init(cx);
3038 });
3039
3040 let fs = FakeFs::new(cx.executor());
3041
3042 // Main repo with a linked worktree.
3043 fs.insert_tree(
3044 "/project",
3045 serde_json::json!({
3046 ".git": {},
3047 "src": {},
3048 }),
3049 )
3050 .await;
3051
3052 // Worktree checkout pointing back to the main repo.
3053 fs.add_linked_worktree_for_repo(
3054 Path::new("/project/.git"),
3055 false,
3056 git::repository::Worktree {
3057 path: std::path::PathBuf::from("/wt-feature-a"),
3058 ref_name: Some("refs/heads/feature-a".into()),
3059 sha: "aaa".into(),
3060 is_main: false,
3061 is_bare: false,
3062 },
3063 )
3064 .await;
3065
3066 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3067
3068 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3069 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3070
3071 main_project
3072 .update(cx, |p, cx| p.git_scans_complete(cx))
3073 .await;
3074 worktree_project
3075 .update(cx, |p, cx| p.git_scans_complete(cx))
3076 .await;
3077
3078 let (multi_workspace, cx) =
3079 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3080
3081 let sidebar = setup_sidebar(&multi_workspace, cx);
3082
3083 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3084 mw.test_add_workspace(worktree_project.clone(), window, cx)
3085 });
3086
3087 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3088
3089 // Switch to the worktree workspace.
3090 multi_workspace.update_in(cx, |mw, window, cx| {
3091 let workspace = mw.workspaces().nth(1).unwrap().clone();
3092 mw.activate(workspace, None, window, cx);
3093 });
3094
3095 // Create a non-empty thread in the worktree workspace.
3096 let connection = StubAgentConnection::new();
3097 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3098 acp::ContentChunk::new("Done".into()),
3099 )]);
3100 open_thread_with_connection(&worktree_panel, connection, cx);
3101 send_message(&worktree_panel, cx);
3102
3103 let session_id = active_session_id(&worktree_panel, cx);
3104 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3105 cx.run_until_parked();
3106
3107 assert_eq!(
3108 visible_entries_as_strings(&sidebar, cx),
3109 vec![
3110 //
3111 "v [project]",
3112 " Hello {wt-feature-a} *",
3113 ]
3114 );
3115
3116 // Simulate Cmd-N in the worktree workspace.
3117 worktree_panel.update_in(cx, |panel, window, cx| {
3118 panel.new_thread(&NewThread, window, cx);
3119 });
3120 worktree_workspace.update_in(cx, |workspace, window, cx| {
3121 workspace.focus_panel::<AgentPanel>(window, cx);
3122 });
3123 cx.run_until_parked();
3124
3125 // Drafts are not shown as sidebar rows, so entries stay the same.
3126 assert_eq!(
3127 visible_entries_as_strings(&sidebar, cx),
3128 vec![
3129 //
3130 "v [project]",
3131 " Hello {wt-feature-a} *"
3132 ],
3133 "After Cmd-N the sidebar should not show a Draft entry"
3134 );
3135
3136 // The panel should be on the draft and active_entry should track it.
3137 worktree_panel.read_with(cx, |panel, cx| {
3138 assert!(
3139 panel.active_thread_is_draft(cx),
3140 "panel should be showing the draft after Cmd-N",
3141 );
3142 });
3143 sidebar.read_with(cx, |sidebar, _cx| {
3144 assert_active_draft(
3145 sidebar,
3146 &worktree_workspace,
3147 "active_entry should be Draft after Cmd-N",
3148 );
3149 });
3150}
3151
3152async fn init_test_project_with_git(
3153 worktree_path: &str,
3154 cx: &mut TestAppContext,
3155) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
3156 init_test(cx);
3157 let fs = FakeFs::new(cx.executor());
3158 fs.insert_tree(
3159 worktree_path,
3160 serde_json::json!({
3161 ".git": {},
3162 "src": {},
3163 }),
3164 )
3165 .await;
3166 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3167 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
3168 (project, fs)
3169}
3170
3171#[gpui::test]
3172async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
3173 let (project, fs) = init_test_project_with_git("/project", cx).await;
3174
3175 fs.as_fake()
3176 .add_linked_worktree_for_repo(
3177 Path::new("/project/.git"),
3178 false,
3179 git::repository::Worktree {
3180 path: std::path::PathBuf::from("/wt/rosewood"),
3181 ref_name: Some("refs/heads/rosewood".into()),
3182 sha: "abc".into(),
3183 is_main: false,
3184 is_bare: false,
3185 },
3186 )
3187 .await;
3188
3189 project
3190 .update(cx, |project, cx| project.git_scans_complete(cx))
3191 .await;
3192
3193 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3194 worktree_project
3195 .update(cx, |p, cx| p.git_scans_complete(cx))
3196 .await;
3197
3198 let (multi_workspace, cx) =
3199 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3200 let sidebar = setup_sidebar(&multi_workspace, cx);
3201
3202 save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
3203 save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
3204
3205 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3206 cx.run_until_parked();
3207
3208 // Search for "rosewood" — should match the worktree name, not the title.
3209 type_in_search(&sidebar, "rosewood", cx);
3210
3211 assert_eq!(
3212 visible_entries_as_strings(&sidebar, cx),
3213 vec![
3214 //
3215 "v [project]",
3216 " Fix Bug {rosewood} <== selected",
3217 ],
3218 );
3219}
3220
3221#[gpui::test]
3222async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
3223 let (project, fs) = init_test_project_with_git("/project", cx).await;
3224
3225 project
3226 .update(cx, |project, cx| project.git_scans_complete(cx))
3227 .await;
3228
3229 let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3230 worktree_project
3231 .update(cx, |p, cx| p.git_scans_complete(cx))
3232 .await;
3233
3234 let (multi_workspace, cx) =
3235 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3236 let sidebar = setup_sidebar(&multi_workspace, cx);
3237
3238 // Save a thread against a worktree path with the correct main
3239 // worktree association (as if the git state had been resolved).
3240 save_thread_metadata_with_main_paths(
3241 "wt-thread",
3242 "Worktree Thread",
3243 PathList::new(&[PathBuf::from("/wt/rosewood")]),
3244 PathList::new(&[PathBuf::from("/project")]),
3245 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3246 cx,
3247 );
3248
3249 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3250 cx.run_until_parked();
3251
3252 // Thread is visible because its main_worktree_paths match the group.
3253 // The chip name is derived from the path even before git discovery.
3254 assert_eq!(
3255 visible_entries_as_strings(&sidebar, cx),
3256 vec!["v [project]", " Worktree Thread {rosewood}"]
3257 );
3258
3259 // Now add the worktree to the git state and trigger a rescan.
3260 fs.as_fake()
3261 .add_linked_worktree_for_repo(
3262 Path::new("/project/.git"),
3263 true,
3264 git::repository::Worktree {
3265 path: std::path::PathBuf::from("/wt/rosewood"),
3266 ref_name: Some("refs/heads/rosewood".into()),
3267 sha: "abc".into(),
3268 is_main: false,
3269 is_bare: false,
3270 },
3271 )
3272 .await;
3273
3274 cx.run_until_parked();
3275
3276 assert_eq!(
3277 visible_entries_as_strings(&sidebar, cx),
3278 vec![
3279 //
3280 "v [project]",
3281 " Worktree Thread {rosewood}",
3282 ]
3283 );
3284}
3285
3286#[gpui::test]
3287async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
3288 init_test(cx);
3289 let fs = FakeFs::new(cx.executor());
3290
3291 // Create the main repo directory (not opened as a workspace yet).
3292 fs.insert_tree(
3293 "/project",
3294 serde_json::json!({
3295 ".git": {
3296 },
3297 "src": {},
3298 }),
3299 )
3300 .await;
3301
3302 // Two worktree checkouts whose .git files point back to the main repo.
3303 fs.add_linked_worktree_for_repo(
3304 Path::new("/project/.git"),
3305 false,
3306 git::repository::Worktree {
3307 path: std::path::PathBuf::from("/wt-feature-a"),
3308 ref_name: Some("refs/heads/feature-a".into()),
3309 sha: "aaa".into(),
3310 is_main: false,
3311 is_bare: false,
3312 },
3313 )
3314 .await;
3315 fs.add_linked_worktree_for_repo(
3316 Path::new("/project/.git"),
3317 false,
3318 git::repository::Worktree {
3319 path: std::path::PathBuf::from("/wt-feature-b"),
3320 ref_name: Some("refs/heads/feature-b".into()),
3321 sha: "bbb".into(),
3322 is_main: false,
3323 is_bare: false,
3324 },
3325 )
3326 .await;
3327
3328 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3329
3330 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3331 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3332
3333 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3334 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3335
3336 // Open both worktrees as workspaces — no main repo yet.
3337 let (multi_workspace, cx) =
3338 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3339 multi_workspace.update_in(cx, |mw, window, cx| {
3340 mw.test_add_workspace(project_b.clone(), window, cx);
3341 });
3342 let sidebar = setup_sidebar(&multi_workspace, cx);
3343
3344 save_thread_metadata(
3345 acp::SessionId::new(Arc::from("thread-a")),
3346 Some("Thread A".into()),
3347 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3348 None,
3349 None,
3350 &project_a,
3351 cx,
3352 );
3353 save_thread_metadata(
3354 acp::SessionId::new(Arc::from("thread-b")),
3355 Some("Thread B".into()),
3356 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
3357 None,
3358 None,
3359 &project_b,
3360 cx,
3361 );
3362
3363 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3364 cx.run_until_parked();
3365
3366 // Without the main repo, each worktree has its own header.
3367 assert_eq!(
3368 visible_entries_as_strings(&sidebar, cx),
3369 vec![
3370 //
3371 "v [project]",
3372 " Thread B {wt-feature-b}",
3373 " Thread A {wt-feature-a}",
3374 ]
3375 );
3376
3377 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3378 main_project
3379 .update(cx, |p, cx| p.git_scans_complete(cx))
3380 .await;
3381
3382 multi_workspace.update_in(cx, |mw, window, cx| {
3383 mw.test_add_workspace(main_project.clone(), window, cx);
3384 });
3385 cx.run_until_parked();
3386
3387 // Both worktree workspaces should now be absorbed under the main
3388 // repo header, with worktree chips.
3389 assert_eq!(
3390 visible_entries_as_strings(&sidebar, cx),
3391 vec![
3392 //
3393 "v [project]",
3394 " Thread B {wt-feature-b}",
3395 " Thread A {wt-feature-a}",
3396 ]
3397 );
3398}
3399
3400#[gpui::test]
3401async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
3402 // When a group has two workspaces — one with threads and one
3403 // without — the threadless workspace should appear as a
3404 // "New Thread" button with its worktree chip.
3405 init_test(cx);
3406 let fs = FakeFs::new(cx.executor());
3407
3408 // Main repo with two linked worktrees.
3409 fs.insert_tree(
3410 "/project",
3411 serde_json::json!({
3412 ".git": {},
3413 "src": {},
3414 }),
3415 )
3416 .await;
3417 fs.add_linked_worktree_for_repo(
3418 Path::new("/project/.git"),
3419 false,
3420 git::repository::Worktree {
3421 path: std::path::PathBuf::from("/wt-feature-a"),
3422 ref_name: Some("refs/heads/feature-a".into()),
3423 sha: "aaa".into(),
3424 is_main: false,
3425 is_bare: false,
3426 },
3427 )
3428 .await;
3429 fs.add_linked_worktree_for_repo(
3430 Path::new("/project/.git"),
3431 false,
3432 git::repository::Worktree {
3433 path: std::path::PathBuf::from("/wt-feature-b"),
3434 ref_name: Some("refs/heads/feature-b".into()),
3435 sha: "bbb".into(),
3436 is_main: false,
3437 is_bare: false,
3438 },
3439 )
3440 .await;
3441
3442 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3443
3444 // Workspace A: worktree feature-a (has threads).
3445 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3446 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3447
3448 // Workspace B: worktree feature-b (no threads).
3449 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3450 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3451
3452 let (multi_workspace, cx) =
3453 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3454 multi_workspace.update_in(cx, |mw, window, cx| {
3455 mw.test_add_workspace(project_b.clone(), window, cx);
3456 });
3457 let sidebar = setup_sidebar(&multi_workspace, cx);
3458
3459 // Only save a thread for workspace A.
3460 save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
3461
3462 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3463 cx.run_until_parked();
3464
3465 // Workspace A's thread appears normally. Workspace B (threadless)
3466 // appears as a "New Thread" button with its worktree chip.
3467 assert_eq!(
3468 visible_entries_as_strings(&sidebar, cx),
3469 vec!["v [project]", " Thread A {wt-feature-a}",]
3470 );
3471}
3472
3473#[gpui::test]
3474async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
3475 // A thread created in a workspace with roots from different git
3476 // worktrees should show a chip for each distinct worktree name.
3477 init_test(cx);
3478 let fs = FakeFs::new(cx.executor());
3479
3480 // Two main repos.
3481 fs.insert_tree(
3482 "/project_a",
3483 serde_json::json!({
3484 ".git": {},
3485 "src": {},
3486 }),
3487 )
3488 .await;
3489 fs.insert_tree(
3490 "/project_b",
3491 serde_json::json!({
3492 ".git": {},
3493 "src": {},
3494 }),
3495 )
3496 .await;
3497
3498 // Worktree checkouts.
3499 for repo in &["project_a", "project_b"] {
3500 let git_path = format!("/{repo}/.git");
3501 for branch in &["olivetti", "selectric"] {
3502 fs.add_linked_worktree_for_repo(
3503 Path::new(&git_path),
3504 false,
3505 git::repository::Worktree {
3506 path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
3507 ref_name: Some(format!("refs/heads/{branch}").into()),
3508 sha: "aaa".into(),
3509 is_main: false,
3510 is_bare: false,
3511 },
3512 )
3513 .await;
3514 }
3515 }
3516
3517 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3518
3519 // Open a workspace with the worktree checkout paths as roots
3520 // (this is the workspace the thread was created in).
3521 let project = project::Project::test(
3522 fs.clone(),
3523 [
3524 "/worktrees/project_a/olivetti/project_a".as_ref(),
3525 "/worktrees/project_b/selectric/project_b".as_ref(),
3526 ],
3527 cx,
3528 )
3529 .await;
3530 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3531
3532 let (multi_workspace, cx) =
3533 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3534 let sidebar = setup_sidebar(&multi_workspace, cx);
3535
3536 // Save a thread under the same paths as the workspace roots.
3537 save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
3538
3539 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3540 cx.run_until_parked();
3541
3542 // Should show two distinct worktree chips.
3543 assert_eq!(
3544 visible_entries_as_strings(&sidebar, cx),
3545 vec![
3546 //
3547 "v [project_a, project_b]",
3548 " Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
3549 ]
3550 );
3551}
3552
3553#[gpui::test]
3554async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
3555 // When a thread's roots span multiple repos but share the same
3556 // worktree name (e.g. both in "olivetti"), only one chip should
3557 // appear.
3558 init_test(cx);
3559 let fs = FakeFs::new(cx.executor());
3560
3561 fs.insert_tree(
3562 "/project_a",
3563 serde_json::json!({
3564 ".git": {},
3565 "src": {},
3566 }),
3567 )
3568 .await;
3569 fs.insert_tree(
3570 "/project_b",
3571 serde_json::json!({
3572 ".git": {},
3573 "src": {},
3574 }),
3575 )
3576 .await;
3577
3578 for repo in &["project_a", "project_b"] {
3579 let git_path = format!("/{repo}/.git");
3580 fs.add_linked_worktree_for_repo(
3581 Path::new(&git_path),
3582 false,
3583 git::repository::Worktree {
3584 path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
3585 ref_name: Some("refs/heads/olivetti".into()),
3586 sha: "aaa".into(),
3587 is_main: false,
3588 is_bare: false,
3589 },
3590 )
3591 .await;
3592 }
3593
3594 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3595
3596 let project = project::Project::test(
3597 fs.clone(),
3598 [
3599 "/worktrees/project_a/olivetti/project_a".as_ref(),
3600 "/worktrees/project_b/olivetti/project_b".as_ref(),
3601 ],
3602 cx,
3603 )
3604 .await;
3605 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3606
3607 let (multi_workspace, cx) =
3608 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3609 let sidebar = setup_sidebar(&multi_workspace, cx);
3610
3611 // Thread with roots in both repos' "olivetti" worktrees.
3612 save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
3613
3614 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3615 cx.run_until_parked();
3616
3617 // Both worktree paths have the name "olivetti", so only one chip.
3618 assert_eq!(
3619 visible_entries_as_strings(&sidebar, cx),
3620 vec![
3621 //
3622 "v [project_a, project_b]",
3623 " Same Branch Thread {olivetti}",
3624 ]
3625 );
3626}
3627
3628#[gpui::test]
3629async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
3630 // When a worktree workspace is absorbed under the main repo, a
3631 // running thread in the worktree's agent panel should still show
3632 // live status (spinner + "(running)") in the sidebar.
3633 agent_ui::test_support::init_test(cx);
3634 cx.update(|cx| {
3635 ThreadStore::init_global(cx);
3636 ThreadMetadataStore::init_global(cx);
3637 language_model::LanguageModelRegistry::test(cx);
3638 prompt_store::init(cx);
3639 });
3640
3641 let fs = FakeFs::new(cx.executor());
3642
3643 // Main repo with a linked worktree.
3644 fs.insert_tree(
3645 "/project",
3646 serde_json::json!({
3647 ".git": {},
3648 "src": {},
3649 }),
3650 )
3651 .await;
3652
3653 // Worktree checkout pointing back to the main repo.
3654 fs.add_linked_worktree_for_repo(
3655 Path::new("/project/.git"),
3656 false,
3657 git::repository::Worktree {
3658 path: std::path::PathBuf::from("/wt-feature-a"),
3659 ref_name: Some("refs/heads/feature-a".into()),
3660 sha: "aaa".into(),
3661 is_main: false,
3662 is_bare: false,
3663 },
3664 )
3665 .await;
3666
3667 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3668
3669 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3670 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3671
3672 main_project
3673 .update(cx, |p, cx| p.git_scans_complete(cx))
3674 .await;
3675 worktree_project
3676 .update(cx, |p, cx| p.git_scans_complete(cx))
3677 .await;
3678
3679 // Create the MultiWorkspace with both projects.
3680 let (multi_workspace, cx) =
3681 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3682
3683 let sidebar = setup_sidebar(&multi_workspace, cx);
3684
3685 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3686 mw.test_add_workspace(worktree_project.clone(), window, cx)
3687 });
3688
3689 // Add an agent panel to the worktree workspace so we can run a
3690 // thread inside it.
3691 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3692
3693 // Switch back to the main workspace before setting up the sidebar.
3694 multi_workspace.update_in(cx, |mw, window, cx| {
3695 let workspace = mw.workspaces().next().unwrap().clone();
3696 mw.activate(workspace, None, window, cx);
3697 });
3698
3699 // Start a thread in the worktree workspace's panel and keep it
3700 // generating (don't resolve it).
3701 let connection = StubAgentConnection::new();
3702 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3703 send_message(&worktree_panel, cx);
3704
3705 let session_id = active_session_id(&worktree_panel, cx);
3706
3707 // Save metadata so the sidebar knows about this thread.
3708 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3709
3710 // Keep the thread generating by sending a chunk without ending
3711 // the turn.
3712 cx.update(|_, cx| {
3713 connection.send_update(
3714 session_id.clone(),
3715 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3716 cx,
3717 );
3718 });
3719 cx.run_until_parked();
3720
3721 // The worktree thread should be absorbed under the main project
3722 // and show live running status.
3723 let entries = visible_entries_as_strings(&sidebar, cx);
3724 assert_eq!(
3725 entries,
3726 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
3727 );
3728}
3729
3730#[gpui::test]
3731async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
3732 agent_ui::test_support::init_test(cx);
3733 cx.update(|cx| {
3734 ThreadStore::init_global(cx);
3735 ThreadMetadataStore::init_global(cx);
3736 language_model::LanguageModelRegistry::test(cx);
3737 prompt_store::init(cx);
3738 });
3739
3740 let fs = FakeFs::new(cx.executor());
3741
3742 fs.insert_tree(
3743 "/project",
3744 serde_json::json!({
3745 ".git": {},
3746 "src": {},
3747 }),
3748 )
3749 .await;
3750
3751 fs.add_linked_worktree_for_repo(
3752 Path::new("/project/.git"),
3753 false,
3754 git::repository::Worktree {
3755 path: std::path::PathBuf::from("/wt-feature-a"),
3756 ref_name: Some("refs/heads/feature-a".into()),
3757 sha: "aaa".into(),
3758 is_main: false,
3759 is_bare: false,
3760 },
3761 )
3762 .await;
3763
3764 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3765
3766 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3767 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3768
3769 main_project
3770 .update(cx, |p, cx| p.git_scans_complete(cx))
3771 .await;
3772 worktree_project
3773 .update(cx, |p, cx| p.git_scans_complete(cx))
3774 .await;
3775
3776 let (multi_workspace, cx) =
3777 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3778
3779 let sidebar = setup_sidebar(&multi_workspace, cx);
3780
3781 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3782 mw.test_add_workspace(worktree_project.clone(), window, cx)
3783 });
3784
3785 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3786
3787 multi_workspace.update_in(cx, |mw, window, cx| {
3788 let workspace = mw.workspaces().next().unwrap().clone();
3789 mw.activate(workspace, None, window, cx);
3790 });
3791
3792 let connection = StubAgentConnection::new();
3793 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3794 send_message(&worktree_panel, cx);
3795
3796 let session_id = active_session_id(&worktree_panel, cx);
3797 save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3798
3799 cx.update(|_, cx| {
3800 connection.send_update(
3801 session_id.clone(),
3802 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3803 cx,
3804 );
3805 });
3806 cx.run_until_parked();
3807
3808 assert_eq!(
3809 visible_entries_as_strings(&sidebar, cx),
3810 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
3811 );
3812
3813 connection.end_turn(session_id, acp::StopReason::EndTurn);
3814 cx.run_until_parked();
3815
3816 assert_eq!(
3817 visible_entries_as_strings(&sidebar, cx),
3818 vec!["v [project]", " Hello {wt-feature-a} * (!)",]
3819 );
3820}
3821
3822#[gpui::test]
3823async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
3824 init_test(cx);
3825 let fs = FakeFs::new(cx.executor());
3826
3827 fs.insert_tree(
3828 "/project",
3829 serde_json::json!({
3830 ".git": {},
3831 "src": {},
3832 }),
3833 )
3834 .await;
3835
3836 fs.add_linked_worktree_for_repo(
3837 Path::new("/project/.git"),
3838 false,
3839 git::repository::Worktree {
3840 path: std::path::PathBuf::from("/wt-feature-a"),
3841 ref_name: Some("refs/heads/feature-a".into()),
3842 sha: "aaa".into(),
3843 is_main: false,
3844 is_bare: false,
3845 },
3846 )
3847 .await;
3848
3849 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3850
3851 // Only open the main repo — no workspace for the worktree.
3852 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3853 main_project
3854 .update(cx, |p, cx| p.git_scans_complete(cx))
3855 .await;
3856
3857 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3858 worktree_project
3859 .update(cx, |p, cx| p.git_scans_complete(cx))
3860 .await;
3861
3862 let (multi_workspace, cx) =
3863 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3864 let sidebar = setup_sidebar(&multi_workspace, cx);
3865
3866 // Save a thread for the worktree path (no workspace for it).
3867 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3868
3869 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3870 cx.run_until_parked();
3871
3872 // Thread should appear under the main repo with a worktree chip.
3873 assert_eq!(
3874 visible_entries_as_strings(&sidebar, cx),
3875 vec![
3876 //
3877 "v [project]",
3878 " WT Thread {wt-feature-a}",
3879 ],
3880 );
3881
3882 // Only 1 workspace should exist.
3883 assert_eq!(
3884 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3885 1,
3886 );
3887
3888 // Focus the sidebar and select the worktree thread.
3889 focus_sidebar(&sidebar, cx);
3890 sidebar.update_in(cx, |sidebar, _window, _cx| {
3891 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3892 });
3893
3894 // Confirm to open the worktree thread.
3895 cx.dispatch_action(Confirm);
3896 cx.run_until_parked();
3897
3898 // A new workspace should have been created for the worktree path.
3899 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
3900 assert_eq!(
3901 mw.workspaces().count(),
3902 2,
3903 "confirming a worktree thread without a workspace should open one",
3904 );
3905 mw.workspaces().nth(1).unwrap().clone()
3906 });
3907
3908 let new_path_list =
3909 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
3910 assert_eq!(
3911 new_path_list,
3912 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
3913 "the new workspace should have been opened for the worktree path",
3914 );
3915}
3916
3917#[gpui::test]
3918async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
3919 cx: &mut TestAppContext,
3920) {
3921 init_test(cx);
3922 let fs = FakeFs::new(cx.executor());
3923
3924 fs.insert_tree(
3925 "/project",
3926 serde_json::json!({
3927 ".git": {},
3928 "src": {},
3929 }),
3930 )
3931 .await;
3932
3933 fs.add_linked_worktree_for_repo(
3934 Path::new("/project/.git"),
3935 false,
3936 git::repository::Worktree {
3937 path: std::path::PathBuf::from("/wt-feature-a"),
3938 ref_name: Some("refs/heads/feature-a".into()),
3939 sha: "aaa".into(),
3940 is_main: false,
3941 is_bare: false,
3942 },
3943 )
3944 .await;
3945
3946 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3947
3948 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3949 main_project
3950 .update(cx, |p, cx| p.git_scans_complete(cx))
3951 .await;
3952
3953 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3954 worktree_project
3955 .update(cx, |p, cx| p.git_scans_complete(cx))
3956 .await;
3957
3958 let (multi_workspace, cx) =
3959 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3960 let sidebar = setup_sidebar(&multi_workspace, cx);
3961
3962 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3963
3964 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3965 cx.run_until_parked();
3966
3967 assert_eq!(
3968 visible_entries_as_strings(&sidebar, cx),
3969 vec![
3970 //
3971 "v [project]",
3972 " WT Thread {wt-feature-a}",
3973 ],
3974 );
3975
3976 focus_sidebar(&sidebar, cx);
3977 sidebar.update_in(cx, |sidebar, _window, _cx| {
3978 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3979 });
3980
3981 let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
3982 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
3983 if let ListEntry::ProjectHeader { label, .. } = entry {
3984 Some(label.as_ref())
3985 } else {
3986 None
3987 }
3988 });
3989
3990 let Some(project_header) = project_headers.next() else {
3991 panic!("expected exactly one sidebar project header named `project`, found none");
3992 };
3993 assert_eq!(
3994 project_header, "project",
3995 "expected the only sidebar project header to be `project`"
3996 );
3997 if let Some(unexpected_header) = project_headers.next() {
3998 panic!(
3999 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
4000 );
4001 }
4002
4003 let mut saw_expected_thread = false;
4004 for entry in &sidebar.contents.entries {
4005 match entry {
4006 ListEntry::ProjectHeader { label, .. } => {
4007 assert_eq!(
4008 label.as_ref(),
4009 "project",
4010 "expected the only sidebar project header to be `project`"
4011 );
4012 }
4013 ListEntry::Thread(thread)
4014 if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
4015 && thread
4016 .worktrees
4017 .first()
4018 .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
4019 == Some("wt-feature-a") =>
4020 {
4021 saw_expected_thread = true;
4022 }
4023 ListEntry::Thread(thread) => {
4024 let title = thread.metadata.display_title();
4025 let worktree_name = thread
4026 .worktrees
4027 .first()
4028 .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
4029 .unwrap_or("<none>");
4030 panic!(
4031 "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
4032 title, worktree_name
4033 );
4034 }
4035 ListEntry::Terminal(terminal) => {
4036 panic!(
4037 "unexpected sidebar terminal while opening linked worktree thread: title=`{}`",
4038 terminal.title
4039 );
4040 }
4041 }
4042 }
4043
4044 assert!(
4045 saw_expected_thread,
4046 "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
4047 );
4048 };
4049
4050 sidebar
4051 .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
4052 .detach();
4053
4054 let window = cx.windows()[0];
4055 cx.update_window(window, |_, window, cx| {
4056 window.dispatch_action(Confirm.boxed_clone(), cx);
4057 })
4058 .unwrap();
4059
4060 cx.run_until_parked();
4061
4062 sidebar.update(cx, assert_sidebar_state);
4063}
4064
4065#[gpui::test]
4066async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
4067 cx: &mut TestAppContext,
4068) {
4069 init_test(cx);
4070 let fs = FakeFs::new(cx.executor());
4071
4072 fs.insert_tree(
4073 "/project",
4074 serde_json::json!({
4075 ".git": {},
4076 "src": {},
4077 }),
4078 )
4079 .await;
4080
4081 fs.add_linked_worktree_for_repo(
4082 Path::new("/project/.git"),
4083 false,
4084 git::repository::Worktree {
4085 path: std::path::PathBuf::from("/wt-feature-a"),
4086 ref_name: Some("refs/heads/feature-a".into()),
4087 sha: "aaa".into(),
4088 is_main: false,
4089 is_bare: false,
4090 },
4091 )
4092 .await;
4093
4094 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4095
4096 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4097 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4098
4099 main_project
4100 .update(cx, |p, cx| p.git_scans_complete(cx))
4101 .await;
4102 worktree_project
4103 .update(cx, |p, cx| p.git_scans_complete(cx))
4104 .await;
4105
4106 let (multi_workspace, cx) =
4107 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4108
4109 let sidebar = setup_sidebar(&multi_workspace, cx);
4110
4111 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4112 mw.test_add_workspace(worktree_project.clone(), window, cx)
4113 });
4114
4115 // Activate the main workspace before setting up the sidebar.
4116 let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4117 let workspace = mw.workspaces().next().unwrap().clone();
4118 mw.activate(workspace.clone(), None, window, cx);
4119 workspace
4120 });
4121
4122 save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
4123 save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
4124
4125 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4126 cx.run_until_parked();
4127
4128 // The worktree workspace should be absorbed under the main repo.
4129 let entries = visible_entries_as_strings(&sidebar, cx);
4130 assert_eq!(entries.len(), 3);
4131 assert_eq!(entries[0], "v [project]");
4132 assert!(entries.contains(&" Main Thread".to_string()));
4133 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
4134
4135 let wt_thread_index = entries
4136 .iter()
4137 .position(|e| e.contains("WT Thread"))
4138 .expect("should find the worktree thread entry");
4139
4140 assert_eq!(
4141 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4142 main_workspace,
4143 "main workspace should be active initially"
4144 );
4145
4146 // Focus the sidebar and select the absorbed worktree thread.
4147 focus_sidebar(&sidebar, cx);
4148 sidebar.update_in(cx, |sidebar, _window, _cx| {
4149 sidebar.selection = Some(wt_thread_index);
4150 });
4151
4152 // Confirm to activate the worktree thread.
4153 cx.dispatch_action(Confirm);
4154 cx.run_until_parked();
4155
4156 // The worktree workspace should now be active, not the main one.
4157 let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4158 assert_eq!(
4159 active_workspace, worktree_workspace,
4160 "clicking an absorbed worktree thread should activate the worktree workspace"
4161 );
4162}
4163
4164// Reproduces the core of the user-reported bug: a thread belonging to
4165// a multi-root workspace that mixes a standalone project and a linked
4166// git worktree can become invisible in the sidebar when its stored
4167// `main_worktree_paths` don't match the workspace's project group
4168// key. The metadata still exists and Thread History still shows it,
4169// but the sidebar rebuild's lookups all miss.
4170//
4171// Real-world setup: a single multi-root workspace whose roots are
4172// `[/cloud, /worktrees/zed/wt_a/zed]`, where:
4173// - `/cloud` is a standalone git repo (main == folder).
4174// - `/worktrees/zed/wt_a/zed` is a linked worktree of `/zed`.
4175//
4176// Once git scans complete the project group key is
4177// `[/cloud, /zed]` — the main paths of the two roots. A thread
4178// created in this workspace is written with
4179// `main=[/cloud, /zed], folder=[/cloud, /worktrees/zed/wt_a/zed]`
4180// and the sidebar finds it via `entries_for_main_worktree_path`.
4181//
4182// If some other code path (stale data on reload, a path-less archive
4183// restored via the project picker, a legacy write …) persists the
4184// thread with `main == folder` instead, the stored
4185// `main_worktree_paths` is
4186// `[/cloud, /worktrees/zed/wt_a/zed]` ≠ `[/cloud, /zed]`. The three
4187// lookups in `rebuild_contents` all miss:
4188//
4189// 1. `entries_for_main_worktree_path([/cloud, /zed])` — the
4190// thread's stored main doesn't equal the group key.
4191// 2. `entries_for_path([/cloud, /zed])` — the thread's folder paths
4192// don't equal the group key either.
4193// 3. The linked-worktree fallback iterates the group's workspaces'
4194// `linked_worktrees()` snapshots. Those yield *sibling* linked
4195// worktrees of the repo, not the workspace's own roots, so the
4196// thread's folder `/worktrees/zed/wt_a/zed` doesn't match.
4197//
4198// The row falls out of the sidebar entirely — matching the user's
4199// symptom of a thread visible in the agent panel but missing from
4200// the sidebar. It only reappears once something re-writes the
4201// thread's metadata in the good shape (e.g. `handle_conversation_event`
4202// firing after the user sends a message).
4203//
4204// We directly persist the bad shape via `store.save(...)` rather
4205// than trying to reproduce the original writer. The bug is
4206// ultimately about the sidebar's tolerance for any stale row whose
4207// folder paths correspond to an open workspace's roots, regardless
4208// of how that row came to be in the store.
4209#[gpui::test]
4210async fn test_sidebar_keeps_multi_root_thread_with_stale_main_paths(cx: &mut TestAppContext) {
4211 agent_ui::test_support::init_test(cx);
4212 cx.update(|cx| {
4213 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
4214 ThreadStore::init_global(cx);
4215 ThreadMetadataStore::init_global(cx);
4216 language_model::LanguageModelRegistry::test(cx);
4217 prompt_store::init(cx);
4218 });
4219
4220 let fs = FakeFs::new(cx.executor());
4221
4222 // Standalone repo — one of the workspace's two roots, main
4223 // worktree of its own .git.
4224 fs.insert_tree(
4225 "/cloud",
4226 serde_json::json!({
4227 ".git": {},
4228 "src": {},
4229 }),
4230 )
4231 .await;
4232
4233 // Separate /zed repo whose linked worktree will form the second
4234 // workspace root. /zed itself is NOT opened as a workspace root.
4235 fs.insert_tree(
4236 "/zed",
4237 serde_json::json!({
4238 ".git": {},
4239 "src": {},
4240 }),
4241 )
4242 .await;
4243 fs.insert_tree(
4244 "/worktrees/zed/wt_a/zed",
4245 serde_json::json!({
4246 ".git": "gitdir: /zed/.git/worktrees/wt_a",
4247 "src": {},
4248 }),
4249 )
4250 .await;
4251 fs.add_linked_worktree_for_repo(
4252 Path::new("/zed/.git"),
4253 false,
4254 git::repository::Worktree {
4255 path: std::path::PathBuf::from("/worktrees/zed/wt_a/zed"),
4256 ref_name: Some("refs/heads/wt_a".into()),
4257 sha: "aaa".into(),
4258 is_main: false,
4259 is_bare: false,
4260 },
4261 )
4262 .await;
4263
4264 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4265
4266 // Single multi-root project with both /cloud and the linked
4267 // worktree of /zed.
4268 let project = project::Project::test(
4269 fs.clone(),
4270 ["/cloud".as_ref(), "/worktrees/zed/wt_a/zed".as_ref()],
4271 cx,
4272 )
4273 .await;
4274 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4275
4276 let (multi_workspace, cx) =
4277 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4278 let sidebar = setup_sidebar(&multi_workspace, cx);
4279 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4280 let _panel = add_agent_panel(&workspace, cx);
4281 cx.run_until_parked();
4282
4283 // Sanity-check the shapes the rest of the test depends on.
4284 let group_key = workspace.read_with(cx, |ws, cx| ws.project_group_key(cx));
4285 let expected_main_paths = PathList::new(&[PathBuf::from("/cloud"), PathBuf::from("/zed")]);
4286 assert_eq!(
4287 group_key.path_list(),
4288 &expected_main_paths,
4289 "expected the multi-root workspace's project group key to normalize to \
4290 [/cloud, /zed] (main of the standalone repo + main of the linked worktree)"
4291 );
4292
4293 let folder_paths = PathList::new(&[
4294 PathBuf::from("/cloud"),
4295 PathBuf::from("/worktrees/zed/wt_a/zed"),
4296 ]);
4297 let workspace_root_paths = workspace.read_with(cx, |ws, cx| PathList::new(&ws.root_paths(cx)));
4298 assert_eq!(
4299 workspace_root_paths, folder_paths,
4300 "expected the workspace's root paths to equal [/cloud, /worktrees/zed/wt_a/zed]"
4301 );
4302
4303 let session_id = acp::SessionId::new(Arc::from("multi-root-stale-paths"));
4304 let thread_id = ThreadId::new();
4305
4306 // Persist the thread in the "bad" shape that the bug manifests as:
4307 // main == folder for every root. Any stale row where
4308 // `main_worktree_paths` no longer equals the group key produces
4309 // the same user-visible symptom; this is the concrete shape
4310 // produced by `WorktreePaths::from_folder_paths` on the workspace
4311 // roots.
4312 cx.update(|_, cx| {
4313 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4314 store.save(
4315 ThreadMetadata {
4316 thread_id,
4317 session_id: Some(session_id.clone()),
4318 agent_id: agent::ZED_AGENT_ID.clone(),
4319 title: Some("Stale Multi-Root Thread".into()),
4320 updated_at: Utc::now(),
4321 created_at: None,
4322 interacted_at: None,
4323 worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
4324 archived: false,
4325 remote_connection: None,
4326 },
4327 cx,
4328 )
4329 });
4330 });
4331 cx.run_until_parked();
4332
4333 let entries = visible_entries_as_strings(&sidebar, cx);
4334 let visible = sidebar.read_with(cx, |sidebar, _cx| has_thread_entry(sidebar, &session_id));
4335
4336 // If this assert fails, we've reproduced the bug: the sidebar's
4337 // rebuild queries can't locate the thread under the current
4338 // project group, even though the metadata is intact and the
4339 // thread's folder paths exactly equal the open workspace's roots.
4340 assert!(
4341 visible,
4342 "thread disappeared from the sidebar when its main_worktree_paths \
4343 ({folder_paths:?}) diverged from the project group key ({expected_main_paths:?}); \
4344 sidebar entries: {entries:?}"
4345 );
4346}
4347
4348#[gpui::test]
4349async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
4350 cx: &mut TestAppContext,
4351) {
4352 // Thread has saved metadata in ThreadStore. A matching workspace is
4353 // already open. Expected: activates the matching workspace.
4354 init_test(cx);
4355 let fs = FakeFs::new(cx.executor());
4356 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4357 .await;
4358 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4359 .await;
4360 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4361
4362 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4363 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4364
4365 let (multi_workspace, cx) =
4366 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4367
4368 let sidebar = setup_sidebar(&multi_workspace, cx);
4369
4370 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4371 mw.test_add_workspace(project_b.clone(), window, cx)
4372 });
4373 let workspace_a =
4374 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4375
4376 // Save a thread with path_list pointing to project-b.
4377 let session_id = acp::SessionId::new(Arc::from("archived-1"));
4378 save_test_thread_metadata(&session_id, &project_b, cx).await;
4379
4380 // Ensure workspace A is active.
4381 multi_workspace.update_in(cx, |mw, window, cx| {
4382 let workspace = mw.workspaces().next().unwrap().clone();
4383 mw.activate(workspace, None, window, cx);
4384 });
4385 cx.run_until_parked();
4386 assert_eq!(
4387 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4388 workspace_a
4389 );
4390
4391 // Call activate_archived_thread – should resolve saved paths and
4392 // switch to the workspace for project-b.
4393 sidebar.update_in(cx, |sidebar, window, cx| {
4394 sidebar.open_thread_from_archive(
4395 ThreadMetadata {
4396 thread_id: ThreadId::new(),
4397 session_id: Some(session_id.clone()),
4398 agent_id: agent::ZED_AGENT_ID.clone(),
4399 title: Some("Archived Thread".into()),
4400 updated_at: Utc::now(),
4401 created_at: None,
4402 interacted_at: None,
4403 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4404 "/project-b",
4405 )])),
4406 archived: false,
4407 remote_connection: None,
4408 },
4409 window,
4410 cx,
4411 );
4412 });
4413 cx.run_until_parked();
4414
4415 assert_eq!(
4416 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4417 workspace_b,
4418 "should have switched to the workspace matching the saved paths"
4419 );
4420}
4421
4422#[gpui::test]
4423async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
4424 cx: &mut TestAppContext,
4425) {
4426 // Thread has no saved metadata but session_info has cwd. A matching
4427 // workspace is open. Expected: uses cwd to find and activate it.
4428 init_test(cx);
4429 let fs = FakeFs::new(cx.executor());
4430 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4431 .await;
4432 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4433 .await;
4434 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4435
4436 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4437 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4438
4439 let (multi_workspace, cx) =
4440 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4441
4442 let sidebar = setup_sidebar(&multi_workspace, cx);
4443
4444 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4445 mw.test_add_workspace(project_b, window, cx)
4446 });
4447 let workspace_a =
4448 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4449
4450 // Start with workspace A active.
4451 multi_workspace.update_in(cx, |mw, window, cx| {
4452 let workspace = mw.workspaces().next().unwrap().clone();
4453 mw.activate(workspace, None, window, cx);
4454 });
4455 cx.run_until_parked();
4456 assert_eq!(
4457 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4458 workspace_a
4459 );
4460
4461 // No thread saved to the store – cwd is the only path hint.
4462 sidebar.update_in(cx, |sidebar, window, cx| {
4463 sidebar.open_thread_from_archive(
4464 ThreadMetadata {
4465 thread_id: ThreadId::new(),
4466 session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
4467 agent_id: agent::ZED_AGENT_ID.clone(),
4468 title: Some("CWD Thread".into()),
4469 updated_at: Utc::now(),
4470 created_at: None,
4471 interacted_at: None,
4472 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
4473 std::path::PathBuf::from("/project-b"),
4474 ])),
4475 archived: false,
4476 remote_connection: None,
4477 },
4478 window,
4479 cx,
4480 );
4481 });
4482 cx.run_until_parked();
4483
4484 assert_eq!(
4485 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4486 workspace_b,
4487 "should have activated the workspace matching the cwd"
4488 );
4489}
4490
4491#[gpui::test]
4492async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4493 cx: &mut TestAppContext,
4494) {
4495 // Thread has no saved metadata and no cwd. Expected: falls back to
4496 // the currently active workspace.
4497 init_test(cx);
4498 let fs = FakeFs::new(cx.executor());
4499 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4500 .await;
4501 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4502 .await;
4503 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4504
4505 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4506 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4507
4508 let (multi_workspace, cx) =
4509 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4510
4511 let sidebar = setup_sidebar(&multi_workspace, cx);
4512
4513 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4514 mw.test_add_workspace(project_b, window, cx)
4515 });
4516
4517 // Activate workspace B (index 1) to make it the active one.
4518 multi_workspace.update_in(cx, |mw, window, cx| {
4519 let workspace = mw.workspaces().nth(1).unwrap().clone();
4520 mw.activate(workspace, None, window, cx);
4521 });
4522 cx.run_until_parked();
4523 assert_eq!(
4524 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4525 workspace_b
4526 );
4527
4528 // No saved thread, no cwd – should fall back to the active workspace.
4529 sidebar.update_in(cx, |sidebar, window, cx| {
4530 sidebar.open_thread_from_archive(
4531 ThreadMetadata {
4532 thread_id: ThreadId::new(),
4533 session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
4534 agent_id: agent::ZED_AGENT_ID.clone(),
4535 title: Some("Contextless Thread".into()),
4536 updated_at: Utc::now(),
4537 created_at: None,
4538 interacted_at: None,
4539 worktree_paths: WorktreePaths::default(),
4540 archived: false,
4541 remote_connection: None,
4542 },
4543 window,
4544 cx,
4545 );
4546 });
4547 cx.run_until_parked();
4548
4549 assert_eq!(
4550 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4551 workspace_b,
4552 "should have stayed on the active workspace when no path info is available"
4553 );
4554}
4555
4556#[gpui::test]
4557async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
4558 // Thread has saved metadata pointing to a path with no open workspace.
4559 // Expected: opens a new workspace for that path.
4560 init_test(cx);
4561 let fs = FakeFs::new(cx.executor());
4562 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4563 .await;
4564 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4565 .await;
4566 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4567
4568 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4569
4570 let (multi_workspace, cx) =
4571 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4572
4573 let sidebar = setup_sidebar(&multi_workspace, cx);
4574
4575 // Save a thread with path_list pointing to project-b – which has no
4576 // open workspace.
4577 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4578 let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4579
4580 assert_eq!(
4581 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4582 1,
4583 "should start with one workspace"
4584 );
4585
4586 sidebar.update_in(cx, |sidebar, window, cx| {
4587 sidebar.open_thread_from_archive(
4588 ThreadMetadata {
4589 thread_id: ThreadId::new(),
4590 session_id: Some(session_id.clone()),
4591 agent_id: agent::ZED_AGENT_ID.clone(),
4592 title: Some("New WS Thread".into()),
4593 updated_at: Utc::now(),
4594 created_at: None,
4595 interacted_at: None,
4596 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
4597 archived: false,
4598 remote_connection: None,
4599 },
4600 window,
4601 cx,
4602 );
4603 });
4604 cx.run_until_parked();
4605
4606 assert_eq!(
4607 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4608 2,
4609 "should have opened a second workspace for the archived thread's saved paths"
4610 );
4611}
4612
4613#[gpui::test]
4614async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
4615 init_test(cx);
4616 let fs = FakeFs::new(cx.executor());
4617 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4618 .await;
4619 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4620 .await;
4621 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4622
4623 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4624 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4625
4626 let multi_workspace_a =
4627 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4628 let multi_workspace_b =
4629 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, 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 = setup_sidebar(&multi_workspace_a_entity, cx_a);
4639
4640 let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
4641
4642 sidebar.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("Cross 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-b",
4654 )])),
4655 archived: false,
4656 remote_connection: None,
4657 },
4658 window,
4659 cx,
4660 );
4661 });
4662 cx_a.run_until_parked();
4663
4664 assert_eq!(
4665 multi_workspace_a
4666 .read_with(cx_a, |mw, _| mw.workspaces().count())
4667 .unwrap(),
4668 1,
4669 "should not add the other window's workspace into the current window"
4670 );
4671 assert_eq!(
4672 multi_workspace_b
4673 .read_with(cx_a, |mw, _| mw.workspaces().count())
4674 .unwrap(),
4675 1,
4676 "should reuse the existing workspace in the other window"
4677 );
4678 assert!(
4679 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4680 "should activate the window that already owns the matching workspace"
4681 );
4682 sidebar.read_with(cx_a, |sidebar, _| {
4683 assert!(
4684 !is_active_session(&sidebar, &session_id),
4685 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4686 );
4687 });
4688}
4689
4690#[gpui::test]
4691async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
4692 cx: &mut TestAppContext,
4693) {
4694 init_test(cx);
4695 let fs = FakeFs::new(cx.executor());
4696 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4697 .await;
4698 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4699 .await;
4700 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4701
4702 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4703 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4704
4705 let multi_workspace_a =
4706 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4707 let multi_workspace_b =
4708 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
4709
4710 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4711 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4712
4713 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4714 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4715
4716 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4717 let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4718 let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
4719 let _panel_b = add_agent_panel(&workspace_b, cx_b);
4720
4721 let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
4722
4723 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4724 sidebar.open_thread_from_archive(
4725 ThreadMetadata {
4726 thread_id: ThreadId::new(),
4727 session_id: Some(session_id.clone()),
4728 agent_id: agent::ZED_AGENT_ID.clone(),
4729 title: Some("Cross Window Thread".into()),
4730 updated_at: Utc::now(),
4731 created_at: None,
4732 interacted_at: None,
4733 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4734 "/project-b",
4735 )])),
4736 archived: false,
4737 remote_connection: None,
4738 },
4739 window,
4740 cx,
4741 );
4742 });
4743 cx_a.run_until_parked();
4744
4745 assert_eq!(
4746 multi_workspace_a
4747 .read_with(cx_a, |mw, _| mw.workspaces().count())
4748 .unwrap(),
4749 1,
4750 "should not add the other window's workspace into the current window"
4751 );
4752 assert_eq!(
4753 multi_workspace_b
4754 .read_with(cx_a, |mw, _| mw.workspaces().count())
4755 .unwrap(),
4756 1,
4757 "should reuse the existing workspace in the other window"
4758 );
4759 assert!(
4760 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4761 "should activate the window that already owns the matching workspace"
4762 );
4763 sidebar_a.read_with(cx_a, |sidebar, _| {
4764 assert!(
4765 !is_active_session(&sidebar, &session_id),
4766 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4767 );
4768 });
4769 sidebar_b.read_with(cx_b, |sidebar, _| {
4770 assert_active_thread(
4771 sidebar,
4772 &session_id,
4773 "target window's sidebar should eagerly focus the activated archived thread",
4774 );
4775 });
4776}
4777
4778#[gpui::test]
4779async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
4780 cx: &mut TestAppContext,
4781) {
4782 init_test(cx);
4783 let fs = FakeFs::new(cx.executor());
4784 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4785 .await;
4786 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4787
4788 let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4789 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4790
4791 let multi_workspace_b =
4792 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4793 let multi_workspace_a =
4794 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4795
4796 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4797 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4798
4799 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4800 let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4801
4802 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4803 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4804
4805 let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
4806
4807 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4808 sidebar.open_thread_from_archive(
4809 ThreadMetadata {
4810 thread_id: ThreadId::new(),
4811 session_id: Some(session_id.clone()),
4812 agent_id: agent::ZED_AGENT_ID.clone(),
4813 title: Some("Current Window Thread".into()),
4814 updated_at: Utc::now(),
4815 created_at: None,
4816 interacted_at: None,
4817 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
4818 "/project-a",
4819 )])),
4820 archived: false,
4821 remote_connection: None,
4822 },
4823 window,
4824 cx,
4825 );
4826 });
4827 cx_a.run_until_parked();
4828
4829 assert!(
4830 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
4831 "should keep activation in the current window when it already has a matching workspace"
4832 );
4833 sidebar_a.read_with(cx_a, |sidebar, _| {
4834 assert_active_thread(
4835 sidebar,
4836 &session_id,
4837 "current window's sidebar should eagerly focus the activated archived thread",
4838 );
4839 });
4840 assert_eq!(
4841 multi_workspace_a
4842 .read_with(cx_a, |mw, _| mw.workspaces().count())
4843 .unwrap(),
4844 1,
4845 "current window should continue reusing its existing workspace"
4846 );
4847 assert_eq!(
4848 multi_workspace_b
4849 .read_with(cx_a, |mw, _| mw.workspaces().count())
4850 .unwrap(),
4851 1,
4852 "other windows should not be activated just because they also match the saved paths"
4853 );
4854}
4855
4856#[gpui::test]
4857async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
4858 // Regression test: archive_thread previously always loaded the next thread
4859 // through group_workspace (the main workspace's ProjectHeader), even when
4860 // the next thread belonged to an absorbed linked-worktree workspace. That
4861 // caused the worktree thread to be loaded in the main panel, which bound it
4862 // to the main project and corrupted its stored folder_paths.
4863 //
4864 // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
4865 // falling back to group_workspace only for Closed workspaces.
4866 agent_ui::test_support::init_test(cx);
4867 cx.update(|cx| {
4868 ThreadStore::init_global(cx);
4869 ThreadMetadataStore::init_global(cx);
4870 language_model::LanguageModelRegistry::test(cx);
4871 prompt_store::init(cx);
4872 });
4873
4874 let fs = FakeFs::new(cx.executor());
4875
4876 fs.insert_tree(
4877 "/project",
4878 serde_json::json!({
4879 ".git": {},
4880 "src": {},
4881 }),
4882 )
4883 .await;
4884
4885 fs.add_linked_worktree_for_repo(
4886 Path::new("/project/.git"),
4887 false,
4888 git::repository::Worktree {
4889 path: std::path::PathBuf::from("/wt-feature-a"),
4890 ref_name: Some("refs/heads/feature-a".into()),
4891 sha: "aaa".into(),
4892 is_main: false,
4893 is_bare: false,
4894 },
4895 )
4896 .await;
4897
4898 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4899
4900 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4901 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4902
4903 main_project
4904 .update(cx, |p, cx| p.git_scans_complete(cx))
4905 .await;
4906 worktree_project
4907 .update(cx, |p, cx| p.git_scans_complete(cx))
4908 .await;
4909
4910 let (multi_workspace, cx) =
4911 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4912
4913 let sidebar = setup_sidebar(&multi_workspace, cx);
4914
4915 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4916 mw.test_add_workspace(worktree_project.clone(), window, cx)
4917 });
4918
4919 // Activate main workspace so the sidebar tracks the main panel.
4920 multi_workspace.update_in(cx, |mw, window, cx| {
4921 let workspace = mw.workspaces().next().unwrap().clone();
4922 mw.activate(workspace, None, window, cx);
4923 });
4924
4925 let main_workspace =
4926 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4927 let main_panel = add_agent_panel(&main_workspace, cx);
4928 let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
4929
4930 // Open Thread 2 in the main panel and keep it running.
4931 let connection = StubAgentConnection::new();
4932 open_thread_with_connection(&main_panel, connection.clone(), cx);
4933 send_message(&main_panel, cx);
4934
4935 let thread2_session_id = active_session_id(&main_panel, cx);
4936
4937 cx.update(|_, cx| {
4938 connection.send_update(
4939 thread2_session_id.clone(),
4940 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4941 cx,
4942 );
4943 });
4944
4945 // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
4946 save_thread_metadata(
4947 thread2_session_id.clone(),
4948 Some("Thread 2".into()),
4949 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4950 None,
4951 None,
4952 &main_project,
4953 cx,
4954 );
4955
4956 // Save thread 1's metadata with the worktree path and an older timestamp so
4957 // it sorts below thread 2. archive_thread will find it as the "next" candidate.
4958 let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
4959 save_thread_metadata(
4960 thread1_session_id,
4961 Some("Thread 1".into()),
4962 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4963 None,
4964 None,
4965 &worktree_project,
4966 cx,
4967 );
4968
4969 cx.run_until_parked();
4970
4971 // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
4972 let entries_before = visible_entries_as_strings(&sidebar, cx);
4973 assert!(
4974 entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
4975 "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
4976 entries_before
4977 );
4978
4979 // The sidebar should track T2 as the focused thread (derived from the
4980 // main panel's active view).
4981 sidebar.read_with(cx, |s, _| {
4982 assert_active_thread(
4983 s,
4984 &thread2_session_id,
4985 "focused thread should be Thread 2 before archiving",
4986 );
4987 });
4988
4989 // Archive thread 2.
4990 sidebar.update_in(cx, |sidebar, window, cx| {
4991 sidebar.archive_thread(&thread2_session_id, window, cx);
4992 });
4993
4994 cx.run_until_parked();
4995
4996 // The main panel's active thread must still be thread 2.
4997 let main_active = main_panel.read_with(cx, |panel, cx| {
4998 panel
4999 .active_agent_thread(cx)
5000 .map(|t| t.read(cx).session_id().clone())
5001 });
5002 assert_eq!(
5003 main_active,
5004 Some(thread2_session_id.clone()),
5005 "main panel should not have been taken over by loading the linked-worktree thread T1; \
5006 before the fix, archive_thread used group_workspace instead of next.workspace, \
5007 causing T1 to be loaded in the wrong panel"
5008 );
5009
5010 // Thread 1 should still appear in the sidebar with its worktree chip
5011 // (Thread 2 was archived so it is gone from the list).
5012 let entries_after = visible_entries_as_strings(&sidebar, cx);
5013 assert!(
5014 entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
5015 "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
5016 entries_after
5017 );
5018}
5019
5020#[gpui::test]
5021async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
5022 // When the last non-archived thread for a linked worktree is archived,
5023 // the linked worktree workspace should be removed from the multi-workspace.
5024 // The main worktree workspace should remain (it's always reachable via
5025 // the project header).
5026 init_test(cx);
5027 let fs = FakeFs::new(cx.executor());
5028
5029 fs.insert_tree(
5030 "/project",
5031 serde_json::json!({
5032 ".git": {
5033 "worktrees": {
5034 "feature-a": {
5035 "commondir": "../../",
5036 "HEAD": "ref: refs/heads/feature-a",
5037 },
5038 },
5039 },
5040 "src": {},
5041 }),
5042 )
5043 .await;
5044
5045 fs.insert_tree(
5046 "/worktrees/project/feature-a/project",
5047 serde_json::json!({
5048 ".git": "gitdir: /project/.git/worktrees/feature-a",
5049 "src": {},
5050 }),
5051 )
5052 .await;
5053
5054 fs.add_linked_worktree_for_repo(
5055 Path::new("/project/.git"),
5056 false,
5057 git::repository::Worktree {
5058 path: PathBuf::from("/worktrees/project/feature-a/project"),
5059 ref_name: Some("refs/heads/feature-a".into()),
5060 sha: "abc".into(),
5061 is_main: false,
5062 is_bare: false,
5063 },
5064 )
5065 .await;
5066
5067 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5068
5069 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5070 let worktree_project = project::Project::test(
5071 fs.clone(),
5072 ["/worktrees/project/feature-a/project".as_ref()],
5073 cx,
5074 )
5075 .await;
5076
5077 main_project
5078 .update(cx, |p, cx| p.git_scans_complete(cx))
5079 .await;
5080 worktree_project
5081 .update(cx, |p, cx| p.git_scans_complete(cx))
5082 .await;
5083
5084 let (multi_workspace, cx) =
5085 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5086 let sidebar = setup_sidebar(&multi_workspace, cx);
5087
5088 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5089 mw.test_add_workspace(worktree_project.clone(), window, cx)
5090 });
5091
5092 // Save a thread for the main project.
5093 save_thread_metadata(
5094 acp::SessionId::new(Arc::from("main-thread")),
5095 Some("Main Thread".into()),
5096 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5097 None,
5098 None,
5099 &main_project,
5100 cx,
5101 );
5102
5103 // Save a thread for the linked worktree.
5104 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
5105 save_thread_metadata(
5106 wt_thread_id.clone(),
5107 Some("Worktree Thread".into()),
5108 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5109 None,
5110 None,
5111 &worktree_project,
5112 cx,
5113 );
5114 cx.run_until_parked();
5115
5116 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5117 cx.run_until_parked();
5118
5119 // Should have 2 workspaces.
5120 assert_eq!(
5121 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5122 2,
5123 "should start with 2 workspaces (main + linked worktree)"
5124 );
5125
5126 // Archive the worktree thread (the only thread for /wt-feature-a).
5127 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5128 sidebar.archive_thread(&wt_thread_id, window, cx);
5129 });
5130
5131 // archive_thread spawns a multi-layered chain of tasks (workspace
5132 // removal → git persist → disk removal), each of which may spawn
5133 // further background work. Each run_until_parked() call drives one
5134 // layer of pending work.
5135
5136 cx.run_until_parked();
5137
5138 // The linked worktree workspace should have been removed.
5139 assert_eq!(
5140 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5141 1,
5142 "linked worktree workspace should be removed after archiving its last thread"
5143 );
5144
5145 // The linked worktree checkout directory should also be removed from disk.
5146 assert!(
5147 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
5148 .await,
5149 "linked worktree directory should be removed from disk after archiving its last thread"
5150 );
5151
5152 // The main thread should still be visible.
5153 let entries = visible_entries_as_strings(&sidebar, cx);
5154 assert!(
5155 entries.iter().any(|e| e.contains("Main Thread")),
5156 "main thread should still be visible: {entries:?}"
5157 );
5158 assert!(
5159 !entries.iter().any(|e| e.contains("Worktree Thread")),
5160 "archived worktree thread should not be visible: {entries:?}"
5161 );
5162
5163 // The archived thread must retain its folder_paths so it can be
5164 // restored to the correct workspace later.
5165 let wt_thread_id = cx.update(|_window, cx| {
5166 ThreadMetadataStore::global(cx)
5167 .read(cx)
5168 .entry_by_session(&wt_thread_id)
5169 .unwrap()
5170 .thread_id
5171 });
5172 let archived_paths = cx.update(|_window, cx| {
5173 ThreadMetadataStore::global(cx)
5174 .read(cx)
5175 .entry(wt_thread_id)
5176 .unwrap()
5177 .folder_paths()
5178 .clone()
5179 });
5180 assert_eq!(
5181 archived_paths.paths(),
5182 &[PathBuf::from("/worktrees/project/feature-a/project")],
5183 "archived thread must retain its folder_paths for restore"
5184 );
5185}
5186
5187#[gpui::test]
5188async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
5189 // restore_worktree_via_git should succeed when the branch has moved
5190 // to a different SHA since archival. The worktree stays in detached
5191 // HEAD and the moved branch is left untouched.
5192 init_test(cx);
5193 let fs = FakeFs::new(cx.executor());
5194
5195 fs.insert_tree(
5196 "/project",
5197 serde_json::json!({
5198 ".git": {
5199 "worktrees": {
5200 "feature-a": {
5201 "commondir": "../../",
5202 "HEAD": "ref: refs/heads/feature-a",
5203 },
5204 },
5205 },
5206 "src": {},
5207 }),
5208 )
5209 .await;
5210 fs.insert_tree(
5211 "/wt-feature-a",
5212 serde_json::json!({
5213 ".git": "gitdir: /project/.git/worktrees/feature-a",
5214 "src": {},
5215 }),
5216 )
5217 .await;
5218 fs.add_linked_worktree_for_repo(
5219 Path::new("/project/.git"),
5220 false,
5221 git::repository::Worktree {
5222 path: PathBuf::from("/wt-feature-a"),
5223 ref_name: Some("refs/heads/feature-a".into()),
5224 sha: "original-sha".into(),
5225 is_main: false,
5226 is_bare: false,
5227 },
5228 )
5229 .await;
5230 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5231
5232 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5233 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5234 main_project
5235 .update(cx, |p, cx| p.git_scans_complete(cx))
5236 .await;
5237 worktree_project
5238 .update(cx, |p, cx| p.git_scans_complete(cx))
5239 .await;
5240
5241 let (multi_workspace, _cx) =
5242 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5243 multi_workspace.update_in(_cx, |mw, window, cx| {
5244 mw.test_add_workspace(worktree_project.clone(), window, cx)
5245 });
5246
5247 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5248 project.repositories(cx).values().next().unwrap().clone()
5249 });
5250 let (staged_hash, unstaged_hash) = cx
5251 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5252 .await
5253 .unwrap()
5254 .unwrap();
5255
5256 // Move the branch to a different SHA.
5257 fs.with_git_state(Path::new("/project/.git"), false, |state| {
5258 state
5259 .refs
5260 .insert("refs/heads/feature-a".into(), "moved-sha".into());
5261 })
5262 .unwrap();
5263
5264 let result = cx
5265 .spawn(|mut cx| async move {
5266 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5267 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5268 id: 1,
5269 worktree_path: PathBuf::from("/wt-feature-a"),
5270 main_repo_path: PathBuf::from("/project"),
5271 branch_name: Some("feature-a".to_string()),
5272 staged_commit_hash: staged_hash,
5273 unstaged_commit_hash: unstaged_hash,
5274 original_commit_hash: "original-sha".to_string(),
5275 },
5276 None,
5277 &mut cx,
5278 )
5279 .await
5280 })
5281 .await;
5282
5283 assert!(
5284 result.is_ok(),
5285 "restore should succeed even when branch has moved: {:?}",
5286 result.err()
5287 );
5288
5289 // The moved branch ref should be completely untouched.
5290 let branch_sha = fs
5291 .with_git_state(Path::new("/project/.git"), false, |state| {
5292 state.refs.get("refs/heads/feature-a").cloned()
5293 })
5294 .unwrap();
5295 assert_eq!(
5296 branch_sha.as_deref(),
5297 Some("moved-sha"),
5298 "the moved branch ref should not be modified by the restore"
5299 );
5300}
5301
5302#[gpui::test]
5303async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
5304 // restore_worktree_via_git should succeed when the branch still
5305 // points at the same SHA as at archive time.
5306 init_test(cx);
5307 let fs = FakeFs::new(cx.executor());
5308
5309 fs.insert_tree(
5310 "/project",
5311 serde_json::json!({
5312 ".git": {
5313 "worktrees": {
5314 "feature-b": {
5315 "commondir": "../../",
5316 "HEAD": "ref: refs/heads/feature-b",
5317 },
5318 },
5319 },
5320 "src": {},
5321 }),
5322 )
5323 .await;
5324 fs.insert_tree(
5325 "/wt-feature-b",
5326 serde_json::json!({
5327 ".git": "gitdir: /project/.git/worktrees/feature-b",
5328 "src": {},
5329 }),
5330 )
5331 .await;
5332 fs.add_linked_worktree_for_repo(
5333 Path::new("/project/.git"),
5334 false,
5335 git::repository::Worktree {
5336 path: PathBuf::from("/wt-feature-b"),
5337 ref_name: Some("refs/heads/feature-b".into()),
5338 sha: "original-sha".into(),
5339 is_main: false,
5340 is_bare: false,
5341 },
5342 )
5343 .await;
5344 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5345
5346 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5347 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
5348 main_project
5349 .update(cx, |p, cx| p.git_scans_complete(cx))
5350 .await;
5351 worktree_project
5352 .update(cx, |p, cx| p.git_scans_complete(cx))
5353 .await;
5354
5355 let (multi_workspace, _cx) =
5356 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5357 multi_workspace.update_in(_cx, |mw, window, cx| {
5358 mw.test_add_workspace(worktree_project.clone(), window, cx)
5359 });
5360
5361 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5362 project.repositories(cx).values().next().unwrap().clone()
5363 });
5364 let (staged_hash, unstaged_hash) = cx
5365 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5366 .await
5367 .unwrap()
5368 .unwrap();
5369
5370 // refs/heads/feature-b already points at "original-sha" (set by
5371 // add_linked_worktree_for_repo), matching original_commit_hash.
5372
5373 let result = cx
5374 .spawn(|mut cx| async move {
5375 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5376 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5377 id: 1,
5378 worktree_path: PathBuf::from("/wt-feature-b"),
5379 main_repo_path: PathBuf::from("/project"),
5380 branch_name: Some("feature-b".to_string()),
5381 staged_commit_hash: staged_hash,
5382 unstaged_commit_hash: unstaged_hash,
5383 original_commit_hash: "original-sha".to_string(),
5384 },
5385 None,
5386 &mut cx,
5387 )
5388 .await
5389 })
5390 .await;
5391
5392 assert!(
5393 result.is_ok(),
5394 "restore should succeed when branch has not moved: {:?}",
5395 result.err()
5396 );
5397}
5398
5399#[gpui::test]
5400async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
5401 // restore_worktree_via_git should succeed when the branch no longer
5402 // exists (e.g. it was deleted while the thread was archived). The
5403 // code should attempt to recreate the branch.
5404 init_test(cx);
5405 let fs = FakeFs::new(cx.executor());
5406
5407 fs.insert_tree(
5408 "/project",
5409 serde_json::json!({
5410 ".git": {
5411 "worktrees": {
5412 "feature-d": {
5413 "commondir": "../../",
5414 "HEAD": "ref: refs/heads/feature-d",
5415 },
5416 },
5417 },
5418 "src": {},
5419 }),
5420 )
5421 .await;
5422 fs.insert_tree(
5423 "/wt-feature-d",
5424 serde_json::json!({
5425 ".git": "gitdir: /project/.git/worktrees/feature-d",
5426 "src": {},
5427 }),
5428 )
5429 .await;
5430 fs.add_linked_worktree_for_repo(
5431 Path::new("/project/.git"),
5432 false,
5433 git::repository::Worktree {
5434 path: PathBuf::from("/wt-feature-d"),
5435 ref_name: Some("refs/heads/feature-d".into()),
5436 sha: "original-sha".into(),
5437 is_main: false,
5438 is_bare: false,
5439 },
5440 )
5441 .await;
5442 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5443
5444 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5445 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
5446 main_project
5447 .update(cx, |p, cx| p.git_scans_complete(cx))
5448 .await;
5449 worktree_project
5450 .update(cx, |p, cx| p.git_scans_complete(cx))
5451 .await;
5452
5453 let (multi_workspace, _cx) =
5454 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5455 multi_workspace.update_in(_cx, |mw, window, cx| {
5456 mw.test_add_workspace(worktree_project.clone(), window, cx)
5457 });
5458
5459 let wt_repo = worktree_project.read_with(cx, |project, cx| {
5460 project.repositories(cx).values().next().unwrap().clone()
5461 });
5462 let (staged_hash, unstaged_hash) = cx
5463 .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
5464 .await
5465 .unwrap()
5466 .unwrap();
5467
5468 // Remove the branch ref so change_branch will fail.
5469 fs.with_git_state(Path::new("/project/.git"), false, |state| {
5470 state.refs.remove("refs/heads/feature-d");
5471 })
5472 .unwrap();
5473
5474 let result = cx
5475 .spawn(|mut cx| async move {
5476 agent_ui::thread_worktree_archive::restore_worktree_via_git(
5477 &agent_ui::thread_metadata_store::ArchivedGitWorktree {
5478 id: 1,
5479 worktree_path: PathBuf::from("/wt-feature-d"),
5480 main_repo_path: PathBuf::from("/project"),
5481 branch_name: Some("feature-d".to_string()),
5482 staged_commit_hash: staged_hash,
5483 unstaged_commit_hash: unstaged_hash,
5484 original_commit_hash: "original-sha".to_string(),
5485 },
5486 None,
5487 &mut cx,
5488 )
5489 .await
5490 })
5491 .await;
5492
5493 assert!(
5494 result.is_ok(),
5495 "restore should succeed when branch does not exist: {:?}",
5496 result.err()
5497 );
5498}
5499
5500#[gpui::test]
5501async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
5502 // Activating an archived linked worktree thread whose directory has
5503 // been deleted should reuse the existing main repo workspace, not
5504 // create a new one. The provisional ProjectGroupKey must be derived
5505 // from main_worktree_paths so that find_or_create_local_workspace
5506 // matches the main repo workspace when the worktree path is absent.
5507 init_test(cx);
5508 let fs = FakeFs::new(cx.executor());
5509
5510 fs.insert_tree(
5511 "/project",
5512 serde_json::json!({
5513 ".git": {
5514 "worktrees": {
5515 "feature-c": {
5516 "commondir": "../../",
5517 "HEAD": "ref: refs/heads/feature-c",
5518 },
5519 },
5520 },
5521 "src": {},
5522 }),
5523 )
5524 .await;
5525
5526 fs.insert_tree(
5527 "/wt-feature-c",
5528 serde_json::json!({
5529 ".git": "gitdir: /project/.git/worktrees/feature-c",
5530 "src": {},
5531 }),
5532 )
5533 .await;
5534
5535 fs.add_linked_worktree_for_repo(
5536 Path::new("/project/.git"),
5537 false,
5538 git::repository::Worktree {
5539 path: PathBuf::from("/wt-feature-c"),
5540 ref_name: Some("refs/heads/feature-c".into()),
5541 sha: "original-sha".into(),
5542 is_main: false,
5543 is_bare: false,
5544 },
5545 )
5546 .await;
5547
5548 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5549
5550 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5551 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
5552
5553 main_project
5554 .update(cx, |p, cx| p.git_scans_complete(cx))
5555 .await;
5556 worktree_project
5557 .update(cx, |p, cx| p.git_scans_complete(cx))
5558 .await;
5559
5560 let (multi_workspace, cx) =
5561 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5562 let sidebar = setup_sidebar(&multi_workspace, cx);
5563
5564 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5565 mw.test_add_workspace(worktree_project.clone(), window, cx)
5566 });
5567
5568 // Save thread metadata for the linked worktree.
5569 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
5570 save_thread_metadata(
5571 wt_session_id.clone(),
5572 Some("Worktree Thread C".into()),
5573 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5574 None,
5575 None,
5576 &worktree_project,
5577 cx,
5578 );
5579 cx.run_until_parked();
5580
5581 let thread_id = cx.update(|_window, cx| {
5582 ThreadMetadataStore::global(cx)
5583 .read(cx)
5584 .entry_by_session(&wt_session_id)
5585 .unwrap()
5586 .thread_id
5587 });
5588
5589 // Archive the thread without creating ArchivedGitWorktree records.
5590 let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
5591 cx.update(|_window, cx| {
5592 store.update(cx, |store, cx| store.archive(thread_id, None, cx));
5593 });
5594 cx.run_until_parked();
5595
5596 // Remove the worktree workspace and delete the worktree from disk.
5597 let main_workspace =
5598 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5599 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
5600 mw.remove(
5601 vec![worktree_workspace],
5602 move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
5603 window,
5604 cx,
5605 )
5606 });
5607 remove_task.await.ok();
5608 cx.run_until_parked();
5609 cx.run_until_parked();
5610 fs.remove_dir(
5611 Path::new("/wt-feature-c"),
5612 fs::RemoveOptions {
5613 recursive: true,
5614 ignore_if_not_exists: true,
5615 },
5616 )
5617 .await
5618 .unwrap();
5619
5620 let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5621 assert_eq!(
5622 workspace_count_before, 1,
5623 "should have only the main workspace"
5624 );
5625
5626 // Activate the archived thread. The worktree path is missing from
5627 // disk, so find_or_create_local_workspace falls back to the
5628 // provisional ProjectGroupKey to find a matching workspace.
5629 let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
5630 sidebar.update_in(cx, |sidebar, window, cx| {
5631 sidebar.open_thread_from_archive(metadata, window, cx);
5632 });
5633 cx.run_until_parked();
5634
5635 // The provisional key should use [/project] (the main repo),
5636 // which matches the existing main workspace. If it incorrectly
5637 // used [/wt-feature-c] (the linked worktree path), no workspace
5638 // would match and a spurious new one would be created.
5639 let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
5640 assert_eq!(
5641 workspace_count_after, 1,
5642 "restoring a linked worktree thread should reuse the main repo workspace, \
5643 not create a new one (workspace count went from {workspace_count_before} to \
5644 {workspace_count_after})"
5645 );
5646}
5647
5648#[gpui::test]
5649async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
5650 cx: &mut TestAppContext,
5651) {
5652 // A remote thread at the same path as a local linked worktree thread
5653 // should not prevent the local workspace from being removed when the
5654 // local thread is archived (the last local thread for that worktree).
5655 init_test(cx);
5656 let fs = FakeFs::new(cx.executor());
5657
5658 fs.insert_tree(
5659 "/project",
5660 serde_json::json!({
5661 ".git": {
5662 "worktrees": {
5663 "feature-a": {
5664 "commondir": "../../",
5665 "HEAD": "ref: refs/heads/feature-a",
5666 },
5667 },
5668 },
5669 "src": {},
5670 }),
5671 )
5672 .await;
5673
5674 fs.insert_tree(
5675 "/wt-feature-a",
5676 serde_json::json!({
5677 ".git": "gitdir: /project/.git/worktrees/feature-a",
5678 "src": {},
5679 }),
5680 )
5681 .await;
5682
5683 fs.add_linked_worktree_for_repo(
5684 Path::new("/project/.git"),
5685 false,
5686 git::repository::Worktree {
5687 path: PathBuf::from("/wt-feature-a"),
5688 ref_name: Some("refs/heads/feature-a".into()),
5689 sha: "abc".into(),
5690 is_main: false,
5691 is_bare: false,
5692 },
5693 )
5694 .await;
5695
5696 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5697
5698 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5699 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5700
5701 main_project
5702 .update(cx, |p, cx| p.git_scans_complete(cx))
5703 .await;
5704 worktree_project
5705 .update(cx, |p, cx| p.git_scans_complete(cx))
5706 .await;
5707
5708 let (multi_workspace, cx) =
5709 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5710 let sidebar = setup_sidebar(&multi_workspace, cx);
5711
5712 let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5713 mw.test_add_workspace(worktree_project.clone(), window, cx)
5714 });
5715
5716 // Save a thread for the main project.
5717 save_thread_metadata(
5718 acp::SessionId::new(Arc::from("main-thread")),
5719 Some("Main Thread".into()),
5720 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5721 None,
5722 None,
5723 &main_project,
5724 cx,
5725 );
5726
5727 // Save a local thread for the linked worktree.
5728 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
5729 save_thread_metadata(
5730 wt_thread_id.clone(),
5731 Some("Local Worktree Thread".into()),
5732 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5733 None,
5734 None,
5735 &worktree_project,
5736 cx,
5737 );
5738
5739 // Save a remote thread at the same /wt-feature-a path but on a
5740 // different host. This should NOT count as a remaining thread for
5741 // the local linked worktree workspace.
5742 let remote_host =
5743 remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
5744 cx.update(|_window, cx| {
5745 let metadata = ThreadMetadata {
5746 thread_id: ThreadId::new(),
5747 session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
5748 agent_id: agent::ZED_AGENT_ID.clone(),
5749 title: Some("Remote Worktree Thread".into()),
5750 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5751 created_at: None,
5752 interacted_at: None,
5753 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
5754 "/wt-feature-a",
5755 )])),
5756 archived: false,
5757 remote_connection: Some(remote_host),
5758 };
5759 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5760 store.save(metadata, cx);
5761 });
5762 });
5763 cx.run_until_parked();
5764
5765 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5766 cx.run_until_parked();
5767
5768 assert_eq!(
5769 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5770 2,
5771 "should start with 2 workspaces (main + linked worktree)"
5772 );
5773
5774 // The remote thread should NOT appear in the sidebar (it belongs
5775 // to a different host and no matching remote project group exists).
5776 let entries_before = visible_entries_as_strings(&sidebar, cx);
5777 assert!(
5778 !entries_before
5779 .iter()
5780 .any(|e| e.contains("Remote Worktree Thread")),
5781 "remote thread should not appear in local sidebar: {entries_before:?}"
5782 );
5783
5784 // Archive the local worktree thread.
5785 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5786 sidebar.archive_thread(&wt_thread_id, window, cx);
5787 });
5788
5789 cx.run_until_parked();
5790
5791 // The linked worktree workspace should be removed because the
5792 // only *local* thread for it was archived. The remote thread at
5793 // the same path should not have prevented removal.
5794 assert_eq!(
5795 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5796 1,
5797 "linked worktree workspace should be removed; the remote thread at the same path \
5798 should not count as a remaining local thread"
5799 );
5800
5801 let entries = visible_entries_as_strings(&sidebar, cx);
5802 assert!(
5803 entries.iter().any(|e| e.contains("Main Thread")),
5804 "main thread should still be visible: {entries:?}"
5805 );
5806 assert!(
5807 !entries.iter().any(|e| e.contains("Local Worktree Thread")),
5808 "archived local worktree thread should not be visible: {entries:?}"
5809 );
5810 assert!(
5811 !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
5812 "remote thread should still not appear in local sidebar: {entries:?}"
5813 );
5814}
5815
5816#[gpui::test]
5817async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
5818 // When a multi-root workspace (e.g. [/other, /project]) shares a
5819 // repo with a single-root workspace (e.g. [/project]), linked
5820 // worktree threads from the shared repo should only appear under
5821 // the dedicated group [project], not under [other, project].
5822 agent_ui::test_support::init_test(cx);
5823 cx.update(|cx| {
5824 ThreadStore::init_global(cx);
5825 ThreadMetadataStore::init_global(cx);
5826 language_model::LanguageModelRegistry::test(cx);
5827 prompt_store::init(cx);
5828 });
5829 let fs = FakeFs::new(cx.executor());
5830
5831 // Two independent repos, each with their own git history.
5832 fs.insert_tree(
5833 "/project",
5834 serde_json::json!({
5835 ".git": {},
5836 "src": {},
5837 }),
5838 )
5839 .await;
5840 fs.insert_tree(
5841 "/other",
5842 serde_json::json!({
5843 ".git": {},
5844 "src": {},
5845 }),
5846 )
5847 .await;
5848
5849 // Register the linked worktree in the main repo.
5850 fs.add_linked_worktree_for_repo(
5851 Path::new("/project/.git"),
5852 false,
5853 git::repository::Worktree {
5854 path: std::path::PathBuf::from("/wt-feature-a"),
5855 ref_name: Some("refs/heads/feature-a".into()),
5856 sha: "aaa".into(),
5857 is_main: false,
5858 is_bare: false,
5859 },
5860 )
5861 .await;
5862
5863 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5864
5865 // Workspace 1: just /project.
5866 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5867 project_only
5868 .update(cx, |p, cx| p.git_scans_complete(cx))
5869 .await;
5870
5871 // Workspace 2: /other and /project together (multi-root).
5872 let multi_root =
5873 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
5874 multi_root
5875 .update(cx, |p, cx| p.git_scans_complete(cx))
5876 .await;
5877
5878 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5879 worktree_project
5880 .update(cx, |p, cx| p.git_scans_complete(cx))
5881 .await;
5882
5883 // Save a thread under the linked worktree path BEFORE setting up
5884 // the sidebar and panels, so that reconciliation sees the [project]
5885 // group as non-empty and doesn't create a spurious draft there.
5886 let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
5887 save_thread_metadata(
5888 wt_session_id,
5889 Some("Worktree Thread".into()),
5890 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5891 None,
5892 None,
5893 &worktree_project,
5894 cx,
5895 );
5896
5897 let (multi_workspace, cx) =
5898 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
5899 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5900 let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5901 mw.test_add_workspace(multi_root.clone(), window, cx)
5902 });
5903 add_agent_panel(&multi_root_workspace, cx);
5904 cx.run_until_parked();
5905
5906 // The thread should appear only under [project] (the dedicated
5907 // group for the /project repo), not under [other, project].
5908 assert_eq!(
5909 visible_entries_as_strings(&sidebar, cx),
5910 vec![
5911 //
5912 "v [other, project]",
5913 "v [project]",
5914 " Worktree Thread {wt-feature-a}",
5915 ]
5916 );
5917}
5918
5919fn thread_id_for(session_id: &acp::SessionId, cx: &mut TestAppContext) -> ThreadId {
5920 cx.read(|cx| {
5921 ThreadMetadataStore::global(cx)
5922 .read(cx)
5923 .entry_by_session(session_id)
5924 .map(|m| m.thread_id)
5925 .expect("thread metadata should exist")
5926 })
5927}
5928
5929#[gpui::test]
5930async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
5931 let project = init_test_project_with_agent_panel("/my-project", cx).await;
5932 let (multi_workspace, cx) =
5933 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5934 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5935
5936 let switcher_ids =
5937 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<ThreadId> {
5938 sidebar.read_with(cx, |sidebar, cx| {
5939 let switcher = sidebar
5940 .thread_switcher
5941 .as_ref()
5942 .expect("switcher should be open");
5943 switcher
5944 .read(cx)
5945 .entries()
5946 .iter()
5947 .map(|e| e.metadata.thread_id)
5948 .collect()
5949 })
5950 };
5951
5952 let switcher_selected_id =
5953 |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> ThreadId {
5954 sidebar.read_with(cx, |sidebar, cx| {
5955 let switcher = sidebar
5956 .thread_switcher
5957 .as_ref()
5958 .expect("switcher should be open");
5959 let s = switcher.read(cx);
5960 s.selected_entry()
5961 .expect("should have selection")
5962 .metadata
5963 .thread_id
5964 })
5965 };
5966
5967 // ── Setup: create three threads with distinct created_at times ──────
5968 // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
5969 // We send messages in each so they also get last_message_sent_or_queued timestamps.
5970 let connection_c = StubAgentConnection::new();
5971 connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5972 acp::ContentChunk::new("Done C".into()),
5973 )]);
5974 open_thread_with_connection(&panel, connection_c, cx);
5975 send_message(&panel, cx);
5976 let session_id_c = active_session_id(&panel, cx);
5977 let thread_id_c = active_thread_id(&panel, cx);
5978 save_thread_metadata(
5979 session_id_c.clone(),
5980 Some("Thread C".into()),
5981 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5982 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
5983 None,
5984 &project,
5985 cx,
5986 );
5987
5988 let connection_b = StubAgentConnection::new();
5989 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5990 acp::ContentChunk::new("Done B".into()),
5991 )]);
5992 open_thread_with_connection(&panel, connection_b, cx);
5993 send_message(&panel, cx);
5994 let session_id_b = active_session_id(&panel, cx);
5995 let thread_id_b = active_thread_id(&panel, cx);
5996 save_thread_metadata(
5997 session_id_b.clone(),
5998 Some("Thread B".into()),
5999 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6000 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
6001 None,
6002 &project,
6003 cx,
6004 );
6005
6006 let connection_a = StubAgentConnection::new();
6007 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6008 acp::ContentChunk::new("Done A".into()),
6009 )]);
6010 open_thread_with_connection(&panel, connection_a, cx);
6011 send_message(&panel, cx);
6012 let session_id_a = active_session_id(&panel, cx);
6013 let thread_id_a = active_thread_id(&panel, cx);
6014 save_thread_metadata(
6015 session_id_a.clone(),
6016 Some("Thread A".into()),
6017 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
6018 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
6019 None,
6020 &project,
6021 cx,
6022 );
6023
6024 // All three threads are now live. Thread A was opened last, so it's
6025 // the one being viewed. Opening each thread called record_thread_access,
6026 // so all three have last_accessed_at set.
6027 // Access order is: A (most recent), B, C (oldest).
6028
6029 // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
6030 focus_sidebar(&sidebar, cx);
6031 sidebar.update_in(cx, |sidebar, window, cx| {
6032 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6033 });
6034 cx.run_until_parked();
6035
6036 // All three have last_accessed_at, so they sort by access time.
6037 // A was accessed most recently (it's the currently viewed thread),
6038 // then B, then C.
6039 assert_eq!(
6040 switcher_ids(&sidebar, cx),
6041 vec![thread_id_a, thread_id_b, thread_id_c,],
6042 );
6043 // First ctrl-tab selects the second entry (B).
6044 assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_b);
6045
6046 // Dismiss the switcher without confirming.
6047 sidebar.update_in(cx, |sidebar, _window, cx| {
6048 sidebar.dismiss_thread_switcher(cx);
6049 });
6050 cx.run_until_parked();
6051
6052 // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
6053 sidebar.update_in(cx, |sidebar, window, cx| {
6054 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6055 });
6056 cx.run_until_parked();
6057
6058 // Cycle twice to land on Thread C (index 2).
6059 sidebar.read_with(cx, |sidebar, cx| {
6060 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6061 assert_eq!(switcher.read(cx).selected_index(), 1);
6062 });
6063 sidebar.update_in(cx, |sidebar, _window, cx| {
6064 sidebar
6065 .thread_switcher
6066 .as_ref()
6067 .unwrap()
6068 .update(cx, |s, cx| s.cycle_selection(cx));
6069 });
6070 cx.run_until_parked();
6071 assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_c);
6072
6073 assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
6074
6075 // Confirm on Thread C.
6076 sidebar.update_in(cx, |sidebar, window, cx| {
6077 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6078 let focus = switcher.focus_handle(cx);
6079 focus.dispatch_action(&menu::Confirm, window, cx);
6080 });
6081 cx.run_until_parked();
6082
6083 // Switcher should be dismissed after confirm.
6084 sidebar.read_with(cx, |sidebar, _cx| {
6085 assert!(
6086 sidebar.thread_switcher.is_none(),
6087 "switcher should be dismissed"
6088 );
6089 });
6090
6091 sidebar.update(cx, |sidebar, _cx| {
6092 let last_accessed = sidebar
6093 .thread_last_accessed
6094 .keys()
6095 .cloned()
6096 .collect::<Vec<_>>();
6097 assert_eq!(last_accessed.len(), 1);
6098 assert!(last_accessed.contains(&thread_id_c));
6099 assert!(
6100 is_active_session(&sidebar, &session_id_c),
6101 "active_entry should be Thread({session_id_c:?})"
6102 );
6103 });
6104
6105 sidebar.update_in(cx, |sidebar, window, cx| {
6106 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6107 });
6108 cx.run_until_parked();
6109
6110 assert_eq!(
6111 switcher_ids(&sidebar, cx),
6112 vec![thread_id_c, thread_id_a, thread_id_b],
6113 );
6114
6115 // Confirm on Thread A.
6116 sidebar.update_in(cx, |sidebar, window, cx| {
6117 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6118 let focus = switcher.focus_handle(cx);
6119 focus.dispatch_action(&menu::Confirm, window, cx);
6120 });
6121 cx.run_until_parked();
6122
6123 sidebar.update(cx, |sidebar, _cx| {
6124 let last_accessed = sidebar
6125 .thread_last_accessed
6126 .keys()
6127 .cloned()
6128 .collect::<Vec<_>>();
6129 assert_eq!(last_accessed.len(), 2);
6130 assert!(last_accessed.contains(&thread_id_c));
6131 assert!(last_accessed.contains(&thread_id_a));
6132 assert!(
6133 is_active_session(&sidebar, &session_id_a),
6134 "active_entry should be Thread({session_id_a:?})"
6135 );
6136 });
6137
6138 sidebar.update_in(cx, |sidebar, window, cx| {
6139 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6140 });
6141 cx.run_until_parked();
6142
6143 assert_eq!(
6144 switcher_ids(&sidebar, cx),
6145 vec![thread_id_a, thread_id_c, thread_id_b,],
6146 );
6147
6148 sidebar.update_in(cx, |sidebar, _window, cx| {
6149 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6150 switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
6151 });
6152 cx.run_until_parked();
6153
6154 // Confirm on Thread B.
6155 sidebar.update_in(cx, |sidebar, window, cx| {
6156 let switcher = sidebar.thread_switcher.as_ref().unwrap();
6157 let focus = switcher.focus_handle(cx);
6158 focus.dispatch_action(&menu::Confirm, window, cx);
6159 });
6160 cx.run_until_parked();
6161
6162 sidebar.update(cx, |sidebar, _cx| {
6163 let last_accessed = sidebar
6164 .thread_last_accessed
6165 .keys()
6166 .cloned()
6167 .collect::<Vec<_>>();
6168 assert_eq!(last_accessed.len(), 3);
6169 assert!(last_accessed.contains(&thread_id_c));
6170 assert!(last_accessed.contains(&thread_id_a));
6171 assert!(last_accessed.contains(&thread_id_b));
6172 assert!(
6173 is_active_session(&sidebar, &session_id_b),
6174 "active_entry should be Thread({session_id_b:?})"
6175 );
6176 });
6177
6178 // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
6179 // This thread was never opened in a panel — it only exists in metadata.
6180 save_thread_metadata(
6181 acp::SessionId::new(Arc::from("thread-historical")),
6182 Some("Historical Thread".into()),
6183 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
6184 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
6185 None,
6186 &project,
6187 cx,
6188 );
6189
6190 sidebar.update_in(cx, |sidebar, window, cx| {
6191 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6192 });
6193 cx.run_until_parked();
6194
6195 // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
6196 // so it falls to tier 3 (sorted by created_at). It should appear after all
6197 // accessed threads, even though its created_at (June 2024) is much later
6198 // than the others.
6199 //
6200 // But the live threads (A, B, C) each had send_message called which sets
6201 // last_message_sent_or_queued. So for the accessed threads (tier 1) the
6202 // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
6203 let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
6204 let thread_id_hist = thread_id_for(&session_id_hist, cx);
6205
6206 let ids = switcher_ids(&sidebar, cx);
6207 assert_eq!(
6208 ids,
6209 vec![thread_id_b, thread_id_a, thread_id_c, thread_id_hist],
6210 );
6211
6212 sidebar.update_in(cx, |sidebar, _window, cx| {
6213 sidebar.dismiss_thread_switcher(cx);
6214 });
6215 cx.run_until_parked();
6216
6217 // ── 4. Add another historical thread with older created_at ─────────
6218 save_thread_metadata(
6219 acp::SessionId::new(Arc::from("thread-old-historical")),
6220 Some("Old Historical Thread".into()),
6221 chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
6222 Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
6223 None,
6224 &project,
6225 cx,
6226 );
6227
6228 sidebar.update_in(cx, |sidebar, window, cx| {
6229 sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
6230 });
6231 cx.run_until_parked();
6232
6233 // Both historical threads have no access or message times. They should
6234 // appear after accessed threads, sorted by created_at (newest first).
6235 let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
6236 let thread_id_old_hist = thread_id_for(&session_id_old_hist, cx);
6237 let ids = switcher_ids(&sidebar, cx);
6238 assert_eq!(
6239 ids,
6240 vec![
6241 thread_id_b,
6242 thread_id_a,
6243 thread_id_c,
6244 thread_id_hist,
6245 thread_id_old_hist,
6246 ],
6247 );
6248
6249 sidebar.update_in(cx, |sidebar, _window, cx| {
6250 sidebar.dismiss_thread_switcher(cx);
6251 });
6252 cx.run_until_parked();
6253}
6254
6255#[gpui::test]
6256async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
6257 let project = init_test_project("/my-project", cx).await;
6258 let (multi_workspace, cx) =
6259 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6260 let sidebar = setup_sidebar(&multi_workspace, cx);
6261
6262 save_thread_metadata(
6263 acp::SessionId::new(Arc::from("thread-to-archive")),
6264 Some("Thread To Archive".into()),
6265 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6266 None,
6267 None,
6268 &project,
6269 cx,
6270 );
6271 cx.run_until_parked();
6272
6273 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6274 cx.run_until_parked();
6275
6276 let entries = visible_entries_as_strings(&sidebar, cx);
6277 assert!(
6278 entries.iter().any(|e| e.contains("Thread To Archive")),
6279 "expected thread to be visible before archiving, got: {entries:?}"
6280 );
6281
6282 sidebar.update_in(cx, |sidebar, window, cx| {
6283 sidebar.archive_thread(
6284 &acp::SessionId::new(Arc::from("thread-to-archive")),
6285 window,
6286 cx,
6287 );
6288 });
6289 cx.run_until_parked();
6290
6291 let entries = visible_entries_as_strings(&sidebar, cx);
6292 assert!(
6293 !entries.iter().any(|e| e.contains("Thread To Archive")),
6294 "expected thread to be hidden after archiving, got: {entries:?}"
6295 );
6296
6297 cx.update(|_, cx| {
6298 let store = ThreadMetadataStore::global(cx);
6299 let archived: Vec<_> = store.read(cx).archived_entries().collect();
6300 assert_eq!(archived.len(), 1);
6301 assert_eq!(
6302 archived[0].session_id.as_ref().unwrap().0.as_ref(),
6303 "thread-to-archive"
6304 );
6305 assert!(archived[0].archived);
6306 });
6307}
6308
6309#[gpui::test]
6310async fn test_archive_thread_drops_retained_conversation_view(cx: &mut TestAppContext) {
6311 let project = init_test_project_with_agent_panel("/project-a", cx).await;
6312 let (multi_workspace, cx) =
6313 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6314 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6315 cx.run_until_parked();
6316
6317 let connection = acp_thread::StubAgentConnection::new();
6318 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6319 acp::ContentChunk::new("Done".into()),
6320 )]);
6321 open_thread_with_connection(&panel, connection, cx);
6322 send_message(&panel, cx);
6323 let session_id = active_session_id(&panel, cx);
6324 let thread_id = active_thread_id(&panel, cx);
6325 cx.run_until_parked();
6326
6327 sidebar.read_with(cx, |sidebar, _| {
6328 assert!(
6329 is_active_session(sidebar, &session_id),
6330 "expected the newly created thread to be active before archiving",
6331 );
6332 });
6333
6334 sidebar.update_in(cx, |sidebar, window, cx| {
6335 sidebar.archive_thread(&session_id, window, cx);
6336 });
6337 cx.run_until_parked();
6338
6339 panel.read_with(cx, |panel, _| {
6340 assert!(
6341 !panel.is_retained_thread(&thread_id),
6342 "archiving a thread must drop its ConversationView from retained_threads, \
6343 but the archived thread id {thread_id:?} is still retained",
6344 );
6345 });
6346}
6347
6348#[gpui::test]
6349async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
6350 // Tests two archive scenarios:
6351 // 1. Archiving a thread in a non-active workspace leaves active_entry
6352 // as the current draft.
6353 // 2. Archiving the thread the user is looking at falls back to a draft
6354 // on the same workspace.
6355 agent_ui::test_support::init_test(cx);
6356 cx.update(|cx| {
6357 ThreadStore::init_global(cx);
6358 ThreadMetadataStore::init_global(cx);
6359 language_model::LanguageModelRegistry::test(cx);
6360 prompt_store::init(cx);
6361 });
6362
6363 let fs = FakeFs::new(cx.executor());
6364 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6365 .await;
6366 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6367 .await;
6368 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6369
6370 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6371 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6372
6373 let (multi_workspace, cx) =
6374 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6375 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6376
6377 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6378 mw.test_add_workspace(project_b.clone(), window, cx)
6379 });
6380 let panel_b = add_agent_panel(&workspace_b, cx);
6381 cx.run_until_parked();
6382
6383 // Explicitly create a draft on workspace_b so the sidebar tracks one.
6384 sidebar.update_in(cx, |sidebar, window, cx| {
6385 sidebar.create_new_thread(&workspace_b, window, cx);
6386 });
6387 cx.run_until_parked();
6388
6389 // --- Scenario 1: archive a thread in the non-active workspace ---
6390
6391 // Create a thread in project-a (non-active — project-b is active).
6392 let connection = acp_thread::StubAgentConnection::new();
6393 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6394 acp::ContentChunk::new("Done".into()),
6395 )]);
6396 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
6397 agent_ui::test_support::send_message(&panel_a, cx);
6398 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
6399 cx.run_until_parked();
6400
6401 sidebar.update_in(cx, |sidebar, window, cx| {
6402 sidebar.archive_thread(&thread_a, window, cx);
6403 });
6404 cx.run_until_parked();
6405
6406 // active_entry should still be a draft on workspace_b (the active one).
6407 sidebar.read_with(cx, |sidebar, _| {
6408 assert!(
6409 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b),
6410 "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
6411 sidebar.active_entry,
6412 );
6413 });
6414
6415 // --- Scenario 2: archive the thread the user is looking at ---
6416
6417 // Create a thread in project-b (the active workspace) and verify it
6418 // becomes the active entry.
6419 let connection = acp_thread::StubAgentConnection::new();
6420 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6421 acp::ContentChunk::new("Done".into()),
6422 )]);
6423 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6424 agent_ui::test_support::send_message(&panel_b, cx);
6425 let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
6426 cx.run_until_parked();
6427
6428 sidebar.read_with(cx, |sidebar, _| {
6429 assert!(
6430 is_active_session(&sidebar, &thread_b),
6431 "expected active_entry to be Thread({thread_b}), got: {:?}",
6432 sidebar.active_entry,
6433 );
6434 });
6435
6436 sidebar.update_in(cx, |sidebar, window, cx| {
6437 sidebar.archive_thread(&thread_b, window, cx);
6438 });
6439 cx.run_until_parked();
6440
6441 // Archiving the active thread activates a draft on the same workspace
6442 // (via clear_base_view → activate_draft). The draft is not shown as a
6443 // sidebar row but active_entry tracks it.
6444 sidebar.read_with(cx, |sidebar, _| {
6445 assert!(
6446 matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b),
6447 "expected draft on workspace_b after archiving active thread, got: {:?}",
6448 sidebar.active_entry,
6449 );
6450 });
6451}
6452
6453#[gpui::test]
6454async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
6455 // Full flow: create a thread, archive it (removing the workspace),
6456 // then unarchive. Only the restored thread should appear — no
6457 // leftover drafts or previously-serialized threads.
6458 let project = init_test_project_with_agent_panel("/my-project", cx).await;
6459 let (multi_workspace, cx) =
6460 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6461 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6462 cx.run_until_parked();
6463
6464 // Create a thread and send a message so it's a real thread.
6465 let connection = acp_thread::StubAgentConnection::new();
6466 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6467 acp::ContentChunk::new("Hello".into()),
6468 )]);
6469 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6470 agent_ui::test_support::send_message(&panel, cx);
6471 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6472 cx.run_until_parked();
6473
6474 // Archive it.
6475 sidebar.update_in(cx, |sidebar, window, cx| {
6476 sidebar.archive_thread(&session_id, window, cx);
6477 });
6478 cx.run_until_parked();
6479
6480 // Grab metadata for unarchive.
6481 let thread_id = cx.update(|_, cx| {
6482 ThreadMetadataStore::global(cx)
6483 .read(cx)
6484 .entries()
6485 .find(|e| e.session_id.as_ref() == Some(&session_id))
6486 .map(|e| e.thread_id)
6487 .expect("thread should exist")
6488 });
6489 let metadata = cx.update(|_, cx| {
6490 ThreadMetadataStore::global(cx)
6491 .read(cx)
6492 .entry(thread_id)
6493 .cloned()
6494 .expect("metadata should exist")
6495 });
6496
6497 // Unarchive it — the draft should be replaced by the restored thread.
6498 sidebar.update_in(cx, |sidebar, window, cx| {
6499 sidebar.open_thread_from_archive(metadata, window, cx);
6500 });
6501 cx.run_until_parked();
6502
6503 // Only the unarchived thread should be visible — no drafts, no other threads.
6504 let entries = visible_entries_as_strings(&sidebar, cx);
6505 let thread_count = entries
6506 .iter()
6507 .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
6508 .count();
6509 assert_eq!(
6510 thread_count, 1,
6511 "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
6512 );
6513 assert!(
6514 !entries.iter().any(|e| e.contains("Draft")),
6515 "expected no drafts after restoring, got entries: {entries:?}"
6516 );
6517}
6518
6519#[gpui::test]
6520async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
6521 cx: &mut TestAppContext,
6522) {
6523 // When a thread is unarchived into a project group that has no open
6524 // workspace, the sidebar opens a new workspace and loads the thread.
6525 // No spurious draft should appear alongside the unarchived thread.
6526 agent_ui::test_support::init_test(cx);
6527 cx.update(|cx| {
6528 ThreadStore::init_global(cx);
6529 ThreadMetadataStore::init_global(cx);
6530 language_model::LanguageModelRegistry::test(cx);
6531 prompt_store::init(cx);
6532 });
6533
6534 let fs = FakeFs::new(cx.executor());
6535 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6536 .await;
6537 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6538 .await;
6539 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6540
6541 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6542 let (multi_workspace, cx) =
6543 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6544 let sidebar = setup_sidebar(&multi_workspace, cx);
6545 cx.run_until_parked();
6546
6547 // Save an archived thread whose folder_paths point to project-b,
6548 // which has no open workspace.
6549 let session_id = acp::SessionId::new(Arc::from("archived-thread"));
6550 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6551 let thread_id = ThreadId::new();
6552 cx.update(|_, cx| {
6553 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6554 store.save(
6555 ThreadMetadata {
6556 thread_id,
6557 session_id: Some(session_id.clone()),
6558 agent_id: agent::ZED_AGENT_ID.clone(),
6559 title: Some("Unarchived Thread".into()),
6560 updated_at: Utc::now(),
6561 created_at: None,
6562 interacted_at: None,
6563 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6564 archived: true,
6565 remote_connection: None,
6566 },
6567 cx,
6568 )
6569 });
6570 });
6571 cx.run_until_parked();
6572
6573 // Verify no workspace for project-b exists yet.
6574 assert_eq!(
6575 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6576 1,
6577 "should start with only the project-a workspace"
6578 );
6579
6580 // Un-archive the thread — should open project-b workspace and load it.
6581 let metadata = cx.update(|_, cx| {
6582 ThreadMetadataStore::global(cx)
6583 .read(cx)
6584 .entry(thread_id)
6585 .cloned()
6586 .expect("metadata should exist")
6587 });
6588
6589 sidebar.update_in(cx, |sidebar, window, cx| {
6590 sidebar.open_thread_from_archive(metadata, window, cx);
6591 });
6592 cx.run_until_parked();
6593
6594 // A second workspace should have been created for project-b.
6595 assert_eq!(
6596 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6597 2,
6598 "should have opened a workspace for the unarchived thread"
6599 );
6600
6601 // The sidebar should show the unarchived thread without a spurious draft
6602 // in the project-b group.
6603 let entries = visible_entries_as_strings(&sidebar, cx);
6604 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6605 // project-a gets a draft (it's the active workspace with no threads),
6606 // but project-b should NOT have one — only the unarchived thread.
6607 assert!(
6608 draft_count <= 1,
6609 "expected at most one draft (for project-a), got entries: {entries:?}"
6610 );
6611 assert!(
6612 entries.iter().any(|e| e.contains("Unarchived Thread")),
6613 "expected unarchived thread to appear, got entries: {entries:?}"
6614 );
6615}
6616
6617#[gpui::test]
6618async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
6619 cx: &mut TestAppContext,
6620) {
6621 agent_ui::test_support::init_test(cx);
6622 cx.update(|cx| {
6623 ThreadStore::init_global(cx);
6624 ThreadMetadataStore::init_global(cx);
6625 language_model::LanguageModelRegistry::test(cx);
6626 prompt_store::init(cx);
6627 });
6628
6629 let fs = FakeFs::new(cx.executor());
6630 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6631 .await;
6632 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6633 .await;
6634 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6635
6636 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6637 let (multi_workspace, cx) =
6638 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6639 let sidebar = setup_sidebar(&multi_workspace, cx);
6640 cx.run_until_parked();
6641
6642 let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
6643 let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
6644 let original_thread_id = ThreadId::new();
6645 cx.update(|_, cx| {
6646 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6647 store.save(
6648 ThreadMetadata {
6649 thread_id: original_thread_id,
6650 session_id: Some(session_id.clone()),
6651 agent_id: agent::ZED_AGENT_ID.clone(),
6652 title: Some("Unarchived Thread".into()),
6653 updated_at: Utc::now(),
6654 created_at: None,
6655 interacted_at: None,
6656 worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
6657 archived: true,
6658 remote_connection: None,
6659 },
6660 cx,
6661 )
6662 });
6663 });
6664 cx.run_until_parked();
6665
6666 let metadata = cx.update(|_, cx| {
6667 ThreadMetadataStore::global(cx)
6668 .read(cx)
6669 .entry(original_thread_id)
6670 .cloned()
6671 .expect("metadata should exist before unarchive")
6672 });
6673
6674 sidebar.update_in(cx, |sidebar, window, cx| {
6675 sidebar.open_thread_from_archive(metadata, window, cx);
6676 });
6677
6678 cx.run_until_parked();
6679
6680 assert_eq!(
6681 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6682 2,
6683 "expected unarchive to open the target workspace"
6684 );
6685
6686 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
6687 mw.workspaces()
6688 .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
6689 .cloned()
6690 .expect("expected restored workspace for unarchived thread")
6691 });
6692 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
6693 workspace
6694 .panel::<AgentPanel>(cx)
6695 .expect("expected unarchive to install an agent panel in the new workspace")
6696 });
6697
6698 let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6699 assert_eq!(
6700 restored_thread_id,
6701 Some(original_thread_id),
6702 "expected the new workspace's agent panel to target the restored archived thread id"
6703 );
6704
6705 let session_entries = cx.update(|_, cx| {
6706 ThreadMetadataStore::global(cx)
6707 .read(cx)
6708 .entries()
6709 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
6710 .cloned()
6711 .collect::<Vec<_>>()
6712 });
6713 assert_eq!(
6714 session_entries.len(),
6715 1,
6716 "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
6717 );
6718 assert_eq!(
6719 session_entries[0].thread_id, original_thread_id,
6720 "expected restore into a new workspace to reuse the original thread id"
6721 );
6722 assert!(
6723 !session_entries[0].archived,
6724 "expected restored thread metadata to be unarchived, got: {:?}",
6725 session_entries[0]
6726 );
6727
6728 let mapped_thread_id = cx.update(|_, cx| {
6729 ThreadMetadataStore::global(cx)
6730 .read(cx)
6731 .entries()
6732 .find(|e| e.session_id.as_ref() == Some(&session_id))
6733 .map(|e| e.thread_id)
6734 });
6735 assert_eq!(
6736 mapped_thread_id,
6737 Some(original_thread_id),
6738 "expected session mapping to remain stable after opening the new workspace"
6739 );
6740
6741 let entries = visible_entries_as_strings(&sidebar, cx);
6742 let real_thread_rows = entries
6743 .iter()
6744 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
6745 .filter(|entry| !entry.contains("Draft"))
6746 .count();
6747 assert_eq!(
6748 real_thread_rows, 1,
6749 "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
6750 );
6751 assert!(
6752 entries
6753 .iter()
6754 .any(|entry| entry.contains("Unarchived Thread")),
6755 "expected restored thread row to be visible, got entries: {entries:?}"
6756 );
6757}
6758
6759#[gpui::test]
6760async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
6761 // When a workspace already exists with an empty draft and a thread
6762 // is unarchived into it, the draft should be replaced — not kept
6763 // alongside the loaded thread.
6764 agent_ui::test_support::init_test(cx);
6765 cx.update(|cx| {
6766 ThreadStore::init_global(cx);
6767 ThreadMetadataStore::init_global(cx);
6768 language_model::LanguageModelRegistry::test(cx);
6769 prompt_store::init(cx);
6770 });
6771
6772 let fs = FakeFs::new(cx.executor());
6773 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
6774 .await;
6775 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6776
6777 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
6778 let (multi_workspace, cx) =
6779 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6780 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
6781 cx.run_until_parked();
6782
6783 // Create a thread and send a message so it's no longer a draft.
6784 let connection = acp_thread::StubAgentConnection::new();
6785 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6786 acp::ContentChunk::new("Done".into()),
6787 )]);
6788 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
6789 agent_ui::test_support::send_message(&panel, cx);
6790 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
6791 cx.run_until_parked();
6792
6793 // Archive the thread — the group is left empty (no draft created).
6794 sidebar.update_in(cx, |sidebar, window, cx| {
6795 sidebar.archive_thread(&session_id, window, cx);
6796 });
6797 cx.run_until_parked();
6798
6799 // Un-archive the thread.
6800 let thread_id = cx.update(|_, cx| {
6801 ThreadMetadataStore::global(cx)
6802 .read(cx)
6803 .entries()
6804 .find(|e| e.session_id.as_ref() == Some(&session_id))
6805 .map(|e| e.thread_id)
6806 .expect("thread should exist in store")
6807 });
6808 let metadata = cx.update(|_, cx| {
6809 ThreadMetadataStore::global(cx)
6810 .read(cx)
6811 .entry(thread_id)
6812 .cloned()
6813 .expect("metadata should exist")
6814 });
6815
6816 sidebar.update_in(cx, |sidebar, window, cx| {
6817 sidebar.open_thread_from_archive(metadata, window, cx);
6818 });
6819 cx.run_until_parked();
6820
6821 // The draft should be gone — only the unarchived thread remains.
6822 let entries = visible_entries_as_strings(&sidebar, cx);
6823 let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
6824 assert_eq!(
6825 draft_count, 0,
6826 "expected no drafts after unarchiving, got entries: {entries:?}"
6827 );
6828}
6829
6830#[gpui::test]
6831async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
6832 cx: &mut TestAppContext,
6833) {
6834 agent_ui::test_support::init_test(cx);
6835 cx.update(|cx| {
6836 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6837 ThreadStore::init_global(cx);
6838 ThreadMetadataStore::init_global(cx);
6839 language_model::LanguageModelRegistry::test(cx);
6840 prompt_store::init(cx);
6841 });
6842
6843 let fs = FakeFs::new(cx.executor());
6844 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6845 .await;
6846 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6847 .await;
6848 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6849
6850 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6851 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6852
6853 let (multi_workspace, cx) =
6854 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6855 let sidebar = setup_sidebar(&multi_workspace, cx);
6856
6857 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6858 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6859 mw.test_add_workspace(project_b.clone(), window, cx)
6860 });
6861 let _panel_b = add_agent_panel(&workspace_b, cx);
6862 cx.run_until_parked();
6863
6864 multi_workspace.update_in(cx, |mw, window, cx| {
6865 mw.activate(workspace_a.clone(), None, window, cx);
6866 });
6867 cx.run_until_parked();
6868
6869 let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
6870 let thread_id = ThreadId::new();
6871 cx.update(|_, cx| {
6872 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6873 store.save(
6874 ThreadMetadata {
6875 thread_id,
6876 session_id: Some(session_id.clone()),
6877 agent_id: agent::ZED_AGENT_ID.clone(),
6878 title: Some("Restored In Inactive Workspace".into()),
6879 updated_at: Utc::now(),
6880 created_at: None,
6881 interacted_at: None,
6882 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
6883 PathBuf::from("/project-b"),
6884 ])),
6885 archived: true,
6886 remote_connection: None,
6887 },
6888 cx,
6889 )
6890 });
6891 });
6892 cx.run_until_parked();
6893
6894 let metadata = cx.update(|_, cx| {
6895 ThreadMetadataStore::global(cx)
6896 .read(cx)
6897 .entry(thread_id)
6898 .cloned()
6899 .expect("archived metadata should exist before restore")
6900 });
6901
6902 sidebar.update_in(cx, |sidebar, window, cx| {
6903 sidebar.open_thread_from_archive(metadata, window, cx);
6904 });
6905
6906 let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
6907 workspace.panel::<AgentPanel>(cx).expect(
6908 "target workspace should still have an agent panel immediately after activation",
6909 )
6910 });
6911 let immediate_active_thread_id =
6912 panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
6913
6914 cx.run_until_parked();
6915
6916 sidebar.read_with(cx, |sidebar, _cx| {
6917 assert_active_thread(
6918 sidebar,
6919 &session_id,
6920 "unarchiving into an inactive existing workspace should end on the restored thread",
6921 );
6922 });
6923
6924 let panel_b = workspace_b.read_with(cx, |workspace, cx| {
6925 workspace
6926 .panel::<AgentPanel>(cx)
6927 .expect("target workspace should still have an agent panel")
6928 });
6929 assert_eq!(
6930 panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
6931 Some(thread_id),
6932 "expected target panel to activate the restored thread id"
6933 );
6934 assert!(
6935 immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
6936 "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
6937 );
6938
6939 let entries = visible_entries_as_strings(&sidebar, cx);
6940 let target_rows: Vec<_> = entries
6941 .iter()
6942 .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
6943 .cloned()
6944 .collect();
6945 assert_eq!(
6946 target_rows.len(),
6947 1,
6948 "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
6949 );
6950 assert!(
6951 target_rows[0].contains("Restored In Inactive Workspace"),
6952 "expected the remaining row to be the restored thread, got entries: {entries:?}"
6953 );
6954 assert!(
6955 !target_rows[0].contains("Draft"),
6956 "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
6957 );
6958}
6959
6960#[gpui::test]
6961async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
6962 cx: &mut TestAppContext,
6963) {
6964 agent_ui::test_support::init_test(cx);
6965 cx.update(|cx| {
6966 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
6967 ThreadStore::init_global(cx);
6968 ThreadMetadataStore::init_global(cx);
6969 language_model::LanguageModelRegistry::test(cx);
6970 prompt_store::init(cx);
6971 });
6972
6973 let fs = FakeFs::new(cx.executor());
6974 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6975 .await;
6976 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6977 .await;
6978 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6979
6980 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6981 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6982
6983 let (multi_workspace, cx) =
6984 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6985 let sidebar = setup_sidebar(&multi_workspace, cx);
6986
6987 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
6988 mw.test_add_workspace(project_b.clone(), window, cx)
6989 });
6990 let panel_b = add_agent_panel(&workspace_b, cx);
6991 cx.run_until_parked();
6992
6993 let connection = acp_thread::StubAgentConnection::new();
6994 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6995 acp::ContentChunk::new("Done".into()),
6996 )]);
6997 agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
6998 agent_ui::test_support::send_message(&panel_b, cx);
6999 let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
7000 save_test_thread_metadata(&session_id, &project_b, cx).await;
7001 cx.run_until_parked();
7002
7003 sidebar.update_in(cx, |sidebar, window, cx| {
7004 sidebar.archive_thread(&session_id, window, cx);
7005 });
7006
7007 cx.run_until_parked();
7008
7009 let archived_metadata = cx.update(|_, cx| {
7010 let store = ThreadMetadataStore::global(cx).read(cx);
7011 let thread_id = store
7012 .entries()
7013 .find(|e| e.session_id.as_ref() == Some(&session_id))
7014 .map(|e| e.thread_id)
7015 .expect("archived thread should still exist in metadata store");
7016 let metadata = store
7017 .entry(thread_id)
7018 .cloned()
7019 .expect("archived metadata should still exist after archive");
7020 assert!(
7021 metadata.archived,
7022 "thread should be archived before project removal"
7023 );
7024 metadata
7025 });
7026
7027 let group_key_b =
7028 project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
7029 let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
7030 mw.remove_project_group(&group_key_b, window, cx)
7031 });
7032 remove_task
7033 .await
7034 .expect("remove project group task should complete");
7035 cx.run_until_parked();
7036
7037 assert_eq!(
7038 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7039 1,
7040 "removing the archived thread's parent project group should remove its workspace"
7041 );
7042
7043 sidebar.update_in(cx, |sidebar, window, cx| {
7044 sidebar.open_thread_from_archive(archived_metadata.clone(), window, cx);
7045 });
7046 cx.run_until_parked();
7047
7048 let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
7049 mw.workspaces()
7050 .find(|workspace| {
7051 PathList::new(&workspace.read(cx).root_paths(cx))
7052 == PathList::new(&[PathBuf::from("/project-b")])
7053 })
7054 .cloned()
7055 .expect("expected unarchive to recreate the removed project workspace")
7056 });
7057 let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
7058 workspace
7059 .panel::<AgentPanel>(cx)
7060 .expect("expected restored workspace to bootstrap an agent panel")
7061 });
7062
7063 let restored_thread_id = cx.update(|_, cx| {
7064 ThreadMetadataStore::global(cx)
7065 .read(cx)
7066 .entries()
7067 .find(|e| e.session_id.as_ref() == Some(&session_id))
7068 .map(|e| e.thread_id)
7069 .expect("session should still map to restored thread id")
7070 });
7071 assert_eq!(
7072 restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
7073 Some(restored_thread_id),
7074 "expected unarchive after project removal to activate the restored real thread"
7075 );
7076
7077 sidebar.read_with(cx, |sidebar, _cx| {
7078 assert_active_thread(
7079 sidebar,
7080 &session_id,
7081 "expected sidebar active entry to track the restored thread after project removal",
7082 );
7083 });
7084
7085 let entries = visible_entries_as_strings(&sidebar, cx);
7086 let restored_title = archived_metadata.display_title().to_string();
7087 let matching_rows: Vec<_> = entries
7088 .iter()
7089 .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
7090 .cloned()
7091 .collect();
7092 assert_eq!(
7093 matching_rows.len(),
7094 1,
7095 "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
7096 );
7097 assert!(
7098 !matching_rows[0].contains("Draft"),
7099 "expected no draft row after unarchive following project removal, got entries: {entries:?}"
7100 );
7101}
7102
7103#[gpui::test]
7104async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
7105 agent_ui::test_support::init_test(cx);
7106 cx.update(|cx| {
7107 ThreadStore::init_global(cx);
7108 ThreadMetadataStore::init_global(cx);
7109 language_model::LanguageModelRegistry::test(cx);
7110 prompt_store::init(cx);
7111 });
7112
7113 let fs = FakeFs::new(cx.executor());
7114 fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
7115 .await;
7116 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7117
7118 let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
7119 let (multi_workspace, cx) =
7120 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7121 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
7122 cx.run_until_parked();
7123
7124 let connection = acp_thread::StubAgentConnection::new();
7125 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7126 acp::ContentChunk::new("Done".into()),
7127 )]);
7128 agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
7129 agent_ui::test_support::send_message(&panel, cx);
7130 let session_id = agent_ui::test_support::active_session_id(&panel, cx);
7131 cx.run_until_parked();
7132
7133 let original_thread_id = cx.update(|_, cx| {
7134 ThreadMetadataStore::global(cx)
7135 .read(cx)
7136 .entries()
7137 .find(|e| e.session_id.as_ref() == Some(&session_id))
7138 .map(|e| e.thread_id)
7139 .expect("thread should exist in store before archiving")
7140 });
7141
7142 sidebar.update_in(cx, |sidebar, window, cx| {
7143 sidebar.archive_thread(&session_id, window, cx);
7144 });
7145 cx.run_until_parked();
7146
7147 let metadata = cx.update(|_, cx| {
7148 ThreadMetadataStore::global(cx)
7149 .read(cx)
7150 .entry(original_thread_id)
7151 .cloned()
7152 .expect("metadata should exist after archiving")
7153 });
7154
7155 sidebar.update_in(cx, |sidebar, window, cx| {
7156 sidebar.open_thread_from_archive(metadata, window, cx);
7157 });
7158 cx.run_until_parked();
7159
7160 let session_entries = cx.update(|_, cx| {
7161 ThreadMetadataStore::global(cx)
7162 .read(cx)
7163 .entries()
7164 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7165 .cloned()
7166 .collect::<Vec<_>>()
7167 });
7168
7169 assert_eq!(
7170 session_entries.len(),
7171 1,
7172 "expected exactly one metadata row for the restored session, got: {session_entries:?}"
7173 );
7174 assert_eq!(
7175 session_entries[0].thread_id, original_thread_id,
7176 "expected unarchive to reuse the original thread id instead of creating a duplicate row"
7177 );
7178 assert!(
7179 session_entries[0].session_id.is_some(),
7180 "expected restored metadata to be a real thread, got: {:?}",
7181 session_entries[0]
7182 );
7183
7184 let entries = visible_entries_as_strings(&sidebar, cx);
7185 let real_thread_rows = entries
7186 .iter()
7187 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7188 .filter(|entry| !entry.contains("Draft"))
7189 .count();
7190 assert_eq!(
7191 real_thread_rows, 1,
7192 "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
7193 );
7194 assert!(
7195 !entries.iter().any(|entry| entry.contains("Draft")),
7196 "expected no draft rows after restoring, got entries: {entries:?}"
7197 );
7198}
7199
7200#[gpui::test]
7201async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
7202 cx: &mut TestAppContext,
7203) {
7204 // When a thread is archived while the user is in a different workspace,
7205 // clear_base_view creates a draft on the archived workspace's panel.
7206 // Switching back to that workspace shows the draft as active_entry.
7207 agent_ui::test_support::init_test(cx);
7208 cx.update(|cx| {
7209 ThreadStore::init_global(cx);
7210 ThreadMetadataStore::init_global(cx);
7211 language_model::LanguageModelRegistry::test(cx);
7212 prompt_store::init(cx);
7213 });
7214
7215 let fs = FakeFs::new(cx.executor());
7216 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
7217 .await;
7218 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
7219 .await;
7220 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7221
7222 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
7223 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
7224
7225 let (multi_workspace, cx) =
7226 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
7227 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
7228
7229 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
7230 mw.test_add_workspace(project_b.clone(), window, cx)
7231 });
7232 let _panel_b = add_agent_panel(&workspace_b, cx);
7233 cx.run_until_parked();
7234
7235 // Create a thread in project-a's panel (currently non-active).
7236 let connection = acp_thread::StubAgentConnection::new();
7237 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7238 acp::ContentChunk::new("Done".into()),
7239 )]);
7240 agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
7241 agent_ui::test_support::send_message(&panel_a, cx);
7242 let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
7243 cx.run_until_parked();
7244
7245 // Archive it while project-b is active.
7246 sidebar.update_in(cx, |sidebar, window, cx| {
7247 sidebar.archive_thread(&thread_a, window, cx);
7248 });
7249 cx.run_until_parked();
7250
7251 // Switch back to project-a. Its panel was cleared during archiving
7252 // (clear_base_view activated a draft), so active_entry should point
7253 // to the draft on workspace_a.
7254 let workspace_a =
7255 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7256 multi_workspace.update_in(cx, |mw, window, cx| {
7257 mw.activate(workspace_a.clone(), None, window, cx);
7258 });
7259 cx.run_until_parked();
7260
7261 sidebar.update_in(cx, |sidebar, _window, cx| {
7262 sidebar.update_entries(cx);
7263 });
7264 cx.run_until_parked();
7265
7266 sidebar.read_with(cx, |sidebar, _| {
7267 assert_active_draft(
7268 sidebar,
7269 &workspace_a,
7270 "after switching to workspace with archived thread, active_entry should be the draft",
7271 );
7272 });
7273}
7274
7275#[gpui::test]
7276async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
7277 let project = init_test_project("/my-project", cx).await;
7278 let (multi_workspace, cx) =
7279 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7280 let sidebar = setup_sidebar(&multi_workspace, cx);
7281
7282 save_thread_metadata(
7283 acp::SessionId::new(Arc::from("visible-thread")),
7284 Some("Visible Thread".into()),
7285 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7286 None,
7287 None,
7288 &project,
7289 cx,
7290 );
7291
7292 let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
7293 save_thread_metadata(
7294 archived_thread_session_id.clone(),
7295 Some("Archived Thread".into()),
7296 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7297 None,
7298 None,
7299 &project,
7300 cx,
7301 );
7302
7303 cx.update(|_, cx| {
7304 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7305 let thread_id = store
7306 .entries()
7307 .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
7308 .map(|e| e.thread_id)
7309 .unwrap();
7310 store.archive(thread_id, None, cx)
7311 })
7312 });
7313 cx.run_until_parked();
7314
7315 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7316 cx.run_until_parked();
7317
7318 let entries = visible_entries_as_strings(&sidebar, cx);
7319 assert!(
7320 entries.iter().any(|e| e.contains("Visible Thread")),
7321 "expected visible thread in sidebar, got: {entries:?}"
7322 );
7323 assert!(
7324 !entries.iter().any(|e| e.contains("Archived Thread")),
7325 "expected archived thread to be hidden from sidebar, got: {entries:?}"
7326 );
7327
7328 cx.update(|_, cx| {
7329 let store = ThreadMetadataStore::global(cx);
7330 let all: Vec<_> = store.read(cx).entries().collect();
7331 assert_eq!(
7332 all.len(),
7333 2,
7334 "expected 2 total entries in the store, got: {}",
7335 all.len()
7336 );
7337
7338 let archived: Vec<_> = store.read(cx).archived_entries().collect();
7339 assert_eq!(archived.len(), 1);
7340 assert_eq!(
7341 archived[0].session_id.as_ref().unwrap().0.as_ref(),
7342 "archived-thread"
7343 );
7344 });
7345}
7346
7347#[gpui::test]
7348async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
7349 cx: &mut TestAppContext,
7350) {
7351 // When a linked worktree has a single thread and that thread is archived,
7352 // the sidebar must NOT create a new thread on the same worktree (which
7353 // would prevent the worktree from being cleaned up on disk). Instead,
7354 // archive_thread switches to a sibling thread on the main workspace (or
7355 // creates a draft there) before archiving the metadata.
7356 agent_ui::test_support::init_test(cx);
7357 cx.update(|cx| {
7358 ThreadStore::init_global(cx);
7359 ThreadMetadataStore::init_global(cx);
7360 language_model::LanguageModelRegistry::test(cx);
7361 prompt_store::init(cx);
7362 });
7363
7364 let fs = FakeFs::new(cx.executor());
7365
7366 fs.insert_tree(
7367 "/project",
7368 serde_json::json!({
7369 ".git": {},
7370 "src": {},
7371 }),
7372 )
7373 .await;
7374
7375 fs.add_linked_worktree_for_repo(
7376 Path::new("/project/.git"),
7377 false,
7378 git::repository::Worktree {
7379 path: std::path::PathBuf::from("/wt-ochre-drift"),
7380 ref_name: Some("refs/heads/ochre-drift".into()),
7381 sha: "aaa".into(),
7382 is_main: false,
7383 is_bare: false,
7384 },
7385 )
7386 .await;
7387
7388 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7389
7390 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7391 let worktree_project =
7392 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7393
7394 main_project
7395 .update(cx, |p, cx| p.git_scans_complete(cx))
7396 .await;
7397 worktree_project
7398 .update(cx, |p, cx| p.git_scans_complete(cx))
7399 .await;
7400
7401 let (multi_workspace, cx) =
7402 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7403
7404 let sidebar = setup_sidebar(&multi_workspace, cx);
7405
7406 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7407 mw.test_add_workspace(worktree_project.clone(), window, cx)
7408 });
7409
7410 // Set up both workspaces with agent panels.
7411 let main_workspace =
7412 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7413 let _main_panel = add_agent_panel(&main_workspace, cx);
7414 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7415
7416 // Activate the linked worktree workspace so the sidebar tracks it.
7417 multi_workspace.update_in(cx, |mw, window, cx| {
7418 mw.activate(worktree_workspace.clone(), None, window, cx);
7419 });
7420
7421 // Open a thread in the linked worktree panel and send a message
7422 // so it becomes the active thread.
7423 let connection = StubAgentConnection::new();
7424 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7425 send_message(&worktree_panel, cx);
7426
7427 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7428
7429 // Give the thread a response chunk so it has content.
7430 cx.update(|_, cx| {
7431 connection.send_update(
7432 worktree_thread_id.clone(),
7433 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7434 cx,
7435 );
7436 });
7437
7438 // Save the worktree thread's metadata.
7439 save_thread_metadata(
7440 worktree_thread_id.clone(),
7441 Some("Ochre Drift Thread".into()),
7442 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7443 None,
7444 None,
7445 &worktree_project,
7446 cx,
7447 );
7448
7449 // Also save a thread on the main project so there's a sibling in the
7450 // group that can be selected after archiving.
7451 save_thread_metadata(
7452 acp::SessionId::new(Arc::from("main-project-thread")),
7453 Some("Main Project Thread".into()),
7454 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7455 None,
7456 None,
7457 &main_project,
7458 cx,
7459 );
7460
7461 cx.run_until_parked();
7462
7463 // Verify the linked worktree thread appears with its chip.
7464 // The live thread title comes from the message text ("Hello"), not
7465 // the metadata title we saved.
7466 let entries_before = visible_entries_as_strings(&sidebar, cx);
7467 assert!(
7468 entries_before
7469 .iter()
7470 .any(|s| s.contains("{wt-ochre-drift}")),
7471 "expected worktree thread with chip before archiving, got: {entries_before:?}"
7472 );
7473 assert!(
7474 entries_before
7475 .iter()
7476 .any(|s| s.contains("Main Project Thread")),
7477 "expected main project thread before archiving, got: {entries_before:?}"
7478 );
7479
7480 // Confirm the worktree thread is the active entry.
7481 sidebar.read_with(cx, |s, _| {
7482 assert_active_thread(
7483 s,
7484 &worktree_thread_id,
7485 "worktree thread should be active before archiving",
7486 );
7487 });
7488
7489 // Archive the worktree thread — it's the only thread using ochre-drift.
7490 sidebar.update_in(cx, |sidebar, window, cx| {
7491 sidebar.archive_thread(&worktree_thread_id, window, cx);
7492 });
7493
7494 cx.run_until_parked();
7495
7496 // The archived thread should no longer appear in the sidebar.
7497 let entries_after = visible_entries_as_strings(&sidebar, cx);
7498 assert!(
7499 !entries_after
7500 .iter()
7501 .any(|s| s.contains("Ochre Drift Thread")),
7502 "archived thread should be hidden, got: {entries_after:?}"
7503 );
7504
7505 // No "+ New Thread" entry should appear with the ochre-drift worktree
7506 // chip — that would keep the worktree alive and prevent cleanup.
7507 assert!(
7508 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7509 "no entry should reference the archived worktree, got: {entries_after:?}"
7510 );
7511
7512 // The main project thread should still be visible.
7513 assert!(
7514 entries_after
7515 .iter()
7516 .any(|s| s.contains("Main Project Thread")),
7517 "main project thread should still be visible, got: {entries_after:?}"
7518 );
7519}
7520
7521#[gpui::test]
7522async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
7523 cx: &mut TestAppContext,
7524) {
7525 // When a linked worktree thread is the ONLY thread in the project group
7526 // (no threads on the main repo either), archiving it should leave the
7527 // group empty with no active entry.
7528 agent_ui::test_support::init_test(cx);
7529 cx.update(|cx| {
7530 ThreadStore::init_global(cx);
7531 ThreadMetadataStore::init_global(cx);
7532 language_model::LanguageModelRegistry::test(cx);
7533 prompt_store::init(cx);
7534 });
7535
7536 let fs = FakeFs::new(cx.executor());
7537
7538 fs.insert_tree(
7539 "/project",
7540 serde_json::json!({
7541 ".git": {},
7542 "src": {},
7543 }),
7544 )
7545 .await;
7546
7547 fs.add_linked_worktree_for_repo(
7548 Path::new("/project/.git"),
7549 false,
7550 git::repository::Worktree {
7551 path: std::path::PathBuf::from("/wt-ochre-drift"),
7552 ref_name: Some("refs/heads/ochre-drift".into()),
7553 sha: "aaa".into(),
7554 is_main: false,
7555 is_bare: false,
7556 },
7557 )
7558 .await;
7559
7560 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7561
7562 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7563 let worktree_project =
7564 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7565
7566 main_project
7567 .update(cx, |p, cx| p.git_scans_complete(cx))
7568 .await;
7569 worktree_project
7570 .update(cx, |p, cx| p.git_scans_complete(cx))
7571 .await;
7572
7573 let (multi_workspace, cx) =
7574 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7575
7576 let sidebar = setup_sidebar(&multi_workspace, cx);
7577
7578 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7579 mw.test_add_workspace(worktree_project.clone(), window, cx)
7580 });
7581
7582 let main_workspace =
7583 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7584 let _main_panel = add_agent_panel(&main_workspace, cx);
7585 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7586
7587 // Activate the linked worktree workspace.
7588 multi_workspace.update_in(cx, |mw, window, cx| {
7589 mw.activate(worktree_workspace.clone(), None, window, cx);
7590 });
7591
7592 // Open a thread on the linked worktree — this is the ONLY thread.
7593 let connection = StubAgentConnection::new();
7594 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7595 send_message(&worktree_panel, cx);
7596
7597 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7598
7599 cx.update(|_, cx| {
7600 connection.send_update(
7601 worktree_thread_id.clone(),
7602 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7603 cx,
7604 );
7605 });
7606
7607 save_thread_metadata(
7608 worktree_thread_id.clone(),
7609 Some("Ochre Drift Thread".into()),
7610 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7611 None,
7612 None,
7613 &worktree_project,
7614 cx,
7615 );
7616
7617 cx.run_until_parked();
7618
7619 // Archive it — there are no other threads in the group.
7620 sidebar.update_in(cx, |sidebar, window, cx| {
7621 sidebar.archive_thread(&worktree_thread_id, window, cx);
7622 });
7623
7624 cx.run_until_parked();
7625
7626 let entries_after = visible_entries_as_strings(&sidebar, cx);
7627
7628 // No entry should reference the linked worktree.
7629 assert!(
7630 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7631 "no entry should reference the archived worktree, got: {entries_after:?}"
7632 );
7633
7634 // The active entry should be None — no draft is created.
7635 sidebar.read_with(cx, |s, _| {
7636 assert!(
7637 s.active_entry.is_none(),
7638 "expected no active entry after archiving the last thread, got: {:?}",
7639 s.active_entry,
7640 );
7641 });
7642}
7643
7644#[gpui::test]
7645async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
7646 cx: &mut TestAppContext,
7647) {
7648 // When an archived thread belongs to a linked worktree whose main repo is
7649 // already open, unarchiving should reopen the linked workspace into the
7650 // same project group and show only the restored real thread row.
7651 agent_ui::test_support::init_test(cx);
7652 cx.update(|cx| {
7653 ThreadStore::init_global(cx);
7654 ThreadMetadataStore::init_global(cx);
7655 language_model::LanguageModelRegistry::test(cx);
7656 prompt_store::init(cx);
7657 });
7658
7659 let fs = FakeFs::new(cx.executor());
7660
7661 fs.insert_tree(
7662 "/project",
7663 serde_json::json!({
7664 ".git": {},
7665 "src": {},
7666 }),
7667 )
7668 .await;
7669
7670 fs.insert_tree(
7671 "/wt-ochre-drift",
7672 serde_json::json!({
7673 ".git": "gitdir: /project/.git/worktrees/ochre-drift",
7674 "src": {},
7675 }),
7676 )
7677 .await;
7678
7679 fs.add_linked_worktree_for_repo(
7680 Path::new("/project/.git"),
7681 false,
7682 git::repository::Worktree {
7683 path: std::path::PathBuf::from("/wt-ochre-drift"),
7684 ref_name: Some("refs/heads/ochre-drift".into()),
7685 sha: "aaa".into(),
7686 is_main: false,
7687 is_bare: false,
7688 },
7689 )
7690 .await;
7691
7692 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7693
7694 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7695 let worktree_project =
7696 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7697
7698 main_project
7699 .update(cx, |p, cx| p.git_scans_complete(cx))
7700 .await;
7701 worktree_project
7702 .update(cx, |p, cx| p.git_scans_complete(cx))
7703 .await;
7704
7705 let (multi_workspace, cx) =
7706 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7707
7708 let sidebar = setup_sidebar(&multi_workspace, cx);
7709 let main_workspace =
7710 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7711 let _main_panel = add_agent_panel(&main_workspace, cx);
7712 cx.run_until_parked();
7713
7714 let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
7715 let original_thread_id = ThreadId::new();
7716 let main_paths = PathList::new(&[PathBuf::from("/project")]);
7717 let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
7718
7719 cx.update(|_, cx| {
7720 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7721 store.save(
7722 ThreadMetadata {
7723 thread_id: original_thread_id,
7724 session_id: Some(session_id.clone()),
7725 agent_id: agent::ZED_AGENT_ID.clone(),
7726 title: Some("Unarchived Linked Thread".into()),
7727 updated_at: Utc::now(),
7728 created_at: None,
7729 interacted_at: None,
7730 worktree_paths: WorktreePaths::from_path_lists(
7731 main_paths.clone(),
7732 folder_paths.clone(),
7733 )
7734 .expect("main and folder paths should be well-formed"),
7735 archived: true,
7736 remote_connection: None,
7737 },
7738 cx,
7739 )
7740 });
7741 });
7742 cx.run_until_parked();
7743
7744 let metadata = cx.update(|_, cx| {
7745 ThreadMetadataStore::global(cx)
7746 .read(cx)
7747 .entry(original_thread_id)
7748 .cloned()
7749 .expect("archived linked-worktree metadata should exist before restore")
7750 });
7751
7752 sidebar.update_in(cx, |sidebar, window, cx| {
7753 sidebar.open_thread_from_archive(metadata, window, cx);
7754 });
7755 cx.run_until_parked();
7756
7757 assert_eq!(
7758 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7759 2,
7760 "expected unarchive to open the linked worktree workspace into the project group"
7761 );
7762
7763 let session_entries = cx.update(|_, cx| {
7764 ThreadMetadataStore::global(cx)
7765 .read(cx)
7766 .entries()
7767 .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
7768 .cloned()
7769 .collect::<Vec<_>>()
7770 });
7771 assert_eq!(
7772 session_entries.len(),
7773 1,
7774 "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
7775 );
7776 assert_eq!(
7777 session_entries[0].thread_id, original_thread_id,
7778 "expected unarchive to reuse the original linked worktree thread id"
7779 );
7780 assert!(
7781 !session_entries[0].archived,
7782 "expected restored linked worktree metadata to be unarchived, got: {:?}",
7783 session_entries[0]
7784 );
7785
7786 let assert_no_extra_rows = |entries: &[String]| {
7787 let real_thread_rows = entries
7788 .iter()
7789 .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
7790 .filter(|entry| !entry.contains("Draft"))
7791 .count();
7792 assert_eq!(
7793 real_thread_rows, 1,
7794 "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
7795 );
7796 assert!(
7797 !entries.iter().any(|entry| entry.contains("Draft")),
7798 "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
7799 );
7800 assert!(
7801 !entries
7802 .iter()
7803 .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
7804 "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
7805 );
7806 assert!(
7807 entries
7808 .iter()
7809 .any(|entry| entry.contains("Unarchived Linked Thread")),
7810 "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
7811 );
7812 };
7813
7814 let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
7815 assert_no_extra_rows(&entries_after_restore);
7816
7817 // The reported bug may only appear after an extra scheduling turn.
7818 cx.run_until_parked();
7819
7820 let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
7821 assert_no_extra_rows(&entries_after_extra_turns);
7822}
7823
7824#[gpui::test]
7825async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
7826 // When a linked worktree thread is archived but the group has other
7827 // threads (e.g. on the main project), archive_thread should select
7828 // the nearest sibling.
7829 agent_ui::test_support::init_test(cx);
7830 cx.update(|cx| {
7831 ThreadStore::init_global(cx);
7832 ThreadMetadataStore::init_global(cx);
7833 language_model::LanguageModelRegistry::test(cx);
7834 prompt_store::init(cx);
7835 });
7836
7837 let fs = FakeFs::new(cx.executor());
7838
7839 fs.insert_tree(
7840 "/project",
7841 serde_json::json!({
7842 ".git": {},
7843 "src": {},
7844 }),
7845 )
7846 .await;
7847
7848 fs.add_linked_worktree_for_repo(
7849 Path::new("/project/.git"),
7850 false,
7851 git::repository::Worktree {
7852 path: std::path::PathBuf::from("/wt-ochre-drift"),
7853 ref_name: Some("refs/heads/ochre-drift".into()),
7854 sha: "aaa".into(),
7855 is_main: false,
7856 is_bare: false,
7857 },
7858 )
7859 .await;
7860
7861 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7862
7863 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7864 let worktree_project =
7865 project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
7866
7867 main_project
7868 .update(cx, |p, cx| p.git_scans_complete(cx))
7869 .await;
7870 worktree_project
7871 .update(cx, |p, cx| p.git_scans_complete(cx))
7872 .await;
7873
7874 let (multi_workspace, cx) =
7875 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
7876
7877 let sidebar = setup_sidebar(&multi_workspace, cx);
7878
7879 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7880 mw.test_add_workspace(worktree_project.clone(), window, cx)
7881 });
7882
7883 let main_workspace =
7884 multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
7885 let _main_panel = add_agent_panel(&main_workspace, cx);
7886 let worktree_panel = add_agent_panel(&worktree_workspace, cx);
7887
7888 // Activate the linked worktree workspace.
7889 multi_workspace.update_in(cx, |mw, window, cx| {
7890 mw.activate(worktree_workspace.clone(), None, window, cx);
7891 });
7892
7893 // Open a thread on the linked worktree.
7894 let connection = StubAgentConnection::new();
7895 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
7896 send_message(&worktree_panel, cx);
7897
7898 let worktree_thread_id = active_session_id(&worktree_panel, cx);
7899
7900 cx.update(|_, cx| {
7901 connection.send_update(
7902 worktree_thread_id.clone(),
7903 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
7904 cx,
7905 );
7906 });
7907
7908 save_thread_metadata(
7909 worktree_thread_id.clone(),
7910 Some("Ochre Drift Thread".into()),
7911 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7912 None,
7913 None,
7914 &worktree_project,
7915 cx,
7916 );
7917
7918 // Save a sibling thread on the main project.
7919 let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
7920 save_thread_metadata(
7921 main_thread_id,
7922 Some("Main Project Thread".into()),
7923 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7924 None,
7925 None,
7926 &main_project,
7927 cx,
7928 );
7929
7930 cx.run_until_parked();
7931
7932 // Confirm the worktree thread is active.
7933 sidebar.read_with(cx, |s, _| {
7934 assert_active_thread(
7935 s,
7936 &worktree_thread_id,
7937 "worktree thread should be active before archiving",
7938 );
7939 });
7940
7941 // Archive the worktree thread.
7942 sidebar.update_in(cx, |sidebar, window, cx| {
7943 sidebar.archive_thread(&worktree_thread_id, window, cx);
7944 });
7945
7946 cx.run_until_parked();
7947
7948 // The worktree workspace was removed and a draft was created on the
7949 // main workspace. No entry should reference the linked worktree.
7950 let entries_after = visible_entries_as_strings(&sidebar, cx);
7951 assert!(
7952 !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
7953 "no entry should reference the archived worktree, got: {entries_after:?}"
7954 );
7955
7956 // The main project thread should still be visible.
7957 assert!(
7958 entries_after
7959 .iter()
7960 .any(|s| s.contains("Main Project Thread")),
7961 "main project thread should still be visible, got: {entries_after:?}"
7962 );
7963}
7964
7965// TODO: Restore this test once linked worktree draft entries are re-implemented.
7966// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
7967#[gpui::test]
7968#[ignore = "linked worktree draft entries not yet implemented"]
7969async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
7970 init_test(cx);
7971 let fs = FakeFs::new(cx.executor());
7972
7973 fs.insert_tree(
7974 "/project",
7975 serde_json::json!({
7976 ".git": {
7977 "worktrees": {
7978 "feature-a": {
7979 "commondir": "../../",
7980 "HEAD": "ref: refs/heads/feature-a",
7981 },
7982 },
7983 },
7984 "src": {},
7985 }),
7986 )
7987 .await;
7988
7989 fs.insert_tree(
7990 "/wt-feature-a",
7991 serde_json::json!({
7992 ".git": "gitdir: /project/.git/worktrees/feature-a",
7993 "src": {},
7994 }),
7995 )
7996 .await;
7997
7998 fs.add_linked_worktree_for_repo(
7999 Path::new("/project/.git"),
8000 false,
8001 git::repository::Worktree {
8002 path: PathBuf::from("/wt-feature-a"),
8003 ref_name: Some("refs/heads/feature-a".into()),
8004 sha: "aaa".into(),
8005 is_main: false,
8006 is_bare: false,
8007 },
8008 )
8009 .await;
8010
8011 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8012
8013 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
8014 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
8015
8016 main_project
8017 .update(cx, |p, cx| p.git_scans_complete(cx))
8018 .await;
8019 worktree_project
8020 .update(cx, |p, cx| p.git_scans_complete(cx))
8021 .await;
8022
8023 let (multi_workspace, cx) =
8024 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
8025 let sidebar = setup_sidebar(&multi_workspace, cx);
8026
8027 // Open the linked worktree as a separate workspace (simulates cmd-o).
8028 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8029 mw.test_add_workspace(worktree_project.clone(), window, cx)
8030 });
8031 add_agent_panel(&worktree_workspace, cx);
8032 cx.run_until_parked();
8033
8034 // Explicitly create a draft thread from the linked worktree workspace.
8035 // Auto-created drafts use the group's first workspace (the main one),
8036 // so a user-created draft is needed to make the linked worktree reachable.
8037 sidebar.update_in(cx, |sidebar, window, cx| {
8038 sidebar.create_new_thread(&worktree_workspace, window, cx);
8039 });
8040 cx.run_until_parked();
8041
8042 // Switch back to the main workspace.
8043 multi_workspace.update_in(cx, |mw, window, cx| {
8044 let main_ws = mw.workspaces().next().unwrap().clone();
8045 mw.activate(main_ws, None, window, cx);
8046 });
8047 cx.run_until_parked();
8048
8049 sidebar.update_in(cx, |sidebar, _window, cx| {
8050 sidebar.update_entries(cx);
8051 });
8052 cx.run_until_parked();
8053
8054 // The linked worktree workspace must be reachable from some sidebar entry.
8055 let worktree_ws_id = worktree_workspace.entity_id();
8056 let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
8057 let mw = multi_workspace.read(cx);
8058 sidebar
8059 .contents
8060 .entries
8061 .iter()
8062 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
8063 .map(|ws| ws.entity_id())
8064 .collect()
8065 });
8066 assert!(
8067 reachable.contains(&worktree_ws_id),
8068 "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
8069 );
8070
8071 // Find the draft Thread entry whose workspace is the linked worktree.
8072 let _ = (worktree_ws_id, sidebar, multi_workspace);
8073 // todo("re-implement once linked worktree draft entries exist");
8074}
8075
8076#[gpui::test]
8077async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
8078 // When only a linked worktree workspace is open (not the main repo),
8079 // threads saved against the main repo should still appear in the sidebar.
8080 init_test(cx);
8081 let fs = FakeFs::new(cx.executor());
8082
8083 // Create the main repo with a linked worktree.
8084 fs.insert_tree(
8085 "/project",
8086 serde_json::json!({
8087 ".git": {
8088 "worktrees": {
8089 "feature-a": {
8090 "commondir": "../../",
8091 "HEAD": "ref: refs/heads/feature-a",
8092 },
8093 },
8094 },
8095 "src": {},
8096 }),
8097 )
8098 .await;
8099
8100 fs.insert_tree(
8101 "/wt-feature-a",
8102 serde_json::json!({
8103 ".git": "gitdir: /project/.git/worktrees/feature-a",
8104 "src": {},
8105 }),
8106 )
8107 .await;
8108
8109 fs.add_linked_worktree_for_repo(
8110 std::path::Path::new("/project/.git"),
8111 false,
8112 git::repository::Worktree {
8113 path: std::path::PathBuf::from("/wt-feature-a"),
8114 ref_name: Some("refs/heads/feature-a".into()),
8115 sha: "abc".into(),
8116 is_main: false,
8117 is_bare: false,
8118 },
8119 )
8120 .await;
8121
8122 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8123
8124 // Only open the linked worktree as a workspace — NOT the main repo.
8125 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
8126 worktree_project
8127 .update(cx, |p, cx| p.git_scans_complete(cx))
8128 .await;
8129
8130 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
8131 main_project
8132 .update(cx, |p, cx| p.git_scans_complete(cx))
8133 .await;
8134
8135 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
8136 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
8137 });
8138 let sidebar = setup_sidebar(&multi_workspace, cx);
8139
8140 // Save a thread against the MAIN repo path.
8141 save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
8142
8143 // Save a thread against the linked worktree path.
8144 save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
8145
8146 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
8147 cx.run_until_parked();
8148
8149 // Both threads should be visible: the worktree thread by direct lookup,
8150 // and the main repo thread because the workspace is a linked worktree
8151 // and we also query the main repo path.
8152 let entries = visible_entries_as_strings(&sidebar, cx);
8153 assert!(
8154 entries.iter().any(|e| e.contains("Main Repo Thread")),
8155 "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
8156 );
8157 assert!(
8158 entries.iter().any(|e| e.contains("Worktree Thread")),
8159 "expected worktree thread to be visible, got: {entries:?}"
8160 );
8161}
8162
8163async fn init_multi_project_test(
8164 paths: &[&str],
8165 cx: &mut TestAppContext,
8166) -> (Arc<FakeFs>, Entity<project::Project>) {
8167 agent_ui::test_support::init_test(cx);
8168 cx.update(|cx| {
8169 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
8170 ThreadStore::init_global(cx);
8171 ThreadMetadataStore::init_global(cx);
8172 language_model::LanguageModelRegistry::test(cx);
8173 prompt_store::init(cx);
8174 });
8175 let fs = FakeFs::new(cx.executor());
8176 for path in paths {
8177 fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
8178 .await;
8179 }
8180 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8181 let project =
8182 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
8183 (fs, project)
8184}
8185
8186async fn add_test_project(
8187 path: &str,
8188 fs: &Arc<FakeFs>,
8189 multi_workspace: &Entity<MultiWorkspace>,
8190 cx: &mut gpui::VisualTestContext,
8191) -> Entity<Workspace> {
8192 let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
8193 let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8194 mw.test_add_workspace(project, window, cx)
8195 });
8196 cx.run_until_parked();
8197 workspace
8198}
8199
8200#[gpui::test]
8201async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
8202 let (fs, project_a) =
8203 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
8204 let (multi_workspace, cx) =
8205 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8206 let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
8207
8208 // Sidebar starts closed. Initial workspace A is transient.
8209 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8210 assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
8211 assert_eq!(
8212 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8213 1
8214 );
8215 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
8216
8217 // Add B — replaces A as the transient workspace.
8218 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8219 assert_eq!(
8220 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8221 1
8222 );
8223 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
8224
8225 // Add C — replaces B as the transient workspace.
8226 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8227 assert_eq!(
8228 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8229 1
8230 );
8231 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8232}
8233
8234#[gpui::test]
8235async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
8236 let (fs, project_a) = init_multi_project_test(
8237 &["/project-a", "/project-b", "/project-c", "/project-d"],
8238 cx,
8239 )
8240 .await;
8241 let (multi_workspace, cx) =
8242 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8243 let _sidebar = setup_sidebar(&multi_workspace, cx);
8244 assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
8245
8246 // Add B — retained since sidebar is open.
8247 let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8248 assert_eq!(
8249 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8250 2
8251 );
8252
8253 // Switch to A — B survives. (Switching from one internal workspace, to another)
8254 multi_workspace.update_in(cx, |mw, window, cx| {
8255 mw.activate(workspace_a, None, window, cx)
8256 });
8257 cx.run_until_parked();
8258 assert_eq!(
8259 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8260 2
8261 );
8262
8263 // Close sidebar — both A and B remain retained.
8264 multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
8265 cx.run_until_parked();
8266 assert_eq!(
8267 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8268 2
8269 );
8270
8271 // Add C — added as new transient workspace. (switching from retained, to transient)
8272 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8273 assert_eq!(
8274 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8275 3
8276 );
8277 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8278
8279 // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
8280 let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
8281 assert_eq!(
8282 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8283 3
8284 );
8285 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
8286}
8287
8288#[gpui::test]
8289async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
8290 let (fs, project_a) =
8291 init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
8292 let (multi_workspace, cx) =
8293 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
8294 setup_sidebar_closed(&multi_workspace, cx);
8295
8296 // Add B — replaces A as the transient workspace (A is discarded).
8297 let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
8298 assert_eq!(
8299 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8300 1
8301 );
8302 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
8303
8304 // Open sidebar — promotes the transient B to retained.
8305 multi_workspace.update_in(cx, |mw, window, cx| {
8306 mw.toggle_sidebar(window, cx);
8307 });
8308 cx.run_until_parked();
8309 assert_eq!(
8310 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8311 1
8312 );
8313 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
8314
8315 // Close sidebar — the retained B remains.
8316 multi_workspace.update_in(cx, |mw, window, cx| {
8317 mw.toggle_sidebar(window, cx);
8318 });
8319
8320 // Add C — added as new transient workspace.
8321 let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
8322 assert_eq!(
8323 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8324 2
8325 );
8326 assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
8327}
8328
8329#[gpui::test]
8330async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
8331 init_test(cx);
8332 let fs = FakeFs::new(cx.executor());
8333
8334 fs.insert_tree(
8335 "/project",
8336 serde_json::json!({
8337 ".git": {
8338 "worktrees": {
8339 "feature-a": {
8340 "commondir": "../../",
8341 "HEAD": "ref: refs/heads/feature-a",
8342 },
8343 },
8344 },
8345 "src": {},
8346 }),
8347 )
8348 .await;
8349
8350 fs.insert_tree(
8351 "/wt-feature-a",
8352 serde_json::json!({
8353 ".git": "gitdir: /project/.git/worktrees/feature-a",
8354 "src": {},
8355 }),
8356 )
8357 .await;
8358
8359 fs.add_linked_worktree_for_repo(
8360 Path::new("/project/.git"),
8361 false,
8362 git::repository::Worktree {
8363 path: PathBuf::from("/wt-feature-a"),
8364 ref_name: Some("refs/heads/feature-a".into()),
8365 sha: "abc".into(),
8366 is_main: false,
8367 is_bare: false,
8368 },
8369 )
8370 .await;
8371
8372 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8373
8374 // Only a linked worktree workspace is open — no workspace for /project.
8375 let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
8376 worktree_project
8377 .update(cx, |p, cx| p.git_scans_complete(cx))
8378 .await;
8379
8380 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
8381 MultiWorkspace::test_new(worktree_project.clone(), window, cx)
8382 });
8383 let sidebar = setup_sidebar(&multi_workspace, cx);
8384
8385 // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
8386 let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
8387 cx.update(|_, cx| {
8388 let metadata = ThreadMetadata {
8389 thread_id: ThreadId::new(),
8390 session_id: Some(legacy_session.clone()),
8391 agent_id: agent::ZED_AGENT_ID.clone(),
8392 title: Some("Legacy Main Thread".into()),
8393 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8394 created_at: None,
8395 interacted_at: None,
8396 worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
8397 "/project",
8398 )])),
8399 archived: false,
8400 remote_connection: None,
8401 };
8402 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
8403 });
8404 cx.run_until_parked();
8405
8406 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
8407 cx.run_until_parked();
8408
8409 // The legacy thread should appear in the sidebar under the project group.
8410 let entries = visible_entries_as_strings(&sidebar, cx);
8411 assert!(
8412 entries.iter().any(|e| e.contains("Legacy Main Thread")),
8413 "legacy thread should be visible: {entries:?}",
8414 );
8415
8416 // Verify only 1 workspace before clicking.
8417 assert_eq!(
8418 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
8419 1,
8420 );
8421
8422 // Focus and select the legacy thread, then confirm.
8423 focus_sidebar(&sidebar, cx);
8424 let thread_index = sidebar.read_with(cx, |sidebar, _| {
8425 sidebar
8426 .contents
8427 .entries
8428 .iter()
8429 .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
8430 .expect("legacy thread should be in entries")
8431 });
8432 sidebar.update_in(cx, |sidebar, _window, _cx| {
8433 sidebar.selection = Some(thread_index);
8434 });
8435 cx.dispatch_action(Confirm);
8436 cx.run_until_parked();
8437
8438 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8439 let new_path_list =
8440 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
8441 assert_eq!(
8442 new_path_list,
8443 PathList::new(&[PathBuf::from("/project")]),
8444 "the new workspace should be for the main repo, not the linked worktree",
8445 );
8446}
8447
8448#[gpui::test]
8449async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
8450 cx: &mut TestAppContext,
8451) {
8452 // Regression test for a property-test finding:
8453 // AddLinkedWorktree { project_group_index: 0 }
8454 // AddProject { use_worktree: true }
8455 // AddProject { use_worktree: false }
8456 // After these three steps, the linked-worktree workspace was not
8457 // reachable from any sidebar entry.
8458 agent_ui::test_support::init_test(cx);
8459 cx.update(|cx| {
8460 ThreadStore::init_global(cx);
8461 ThreadMetadataStore::init_global(cx);
8462 language_model::LanguageModelRegistry::test(cx);
8463 prompt_store::init(cx);
8464
8465 cx.observe_new(
8466 |workspace: &mut Workspace,
8467 window: Option<&mut Window>,
8468 cx: &mut gpui::Context<Workspace>| {
8469 if let Some(window) = window {
8470 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
8471 workspace.add_panel(panel, window, cx);
8472 }
8473 },
8474 )
8475 .detach();
8476 });
8477
8478 let fs = FakeFs::new(cx.executor());
8479 fs.insert_tree(
8480 "/my-project",
8481 serde_json::json!({
8482 ".git": {},
8483 "src": {},
8484 }),
8485 )
8486 .await;
8487 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8488 let project =
8489 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
8490 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8491
8492 let (multi_workspace, cx) =
8493 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8494 let sidebar = setup_sidebar(&multi_workspace, cx);
8495
8496 // Step 1: Create a linked worktree for the main project.
8497 let worktree_name = "wt-0";
8498 let worktree_path = "/worktrees/wt-0";
8499
8500 fs.insert_tree(
8501 worktree_path,
8502 serde_json::json!({
8503 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
8504 "src": {},
8505 }),
8506 )
8507 .await;
8508 fs.insert_tree(
8509 "/my-project/.git/worktrees/wt-0",
8510 serde_json::json!({
8511 "commondir": "../../",
8512 "HEAD": "ref: refs/heads/wt-0",
8513 }),
8514 )
8515 .await;
8516 fs.add_linked_worktree_for_repo(
8517 Path::new("/my-project/.git"),
8518 false,
8519 git::repository::Worktree {
8520 path: PathBuf::from(worktree_path),
8521 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
8522 sha: "aaa".into(),
8523 is_main: false,
8524 is_bare: false,
8525 },
8526 )
8527 .await;
8528
8529 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8530 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
8531 main_project
8532 .update(cx, |p, cx| p.git_scans_complete(cx))
8533 .await;
8534 cx.run_until_parked();
8535
8536 // Step 2: Open the linked worktree as its own workspace.
8537 let worktree_project =
8538 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
8539 worktree_project
8540 .update(cx, |p, cx| p.git_scans_complete(cx))
8541 .await;
8542 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
8543 mw.test_add_workspace(worktree_project.clone(), window, cx)
8544 });
8545 cx.run_until_parked();
8546
8547 // Step 3: Add an unrelated project.
8548 fs.insert_tree(
8549 "/other-project",
8550 serde_json::json!({
8551 ".git": {},
8552 "src": {},
8553 }),
8554 )
8555 .await;
8556 let other_project = project::Project::test(
8557 fs.clone() as Arc<dyn fs::Fs>,
8558 ["/other-project".as_ref()],
8559 cx,
8560 )
8561 .await;
8562 other_project
8563 .update(cx, |p, cx| p.git_scans_complete(cx))
8564 .await;
8565 multi_workspace.update_in(cx, |mw, window, cx| {
8566 mw.test_add_workspace(other_project.clone(), window, cx);
8567 });
8568 cx.run_until_parked();
8569
8570 // Force a full sidebar rebuild with all groups expanded.
8571 sidebar.update_in(cx, |sidebar, _window, cx| {
8572 if let Some(mw) = sidebar.multi_workspace.upgrade() {
8573 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
8574 }
8575 sidebar.update_entries(cx);
8576 });
8577 cx.run_until_parked();
8578
8579 // The linked-worktree workspace must be reachable from at least one
8580 // sidebar entry — otherwise the user has no way to navigate to it.
8581 let worktree_ws_id = worktree_workspace.entity_id();
8582 let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
8583 let mw = multi_workspace.read(cx);
8584
8585 let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
8586 let reachable: HashSet<gpui::EntityId> = sidebar
8587 .contents
8588 .entries
8589 .iter()
8590 .flat_map(|entry| entry.reachable_workspaces(mw, cx))
8591 .map(|ws| ws.entity_id())
8592 .collect();
8593 (all, reachable)
8594 });
8595
8596 let unreachable = &all_ids - &reachable_ids;
8597 eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
8598
8599 assert!(
8600 unreachable.is_empty(),
8601 "workspaces not reachable from any sidebar entry: {:?}\n\
8602 (linked-worktree workspace id: {:?})",
8603 unreachable,
8604 worktree_ws_id,
8605 );
8606}
8607
8608#[gpui::test]
8609async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
8610 // Empty project groups no longer auto-create drafts via reconciliation.
8611 // A fresh startup with no restorable thread should show only the header.
8612 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8613 let (multi_workspace, cx) =
8614 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8615 let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8616
8617 let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8618
8619 let entries = visible_entries_as_strings(&sidebar, cx);
8620 assert_eq!(
8621 entries,
8622 vec!["v [my-project]"],
8623 "empty group should show only the header, no auto-created draft"
8624 );
8625}
8626
8627#[gpui::test]
8628async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
8629 // Rule 5: When the app starts and the AgentPanel successfully loads
8630 // a thread, no spurious draft should appear.
8631 let project = init_test_project_with_agent_panel("/my-project", cx).await;
8632 let (multi_workspace, cx) =
8633 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8634 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8635
8636 // Create and send a message to make a real thread.
8637 let connection = StubAgentConnection::new();
8638 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8639 acp::ContentChunk::new("Done".into()),
8640 )]);
8641 open_thread_with_connection(&panel, connection, cx);
8642 send_message(&panel, cx);
8643 let session_id = active_session_id(&panel, cx);
8644 save_test_thread_metadata(&session_id, &project, cx).await;
8645 cx.run_until_parked();
8646
8647 // Should show the thread, NOT a spurious draft.
8648 let entries = visible_entries_as_strings(&sidebar, cx);
8649 assert_eq!(entries, vec!["v [my-project]", " Hello *"]);
8650
8651 // active_entry should be Thread, not Draft.
8652 sidebar.read_with(cx, |sidebar, _| {
8653 assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
8654 });
8655}
8656
8657#[gpui::test]
8658async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
8659 // Rule 9: Clicking a project header should restore whatever the
8660 // user was last looking at in that group, not create new drafts
8661 // or jump to the first entry.
8662 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
8663 let (multi_workspace, cx) =
8664 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8665 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
8666
8667 // Create two threads in project-a.
8668 let conn1 = StubAgentConnection::new();
8669 conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8670 acp::ContentChunk::new("Done".into()),
8671 )]);
8672 open_thread_with_connection(&panel_a, conn1, cx);
8673 send_message(&panel_a, cx);
8674 let thread_a1 = active_session_id(&panel_a, cx);
8675 save_test_thread_metadata(&thread_a1, &project_a, cx).await;
8676
8677 let conn2 = StubAgentConnection::new();
8678 conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
8679 acp::ContentChunk::new("Done".into()),
8680 )]);
8681 open_thread_with_connection(&panel_a, conn2, cx);
8682 send_message(&panel_a, cx);
8683 let thread_a2 = active_session_id(&panel_a, cx);
8684 save_test_thread_metadata(&thread_a2, &project_a, cx).await;
8685 cx.run_until_parked();
8686
8687 // The user is now looking at thread_a2.
8688 sidebar.read_with(cx, |sidebar, _| {
8689 assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
8690 });
8691
8692 // Add project-b and switch to it.
8693 let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
8694 fs.as_fake()
8695 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
8696 .await;
8697 let project_b =
8698 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8699 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8700 mw.test_add_workspace(project_b.clone(), window, cx)
8701 });
8702 let _panel_b = add_agent_panel(&workspace_b, cx);
8703 cx.run_until_parked();
8704
8705 // Now switch BACK to project-a by activating its workspace.
8706 let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
8707 mw.workspaces()
8708 .find(|ws| {
8709 ws.read(cx)
8710 .project()
8711 .read(cx)
8712 .visible_worktrees(cx)
8713 .any(|wt| {
8714 wt.read(cx)
8715 .abs_path()
8716 .to_string_lossy()
8717 .contains("project-a")
8718 })
8719 })
8720 .unwrap()
8721 .clone()
8722 });
8723 multi_workspace.update_in(cx, |mw, window, cx| {
8724 mw.activate(workspace_a.clone(), None, window, cx);
8725 });
8726 cx.run_until_parked();
8727
8728 // The panel should still show thread_a2 (the last thing the user
8729 // was viewing in project-a), not a draft or thread_a1.
8730 sidebar.read_with(cx, |sidebar, _| {
8731 assert_active_thread(
8732 sidebar,
8733 &thread_a2,
8734 "switching back to project-a should restore thread_a2",
8735 );
8736 });
8737
8738 // No spurious draft entries should have been created in
8739 // project-a's group (project-b may have a placeholder).
8740 let entries = visible_entries_as_strings(&sidebar, cx);
8741 // Find project-a's section and check it has no drafts.
8742 let project_a_start = entries
8743 .iter()
8744 .position(|e| e.contains("project-a"))
8745 .unwrap();
8746 let project_a_end = entries[project_a_start + 1..]
8747 .iter()
8748 .position(|e| e.starts_with("v "))
8749 .map(|i| i + project_a_start + 1)
8750 .unwrap_or(entries.len());
8751 let project_a_drafts = entries[project_a_start..project_a_end]
8752 .iter()
8753 .filter(|e| e.contains("Draft"))
8754 .count();
8755 assert_eq!(
8756 project_a_drafts, 0,
8757 "switching back to project-a should not create drafts in its group"
8758 );
8759}
8760
8761#[gpui::test]
8762async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
8763 // When a workspace has a draft (from the panel's load fallback)
8764 // and the user activates it (e.g. by clicking the placeholder or
8765 // the project header), no extra drafts should be created.
8766 init_test(cx);
8767 let fs = FakeFs::new(cx.executor());
8768 fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
8769 .await;
8770 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8771 .await;
8772 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8773
8774 let project_a =
8775 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
8776 let (multi_workspace, cx) =
8777 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
8778 let sidebar = setup_sidebar(&multi_workspace, cx);
8779 let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
8780 let _panel_a = add_agent_panel(&workspace_a, cx);
8781 cx.run_until_parked();
8782
8783 // Add project-b with its own workspace and agent panel.
8784 let project_b =
8785 project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
8786 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
8787 mw.test_add_workspace(project_b.clone(), window, cx)
8788 });
8789 let _panel_b = add_agent_panel(&workspace_b, cx);
8790 cx.run_until_parked();
8791
8792 // Explicitly create a draft on workspace_b so the sidebar tracks one.
8793 sidebar.update_in(cx, |sidebar, window, cx| {
8794 sidebar.create_new_thread(&workspace_b, window, cx);
8795 });
8796 cx.run_until_parked();
8797
8798 // Count project-b's drafts.
8799 let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
8800 let entries = visible_entries_as_strings(&sidebar, cx);
8801 entries
8802 .iter()
8803 .skip_while(|e| !e.contains("project-b"))
8804 .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
8805 .filter(|e| e.contains("Draft"))
8806 .count()
8807 };
8808 let drafts_before = count_b_drafts(cx);
8809
8810 // Switch away from project-b, then back.
8811 multi_workspace.update_in(cx, |mw, window, cx| {
8812 mw.activate(workspace_a.clone(), None, window, cx);
8813 });
8814 cx.run_until_parked();
8815 multi_workspace.update_in(cx, |mw, window, cx| {
8816 mw.activate(workspace_b.clone(), None, window, cx);
8817 });
8818 cx.run_until_parked();
8819
8820 let drafts_after = count_b_drafts(cx);
8821 assert_eq!(
8822 drafts_before, drafts_after,
8823 "activating workspace should not create extra drafts"
8824 );
8825
8826 // The draft should be highlighted as active after switching back.
8827 sidebar.read_with(cx, |sidebar, _| {
8828 assert_active_draft(
8829 sidebar,
8830 &workspace_b,
8831 "draft should be active after switching back to its workspace",
8832 );
8833 });
8834}
8835
8836#[gpui::test]
8837async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
8838 // Historical threads (not open in any agent panel) should have their
8839 // worktree paths updated when a folder is added to or removed from the
8840 // project.
8841 let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
8842 let (multi_workspace, cx) =
8843 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8844 let sidebar = setup_sidebar(&multi_workspace, cx);
8845
8846 // Save two threads directly into the metadata store (not via the agent
8847 // panel), so they are purely historical — no open views hold them.
8848 // Use different timestamps so sort order is deterministic.
8849 save_thread_metadata(
8850 acp::SessionId::new(Arc::from("hist-1")),
8851 Some("Historical 1".into()),
8852 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8853 None,
8854 None,
8855 &project,
8856 cx,
8857 );
8858 save_thread_metadata(
8859 acp::SessionId::new(Arc::from("hist-2")),
8860 Some("Historical 2".into()),
8861 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
8862 None,
8863 None,
8864 &project,
8865 cx,
8866 );
8867 cx.run_until_parked();
8868 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8869 cx.run_until_parked();
8870
8871 // Sanity-check: both threads exist under the initial key [/project-a].
8872 let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
8873 cx.update(|_window, cx| {
8874 let store = ThreadMetadataStore::global(cx).read(cx);
8875 assert_eq!(
8876 store
8877 .entries_for_main_worktree_path(&old_key_paths, None)
8878 .count(),
8879 2,
8880 "should have 2 historical threads under old key before worktree add"
8881 );
8882 });
8883
8884 // Add a second worktree to the project.
8885 project
8886 .update(cx, |project, cx| {
8887 project.find_or_create_worktree("/project-b", true, cx)
8888 })
8889 .await
8890 .expect("should add worktree");
8891 cx.run_until_parked();
8892
8893 // The historical threads should now be indexed under the new combined
8894 // key [/project-a, /project-b].
8895 let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
8896 cx.update(|_window, cx| {
8897 let store = ThreadMetadataStore::global(cx).read(cx);
8898 assert_eq!(
8899 store
8900 .entries_for_main_worktree_path(&old_key_paths, None)
8901 .count(),
8902 0,
8903 "should have 0 historical threads under old key after worktree add"
8904 );
8905 assert_eq!(
8906 store
8907 .entries_for_main_worktree_path(&new_key_paths, None)
8908 .count(),
8909 2,
8910 "should have 2 historical threads under new key after worktree add"
8911 );
8912 });
8913
8914 // Sidebar should show threads under the new header.
8915 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8916 cx.run_until_parked();
8917 assert_eq!(
8918 visible_entries_as_strings(&sidebar, cx),
8919 vec![
8920 "v [project-a, project-b]",
8921 " Historical 2",
8922 " Historical 1",
8923 ]
8924 );
8925
8926 // Now remove the second worktree.
8927 let worktree_id = project.read_with(cx, |project, cx| {
8928 project
8929 .visible_worktrees(cx)
8930 .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
8931 .map(|wt| wt.read(cx).id())
8932 .expect("should find project-b worktree")
8933 });
8934 project.update(cx, |project, cx| {
8935 project.remove_worktree(worktree_id, cx);
8936 });
8937 cx.run_until_parked();
8938
8939 // Historical threads should migrate back to the original key.
8940 cx.update(|_window, cx| {
8941 let store = ThreadMetadataStore::global(cx).read(cx);
8942 assert_eq!(
8943 store
8944 .entries_for_main_worktree_path(&new_key_paths, None)
8945 .count(),
8946 0,
8947 "should have 0 historical threads under new key after worktree remove"
8948 );
8949 assert_eq!(
8950 store
8951 .entries_for_main_worktree_path(&old_key_paths, None)
8952 .count(),
8953 2,
8954 "should have 2 historical threads under old key after worktree remove"
8955 );
8956 });
8957
8958 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
8959 cx.run_until_parked();
8960 assert_eq!(
8961 visible_entries_as_strings(&sidebar, cx),
8962 vec!["v [project-a]", " Historical 2", " Historical 1",]
8963 );
8964}
8965
8966#[gpui::test]
8967async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
8968 // When two workspaces share the same project group (same main path)
8969 // but have different folder paths (main repo vs linked worktree),
8970 // adding a worktree to the main workspace should regroup only that
8971 // workspace and its threads into the new project group. Threads for the
8972 // linked worktree workspace should remain under the original group.
8973 agent_ui::test_support::init_test(cx);
8974 cx.update(|cx| {
8975 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
8976 ThreadStore::init_global(cx);
8977 ThreadMetadataStore::init_global(cx);
8978 language_model::LanguageModelRegistry::test(cx);
8979 prompt_store::init(cx);
8980 });
8981
8982 let fs = FakeFs::new(cx.executor());
8983 fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
8984 .await;
8985 fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
8986 .await;
8987 fs.add_linked_worktree_for_repo(
8988 Path::new("/project/.git"),
8989 false,
8990 git::repository::Worktree {
8991 path: std::path::PathBuf::from("/wt-feature"),
8992 ref_name: Some("refs/heads/feature".into()),
8993 sha: "aaa".into(),
8994 is_main: false,
8995 is_bare: false,
8996 },
8997 )
8998 .await;
8999 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
9000
9001 // Workspace A: main repo at /project.
9002 let main_project =
9003 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
9004 // Workspace B: linked worktree of the same repo (same group, different folder).
9005 let worktree_project =
9006 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
9007
9008 main_project
9009 .update(cx, |p, cx| p.git_scans_complete(cx))
9010 .await;
9011 worktree_project
9012 .update(cx, |p, cx| p.git_scans_complete(cx))
9013 .await;
9014
9015 let (multi_workspace, cx) =
9016 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
9017 let sidebar = setup_sidebar(&multi_workspace, cx);
9018 multi_workspace.update_in(cx, |mw, window, cx| {
9019 mw.test_add_workspace(worktree_project.clone(), window, cx);
9020 });
9021 cx.run_until_parked();
9022
9023 // Save a thread for each workspace's folder paths.
9024 let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
9025 let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
9026 save_thread_metadata(
9027 acp::SessionId::new(Arc::from("thread-main")),
9028 Some("Main Thread".into()),
9029 time_main,
9030 Some(time_main),
9031 None,
9032 &main_project,
9033 cx,
9034 );
9035 save_thread_metadata(
9036 acp::SessionId::new(Arc::from("thread-wt")),
9037 Some("Worktree Thread".into()),
9038 time_wt,
9039 Some(time_wt),
9040 None,
9041 &worktree_project,
9042 cx,
9043 );
9044 cx.run_until_parked();
9045
9046 let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
9047 let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
9048
9049 // Sanity-check: each thread is indexed under its own folder paths, but
9050 // both appear under the shared sidebar group keyed by the main worktree.
9051 cx.update(|_window, cx| {
9052 let store = ThreadMetadataStore::global(cx).read(cx);
9053 assert_eq!(
9054 store.entries_for_path(&folder_paths_main, None).count(),
9055 1,
9056 "one thread under [/project]"
9057 );
9058 assert_eq!(
9059 store.entries_for_path(&folder_paths_wt, None).count(),
9060 1,
9061 "one thread under [/wt-feature]"
9062 );
9063 });
9064 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9065 cx.run_until_parked();
9066 assert_eq!(
9067 visible_entries_as_strings(&sidebar, cx),
9068 vec![
9069 "v [project]",
9070 " Worktree Thread {wt-feature}",
9071 " Main Thread",
9072 ]
9073 );
9074
9075 // Add /project-b to the main project only.
9076 main_project
9077 .update(cx, |project, cx| {
9078 project.find_or_create_worktree("/project-b", true, cx)
9079 })
9080 .await
9081 .expect("should add worktree");
9082 cx.run_until_parked();
9083
9084 // Main Thread (folder paths [/project]) should be regrouped to
9085 // [/project, /project-b]. Worktree Thread should remain under the
9086 // original [/project] group.
9087 let folder_paths_main_b =
9088 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
9089 cx.update(|_window, cx| {
9090 let store = ThreadMetadataStore::global(cx).read(cx);
9091 assert_eq!(
9092 store.entries_for_path(&folder_paths_main, None).count(),
9093 0,
9094 "main thread should no longer be under old folder paths [/project]"
9095 );
9096 assert_eq!(
9097 store.entries_for_path(&folder_paths_main_b, None).count(),
9098 1,
9099 "main thread should now be under [/project, /project-b]"
9100 );
9101 assert_eq!(
9102 store.entries_for_path(&folder_paths_wt, None).count(),
9103 1,
9104 "worktree thread should remain unchanged under [/wt-feature]"
9105 );
9106 });
9107
9108 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9109 cx.run_until_parked();
9110 assert_eq!(
9111 visible_entries_as_strings(&sidebar, cx),
9112 vec![
9113 "v [project]",
9114 " Worktree Thread {wt-feature}",
9115 "v [project, project-b]",
9116 " Main Thread",
9117 ]
9118 );
9119}
9120
9121#[gpui::test]
9122async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
9123 cx: &mut TestAppContext,
9124) {
9125 // When a linked worktree is opened as its own workspace and then a new
9126 // folder is added to the main project group, the linked worktree
9127 // workspace must still be reachable from some sidebar entry.
9128 let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
9129 let fs = _fs.clone();
9130
9131 // Set up git worktree infrastructure.
9132 fs.insert_tree(
9133 "/my-project/.git/worktrees/wt-0",
9134 serde_json::json!({
9135 "commondir": "../../",
9136 "HEAD": "ref: refs/heads/wt-0",
9137 }),
9138 )
9139 .await;
9140 fs.insert_tree(
9141 "/worktrees/wt-0",
9142 serde_json::json!({
9143 ".git": "gitdir: /my-project/.git/worktrees/wt-0",
9144 "src": {},
9145 }),
9146 )
9147 .await;
9148 fs.add_linked_worktree_for_repo(
9149 Path::new("/my-project/.git"),
9150 false,
9151 git::repository::Worktree {
9152 path: PathBuf::from("/worktrees/wt-0"),
9153 ref_name: Some("refs/heads/wt-0".into()),
9154 sha: "aaa".into(),
9155 is_main: false,
9156 is_bare: false,
9157 },
9158 )
9159 .await;
9160
9161 // Re-scan so the main project discovers the linked worktree.
9162 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9163
9164 let (multi_workspace, cx) =
9165 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9166 let sidebar = setup_sidebar(&multi_workspace, cx);
9167
9168 // Open the linked worktree as its own workspace.
9169 let worktree_project = project::Project::test(
9170 fs.clone() as Arc<dyn fs::Fs>,
9171 ["/worktrees/wt-0".as_ref()],
9172 cx,
9173 )
9174 .await;
9175 worktree_project
9176 .update(cx, |p, cx| p.git_scans_complete(cx))
9177 .await;
9178 multi_workspace.update_in(cx, |mw, window, cx| {
9179 mw.test_add_workspace(worktree_project.clone(), window, cx);
9180 });
9181 cx.run_until_parked();
9182
9183 // Both workspaces should be reachable.
9184 let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
9185 assert_eq!(workspace_count, 2, "should have 2 workspaces");
9186
9187 // Add a new folder to the main project, changing the project group key.
9188 fs.insert_tree(
9189 "/other-project",
9190 serde_json::json!({ ".git": {}, "src": {} }),
9191 )
9192 .await;
9193 project
9194 .update(cx, |project, cx| {
9195 project.find_or_create_worktree("/other-project", true, cx)
9196 })
9197 .await
9198 .expect("should add worktree");
9199 cx.run_until_parked();
9200
9201 sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
9202 cx.run_until_parked();
9203
9204 // The linked worktree workspace must still be reachable.
9205 let entries = visible_entries_as_strings(&sidebar, cx);
9206 let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
9207 mw.workspaces().map(|ws| ws.entity_id()).collect()
9208 });
9209 sidebar.read_with(cx, |sidebar, cx| {
9210 let multi_workspace = multi_workspace.read(cx);
9211 let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
9212 .contents
9213 .entries
9214 .iter()
9215 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
9216 .map(|ws| ws.entity_id())
9217 .collect();
9218 let all: std::collections::HashSet<gpui::EntityId> =
9219 mw_workspaces.iter().copied().collect();
9220 let unreachable = &all - &reachable;
9221 assert!(
9222 unreachable.is_empty(),
9223 "all workspaces should be reachable after adding folder; \
9224 unreachable: {:?}, entries: {:?}",
9225 unreachable,
9226 entries,
9227 );
9228 });
9229}
9230
9231mod property_test {
9232 use super::*;
9233 use gpui::proptest::prelude::*;
9234
9235 struct UnopenedWorktree {
9236 path: String,
9237 main_workspace_path: String,
9238 }
9239
9240 struct TestState {
9241 fs: Arc<FakeFs>,
9242 thread_counter: u32,
9243 workspace_counter: u32,
9244 worktree_counter: u32,
9245 saved_thread_ids: Vec<acp::SessionId>,
9246 unopened_worktrees: Vec<UnopenedWorktree>,
9247 }
9248
9249 impl TestState {
9250 fn new(fs: Arc<FakeFs>) -> Self {
9251 Self {
9252 fs,
9253 thread_counter: 0,
9254 workspace_counter: 1,
9255 worktree_counter: 0,
9256 saved_thread_ids: Vec::new(),
9257 unopened_worktrees: Vec::new(),
9258 }
9259 }
9260
9261 fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
9262 let id = self.thread_counter;
9263 self.thread_counter += 1;
9264 acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
9265 }
9266
9267 fn next_workspace_path(&mut self) -> String {
9268 let id = self.workspace_counter;
9269 self.workspace_counter += 1;
9270 format!("/prop-project-{id}")
9271 }
9272
9273 fn next_worktree_name(&mut self) -> String {
9274 let id = self.worktree_counter;
9275 self.worktree_counter += 1;
9276 format!("wt-{id}")
9277 }
9278 }
9279
9280 #[derive(Debug)]
9281 enum Operation {
9282 SaveThread { project_group_index: usize },
9283 SaveWorktreeThread { worktree_index: usize },
9284 ToggleAgentPanel,
9285 CreateDraftThread,
9286 AddProject { use_worktree: bool },
9287 ArchiveThread { index: usize },
9288 SwitchToThread { index: usize },
9289 SwitchToProjectGroup { index: usize },
9290 AddLinkedWorktree { project_group_index: usize },
9291 AddWorktreeToProject { project_group_index: usize },
9292 RemoveWorktreeFromProject { project_group_index: usize },
9293 }
9294
9295 // Distribution (out of 24 slots):
9296 // SaveThread: 5 slots (~21%)
9297 // SaveWorktreeThread: 2 slots (~8%)
9298 // ToggleAgentPanel: 1 slot (~4%)
9299 // CreateDraftThread: 1 slot (~4%)
9300 // AddProject: 1 slot (~4%)
9301 // ArchiveThread: 2 slots (~8%)
9302 // SwitchToThread: 2 slots (~8%)
9303 // SwitchToProjectGroup: 2 slots (~8%)
9304 // AddLinkedWorktree: 4 slots (~17%)
9305 // AddWorktreeToProject: 2 slots (~8%)
9306 // RemoveWorktreeFromProject: 2 slots (~8%)
9307 const DISTRIBUTION_SLOTS: u32 = 24;
9308
9309 impl TestState {
9310 fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
9311 let extra = (raw / DISTRIBUTION_SLOTS) as usize;
9312
9313 match raw % DISTRIBUTION_SLOTS {
9314 0..=4 => Operation::SaveThread {
9315 project_group_index: extra % project_group_count,
9316 },
9317 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
9318 worktree_index: extra % self.unopened_worktrees.len(),
9319 },
9320 5..=6 => Operation::SaveThread {
9321 project_group_index: extra % project_group_count,
9322 },
9323 7 => Operation::ToggleAgentPanel,
9324 8 => Operation::CreateDraftThread,
9325 9 => Operation::AddProject {
9326 use_worktree: !self.unopened_worktrees.is_empty(),
9327 },
9328 10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
9329 index: extra % self.saved_thread_ids.len(),
9330 },
9331 10..=11 => Operation::AddProject {
9332 use_worktree: !self.unopened_worktrees.is_empty(),
9333 },
9334 12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
9335 index: extra % self.saved_thread_ids.len(),
9336 },
9337 12..=13 => Operation::SwitchToProjectGroup {
9338 index: extra % project_group_count,
9339 },
9340 14..=15 => Operation::SwitchToProjectGroup {
9341 index: extra % project_group_count,
9342 },
9343 16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
9344 project_group_index: extra % project_group_count,
9345 },
9346 16..=19 => Operation::SaveThread {
9347 project_group_index: extra % project_group_count,
9348 },
9349 20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
9350 project_group_index: extra % project_group_count,
9351 },
9352 20..=21 => Operation::SaveThread {
9353 project_group_index: extra % project_group_count,
9354 },
9355 22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
9356 project_group_index: extra % project_group_count,
9357 },
9358 22..=23 => Operation::SaveThread {
9359 project_group_index: extra % project_group_count,
9360 },
9361 _ => unreachable!(),
9362 }
9363 }
9364 }
9365
9366 fn save_thread_to_path_with_main(
9367 state: &mut TestState,
9368 path_list: PathList,
9369 main_worktree_paths: PathList,
9370 cx: &mut gpui::VisualTestContext,
9371 ) {
9372 let session_id = state.next_metadata_only_thread_id();
9373 let title: SharedString = format!("Thread {}", session_id).into();
9374 let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9375 .unwrap()
9376 + chrono::Duration::seconds(state.thread_counter as i64);
9377 let metadata = ThreadMetadata {
9378 thread_id: ThreadId::new(),
9379 session_id: Some(session_id),
9380 agent_id: agent::ZED_AGENT_ID.clone(),
9381 title: Some(title),
9382 updated_at,
9383 created_at: None,
9384 interacted_at: None,
9385 worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
9386 archived: false,
9387 remote_connection: None,
9388 };
9389 cx.update(|_, cx| {
9390 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
9391 });
9392 cx.run_until_parked();
9393 }
9394
9395 async fn perform_operation(
9396 operation: Operation,
9397 state: &mut TestState,
9398 multi_workspace: &Entity<MultiWorkspace>,
9399 sidebar: &Entity<Sidebar>,
9400 cx: &mut gpui::VisualTestContext,
9401 ) {
9402 match operation {
9403 Operation::SaveThread {
9404 project_group_index,
9405 } => {
9406 // Find a workspace for this project group and create a real
9407 // thread via its agent panel.
9408 let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
9409 let keys = mw.project_group_keys();
9410 let key = &keys[project_group_index];
9411 let ws = mw
9412 .workspaces_for_project_group(key, cx)
9413 .and_then(|ws| ws.first().cloned())
9414 .unwrap_or_else(|| mw.workspace().clone());
9415 let project = ws.read(cx).project().clone();
9416 (ws, project)
9417 });
9418
9419 let panel =
9420 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9421 if let Some(panel) = panel {
9422 let connection = StubAgentConnection::new();
9423 connection.set_next_prompt_updates(vec![
9424 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
9425 "Done".into(),
9426 )),
9427 ]);
9428 open_thread_with_connection(&panel, connection, cx);
9429 send_message(&panel, cx);
9430 let session_id = active_session_id(&panel, cx);
9431 state.saved_thread_ids.push(session_id.clone());
9432
9433 let title: SharedString = format!("Thread {}", state.thread_counter).into();
9434 state.thread_counter += 1;
9435 let updated_at =
9436 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
9437 .unwrap()
9438 + chrono::Duration::seconds(state.thread_counter as i64);
9439 save_thread_metadata(
9440 session_id,
9441 Some(title),
9442 updated_at,
9443 None,
9444 None,
9445 &project,
9446 cx,
9447 );
9448 }
9449 }
9450 Operation::SaveWorktreeThread { worktree_index } => {
9451 let worktree = &state.unopened_worktrees[worktree_index];
9452 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
9453 let main_worktree_paths =
9454 PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
9455 save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
9456 }
9457
9458 Operation::ToggleAgentPanel => {
9459 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9460 let panel_open =
9461 workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
9462 workspace.update_in(cx, |workspace, window, cx| {
9463 if panel_open {
9464 workspace.close_panel::<AgentPanel>(window, cx);
9465 } else {
9466 workspace.open_panel::<AgentPanel>(window, cx);
9467 }
9468 });
9469 }
9470 Operation::CreateDraftThread => {
9471 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
9472 let panel =
9473 workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
9474 if let Some(panel) = panel {
9475 panel.update_in(cx, |panel, window, cx| {
9476 panel.new_thread(&NewThread, window, cx);
9477 });
9478 cx.run_until_parked();
9479 }
9480 workspace.update_in(cx, |workspace, window, cx| {
9481 workspace.focus_panel::<AgentPanel>(window, cx);
9482 });
9483 }
9484 Operation::AddProject { use_worktree } => {
9485 let path = if use_worktree {
9486 // Open an existing linked worktree as a project (simulates Cmd+O
9487 // on a worktree directory).
9488 state.unopened_worktrees.remove(0).path
9489 } else {
9490 // Create a brand new project.
9491 let path = state.next_workspace_path();
9492 state
9493 .fs
9494 .insert_tree(
9495 &path,
9496 serde_json::json!({
9497 ".git": {},
9498 "src": {},
9499 }),
9500 )
9501 .await;
9502 path
9503 };
9504 let project = project::Project::test(
9505 state.fs.clone() as Arc<dyn fs::Fs>,
9506 [path.as_ref()],
9507 cx,
9508 )
9509 .await;
9510 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
9511 multi_workspace.update_in(cx, |mw, window, cx| {
9512 mw.test_add_workspace(project.clone(), window, cx)
9513 });
9514 }
9515
9516 Operation::ArchiveThread { index } => {
9517 let session_id = state.saved_thread_ids[index].clone();
9518 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
9519 sidebar.archive_thread(&session_id, window, cx);
9520 });
9521 cx.run_until_parked();
9522 state.saved_thread_ids.remove(index);
9523 }
9524 Operation::SwitchToThread { index } => {
9525 let session_id = state.saved_thread_ids[index].clone();
9526 // Find the thread's position in the sidebar entries and select it.
9527 let thread_index = sidebar.read_with(cx, |sidebar, _| {
9528 sidebar.contents.entries.iter().position(|entry| {
9529 matches!(
9530 entry,
9531 ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
9532 )
9533 })
9534 });
9535 if let Some(ix) = thread_index {
9536 sidebar.update_in(cx, |sidebar, window, cx| {
9537 sidebar.selection = Some(ix);
9538 sidebar.confirm(&Confirm, window, cx);
9539 });
9540 cx.run_until_parked();
9541 }
9542 }
9543 Operation::SwitchToProjectGroup { index } => {
9544 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9545 let keys = mw.project_group_keys();
9546 let key = &keys[index];
9547 mw.workspaces_for_project_group(key, cx)
9548 .and_then(|ws| ws.first().cloned())
9549 .unwrap_or_else(|| mw.workspace().clone())
9550 });
9551 multi_workspace.update_in(cx, |mw, window, cx| {
9552 mw.activate(workspace, None, window, cx);
9553 });
9554 }
9555 Operation::AddLinkedWorktree {
9556 project_group_index,
9557 } => {
9558 // Get the main worktree path from the project group key.
9559 let main_path = multi_workspace.read_with(cx, |mw, _| {
9560 let keys = mw.project_group_keys();
9561 let key = &keys[project_group_index];
9562 key.path_list()
9563 .paths()
9564 .first()
9565 .unwrap()
9566 .to_string_lossy()
9567 .to_string()
9568 });
9569 let dot_git = format!("{}/.git", main_path);
9570 let worktree_name = state.next_worktree_name();
9571 let worktree_path = format!("/worktrees/{}", worktree_name);
9572
9573 state.fs
9574 .insert_tree(
9575 &worktree_path,
9576 serde_json::json!({
9577 ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
9578 "src": {},
9579 }),
9580 )
9581 .await;
9582
9583 // Also create the worktree metadata dir inside the main repo's .git
9584 state
9585 .fs
9586 .insert_tree(
9587 &format!("{}/.git/worktrees/{}", main_path, worktree_name),
9588 serde_json::json!({
9589 "commondir": "../../",
9590 "HEAD": format!("ref: refs/heads/{}", worktree_name),
9591 }),
9592 )
9593 .await;
9594
9595 let dot_git_path = std::path::Path::new(&dot_git);
9596 let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
9597 state
9598 .fs
9599 .add_linked_worktree_for_repo(
9600 dot_git_path,
9601 false,
9602 git::repository::Worktree {
9603 path: worktree_pathbuf,
9604 ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
9605 sha: "aaa".into(),
9606 is_main: false,
9607 is_bare: false,
9608 },
9609 )
9610 .await;
9611
9612 // Re-scan the main workspace's project so it discovers the new worktree.
9613 let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
9614 let keys = mw.project_group_keys();
9615 let key = &keys[project_group_index];
9616 mw.workspaces_for_project_group(key, cx)
9617 .and_then(|ws| ws.first().cloned())
9618 .unwrap()
9619 });
9620 let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
9621 main_project
9622 .update(cx, |p, cx| p.git_scans_complete(cx))
9623 .await;
9624
9625 state.unopened_worktrees.push(UnopenedWorktree {
9626 path: worktree_path,
9627 main_workspace_path: main_path.clone(),
9628 });
9629 }
9630 Operation::AddWorktreeToProject {
9631 project_group_index,
9632 } => {
9633 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9634 let keys = mw.project_group_keys();
9635 let key = &keys[project_group_index];
9636 mw.workspaces_for_project_group(key, cx)
9637 .and_then(|ws| ws.first().cloned())
9638 });
9639 let Some(workspace) = workspace else { return };
9640 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9641
9642 let new_path = state.next_workspace_path();
9643 state
9644 .fs
9645 .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
9646 .await;
9647
9648 let result = project
9649 .update(cx, |project, cx| {
9650 project.find_or_create_worktree(&new_path, true, cx)
9651 })
9652 .await;
9653 if result.is_err() {
9654 return;
9655 }
9656 cx.run_until_parked();
9657 }
9658 Operation::RemoveWorktreeFromProject {
9659 project_group_index,
9660 } => {
9661 let workspace = multi_workspace.read_with(cx, |mw, cx| {
9662 let keys = mw.project_group_keys();
9663 let key = &keys[project_group_index];
9664 mw.workspaces_for_project_group(key, cx)
9665 .and_then(|ws| ws.first().cloned())
9666 });
9667 let Some(workspace) = workspace else { return };
9668 let project = workspace.read_with(cx, |ws, _| ws.project().clone());
9669
9670 let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
9671 if worktree_count <= 1 {
9672 return;
9673 }
9674
9675 let worktree_id = project.read_with(cx, |p, cx| {
9676 p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
9677 });
9678 if let Some(worktree_id) = worktree_id {
9679 project.update(cx, |project, cx| {
9680 project.remove_worktree(worktree_id, cx);
9681 });
9682 cx.run_until_parked();
9683 }
9684 }
9685 }
9686 }
9687
9688 fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
9689 sidebar.update_in(cx, |sidebar, _window, cx| {
9690 if let Some(mw) = sidebar.multi_workspace.upgrade() {
9691 mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
9692 }
9693 sidebar.update_entries(cx);
9694 });
9695 }
9696
9697 fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9698 verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
9699 verify_no_duplicate_threads(sidebar)?;
9700 verify_all_threads_are_shown(sidebar, cx)?;
9701 verify_active_state_matches_current_workspace(sidebar, cx)?;
9702 verify_all_workspaces_are_reachable(sidebar, cx)?;
9703 verify_workspace_group_key_integrity(sidebar, cx)?;
9704 Ok(())
9705 }
9706
9707 fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
9708 let mut seen: HashSet<acp::SessionId> = HashSet::default();
9709 let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
9710
9711 for entry in &sidebar.contents.entries {
9712 if let Some(session_id) = entry.session_id() {
9713 if !seen.insert(session_id.clone()) {
9714 let title = match entry {
9715 ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
9716 _ => "<unknown>".to_string(),
9717 };
9718 duplicates.push((session_id.clone(), title));
9719 }
9720 }
9721 }
9722
9723 anyhow::ensure!(
9724 duplicates.is_empty(),
9725 "threads appear more than once in sidebar: {:?}",
9726 duplicates,
9727 );
9728 Ok(())
9729 }
9730
9731 fn verify_every_group_in_multiworkspace_is_shown(
9732 sidebar: &Sidebar,
9733 cx: &App,
9734 ) -> anyhow::Result<()> {
9735 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9736 anyhow::bail!("sidebar should still have an associated multi-workspace");
9737 };
9738
9739 let mw = multi_workspace.read(cx);
9740
9741 // Every project group key in the multi-workspace that has a
9742 // non-empty path list should appear as a ProjectHeader in the
9743 // sidebar.
9744 let all_keys = mw.project_group_keys();
9745 let expected_keys: HashSet<&ProjectGroupKey> = all_keys
9746 .iter()
9747 .filter(|k| !k.path_list().paths().is_empty())
9748 .collect();
9749
9750 let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
9751 .contents
9752 .entries
9753 .iter()
9754 .filter_map(|entry| match entry {
9755 ListEntry::ProjectHeader { key, .. } => Some(key),
9756 _ => None,
9757 })
9758 .collect();
9759
9760 let missing = &expected_keys - &sidebar_keys;
9761 let stray = &sidebar_keys - &expected_keys;
9762
9763 anyhow::ensure!(
9764 missing.is_empty() && stray.is_empty(),
9765 "sidebar project groups don't match multi-workspace.\n\
9766 Only in multi-workspace (missing): {:?}\n\
9767 Only in sidebar (stray): {:?}",
9768 missing,
9769 stray,
9770 );
9771
9772 Ok(())
9773 }
9774
9775 fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
9776 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9777 anyhow::bail!("sidebar should still have an associated multi-workspace");
9778 };
9779 let workspaces = multi_workspace
9780 .read(cx)
9781 .workspaces()
9782 .cloned()
9783 .collect::<Vec<_>>();
9784 let thread_store = ThreadMetadataStore::global(cx);
9785
9786 let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
9787 .contents
9788 .entries
9789 .iter()
9790 .filter_map(|entry| entry.session_id().cloned())
9791 .collect();
9792
9793 let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
9794
9795 // Query using the same approach as the sidebar: iterate project
9796 // group keys, then do main + legacy queries per group.
9797 let mw = multi_workspace.read(cx);
9798 let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
9799 HashMap::default();
9800 for workspace in &workspaces {
9801 let key = workspace.read(cx).project_group_key(cx);
9802 workspaces_by_group
9803 .entry(key)
9804 .or_default()
9805 .push(workspace.clone());
9806 }
9807
9808 for group_key in mw.project_group_keys() {
9809 let path_list = group_key.path_list().clone();
9810 if path_list.paths().is_empty() {
9811 continue;
9812 }
9813
9814 let group_workspaces = workspaces_by_group
9815 .get(&group_key)
9816 .map(|ws| ws.as_slice())
9817 .unwrap_or_default();
9818
9819 // Main code path queries (run for all groups, even without workspaces).
9820 // Skip drafts (session_id: None) — they are not shown in the
9821 // sidebar entries.
9822 for metadata in thread_store
9823 .read(cx)
9824 .entries_for_main_worktree_path(&path_list, None)
9825 {
9826 if let Some(sid) = metadata.session_id.clone() {
9827 metadata_thread_ids.insert(sid);
9828 }
9829 }
9830 for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
9831 if let Some(sid) = metadata.session_id.clone() {
9832 metadata_thread_ids.insert(sid);
9833 }
9834 }
9835
9836 // Legacy: per-workspace queries for different root paths.
9837 let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
9838 .iter()
9839 .flat_map(|ws| {
9840 ws.read(cx)
9841 .root_paths(cx)
9842 .into_iter()
9843 .map(|p| p.to_path_buf())
9844 })
9845 .collect();
9846
9847 for workspace in group_workspaces {
9848 let ws_path_list = workspace_path_list(workspace, cx);
9849 if ws_path_list != path_list {
9850 for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
9851 if let Some(sid) = metadata.session_id.clone() {
9852 metadata_thread_ids.insert(sid);
9853 }
9854 }
9855 }
9856 }
9857
9858 for workspace in group_workspaces {
9859 for snapshot in root_repository_snapshots(workspace, cx) {
9860 let Some(main_worktree_abs_path) = snapshot.main_worktree_abs_path() else {
9861 continue;
9862 };
9863 let repo_path_list = PathList::new(&[main_worktree_abs_path.to_path_buf()]);
9864 if repo_path_list != path_list {
9865 continue;
9866 }
9867 for linked_worktree in snapshot.linked_worktrees() {
9868 if covered_paths.contains(&*linked_worktree.path) {
9869 continue;
9870 }
9871 let worktree_path_list =
9872 PathList::new(std::slice::from_ref(&linked_worktree.path));
9873 for metadata in thread_store
9874 .read(cx)
9875 .entries_for_path(&worktree_path_list, None)
9876 {
9877 if let Some(sid) = metadata.session_id.clone() {
9878 metadata_thread_ids.insert(sid);
9879 }
9880 }
9881 }
9882 }
9883 }
9884 }
9885
9886 anyhow::ensure!(
9887 sidebar_thread_ids == metadata_thread_ids,
9888 "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
9889 sidebar_thread_ids,
9890 metadata_thread_ids,
9891 );
9892 Ok(())
9893 }
9894
9895 fn verify_active_state_matches_current_workspace(
9896 sidebar: &Sidebar,
9897 cx: &App,
9898 ) -> anyhow::Result<()> {
9899 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
9900 anyhow::bail!("sidebar should still have an associated multi-workspace");
9901 };
9902
9903 let active_workspace = multi_workspace.read(cx).workspace();
9904
9905 // 1. active_entry should be Some when the panel has content.
9906 // It may be None when the panel is uninitialized (no drafts,
9907 // no threads), which is fine.
9908 // It may also temporarily point at a different workspace
9909 // when the workspace just changed and the new panel has no
9910 // content yet.
9911 let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
9912 let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
9913 || panel.read(cx).active_conversation_view().is_some();
9914
9915 let Some(entry) = sidebar.active_entry.as_ref() else {
9916 if panel_has_content {
9917 anyhow::bail!("active_entry is None but panel has content (draft or thread)");
9918 }
9919 return Ok(());
9920 };
9921
9922 // If the entry workspace doesn't match the active workspace
9923 // and the panel has no content, this is a transient state that
9924 // will resolve when the panel gets content.
9925 if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
9926 return Ok(());
9927 }
9928
9929 // 2. The entry's workspace must agree with the multi-workspace's
9930 // active workspace.
9931 anyhow::ensure!(
9932 entry.workspace().entity_id() == active_workspace.entity_id(),
9933 "active_entry workspace ({:?}) != active workspace ({:?})",
9934 entry.workspace().entity_id(),
9935 active_workspace.entity_id(),
9936 );
9937
9938 // 3. The entry must match the agent panel's current state.
9939 if panel.read(cx).active_thread_id(cx).is_some() {
9940 anyhow::ensure!(
9941 matches!(entry, ActiveEntry::Thread { .. }),
9942 "panel shows a tracked draft but active_entry is {:?}",
9943 entry,
9944 );
9945 } else if let Some(thread_id) = panel
9946 .read(cx)
9947 .active_conversation_view()
9948 .map(|cv| cv.read(cx).parent_id())
9949 {
9950 anyhow::ensure!(
9951 matches!(entry, ActiveEntry::Thread { thread_id: tid, .. } if *tid == thread_id),
9952 "panel has thread {:?} but active_entry is {:?}",
9953 thread_id,
9954 entry,
9955 );
9956 }
9957
9958 // 4. Exactly one entry in sidebar contents must be uniquely
9959 // identified by the active_entry — unless the panel is showing
9960 // a draft, which is represented by the + button's active state
9961 // rather than a sidebar row.
9962 // TODO: Make this check more complete
9963 // Active terminals must still match a row, so don't treat the absence
9964 // of a conversation view as "draft" when a terminal is active.
9965 let is_draft = panel.read(cx).active_terminal_id().is_none()
9966 && (panel.read(cx).active_thread_is_draft(cx)
9967 || panel.read(cx).active_conversation_view().is_none());
9968 if is_draft {
9969 return Ok(());
9970 }
9971 let matching_count = sidebar
9972 .contents
9973 .entries
9974 .iter()
9975 .filter(|e| entry.matches_entry(e))
9976 .count();
9977 if matching_count != 1 {
9978 let thread_entries: Vec<_> = sidebar
9979 .contents
9980 .entries
9981 .iter()
9982 .filter_map(|e| match e {
9983 ListEntry::Thread(t) => Some(format!(
9984 "tid={:?} sid={:?}",
9985 t.metadata.thread_id, t.metadata.session_id
9986 )),
9987 _ => None,
9988 })
9989 .collect();
9990 let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
9991 let store_entries: Vec<_> = store
9992 .entries()
9993 .map(|m| {
9994 format!(
9995 "tid={:?} sid={:?} archived={} paths={:?}",
9996 m.thread_id,
9997 m.session_id,
9998 m.archived,
9999 m.folder_paths()
10000 )
10001 })
10002 .collect();
10003 anyhow::bail!(
10004 "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
10005 entry,
10006 matching_count,
10007 thread_entries,
10008 store_entries,
10009 );
10010 }
10011
10012 Ok(())
10013 }
10014
10015 /// Every workspace in the multi-workspace should be "reachable" from
10016 /// the sidebar — meaning there is at least one entry (thread, draft,
10017 /// new-thread, or project header) that, when clicked, would activate
10018 /// that workspace.
10019 fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
10020 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
10021 anyhow::bail!("sidebar should still have an associated multi-workspace");
10022 };
10023
10024 let multi_workspace = multi_workspace.read(cx);
10025
10026 let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
10027 .contents
10028 .entries
10029 .iter()
10030 .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
10031 .map(|ws| ws.entity_id())
10032 .collect();
10033
10034 let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
10035 .workspaces()
10036 .map(|ws| ws.entity_id())
10037 .collect();
10038
10039 let unreachable = &all_workspace_ids - &reachable_workspaces;
10040
10041 anyhow::ensure!(
10042 unreachable.is_empty(),
10043 "The following workspaces are not reachable from any sidebar entry: {:?}",
10044 unreachable,
10045 );
10046
10047 Ok(())
10048 }
10049
10050 fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
10051 let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
10052 anyhow::bail!("sidebar should still have an associated multi-workspace");
10053 };
10054 multi_workspace
10055 .read(cx)
10056 .assert_project_group_key_integrity(cx)
10057 }
10058
10059 #[gpui::property_test(config = ProptestConfig {
10060 cases: 20,
10061 ..Default::default()
10062 })]
10063 async fn test_sidebar_invariants(
10064 #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
10065 raw_operations: Vec<u32>,
10066 cx: &mut TestAppContext,
10067 ) {
10068 use std::sync::atomic::{AtomicUsize, Ordering};
10069 static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
10070
10071 agent_ui::test_support::init_test(cx);
10072 cx.update(|cx| {
10073 cx.set_global(db::AppDatabase::test_new());
10074 cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
10075 cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
10076 format!(
10077 "PROPTEST_THREAD_METADATA_{}",
10078 NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
10079 ),
10080 ));
10081
10082 ThreadStore::init_global(cx);
10083 ThreadMetadataStore::init_global(cx);
10084 language_model::LanguageModelRegistry::test(cx);
10085 prompt_store::init(cx);
10086
10087 // Auto-add an AgentPanel to every workspace so that implicitly
10088 // created workspaces (e.g. from thread activation) also have one.
10089 cx.observe_new(
10090 |workspace: &mut Workspace,
10091 window: Option<&mut Window>,
10092 cx: &mut gpui::Context<Workspace>| {
10093 if let Some(window) = window {
10094 let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
10095 workspace.add_panel(panel, window, cx);
10096 }
10097 },
10098 )
10099 .detach();
10100 });
10101
10102 let fs = FakeFs::new(cx.executor());
10103 fs.insert_tree(
10104 "/my-project",
10105 serde_json::json!({
10106 ".git": {},
10107 "src": {},
10108 }),
10109 )
10110 .await;
10111 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10112 let project =
10113 project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
10114 .await;
10115 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10116
10117 let (multi_workspace, cx) =
10118 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10119 let sidebar = setup_sidebar(&multi_workspace, cx);
10120
10121 let mut state = TestState::new(fs);
10122 let mut executed: Vec<String> = Vec::new();
10123
10124 for &raw_op in &raw_operations {
10125 let project_group_count =
10126 multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
10127 let operation = state.generate_operation(raw_op, project_group_count);
10128 executed.push(format!("{:?}", operation));
10129 perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
10130 cx.run_until_parked();
10131
10132 update_sidebar(&sidebar, cx);
10133 cx.run_until_parked();
10134
10135 let result =
10136 sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
10137 if let Err(err) = result {
10138 let log = executed.join("\n ");
10139 panic!(
10140 "Property violation after step {}:\n{err}\n\nOperations:\n {log}",
10141 executed.len(),
10142 );
10143 }
10144 }
10145 }
10146}
10147
10148#[gpui::test]
10149async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
10150 cx: &mut TestAppContext,
10151 server_cx: &mut TestAppContext,
10152) {
10153 init_test(cx);
10154
10155 cx.update(|cx| {
10156 release_channel::init(semver::Version::new(0, 0, 0), cx);
10157 });
10158
10159 let app_state = cx.update(|cx| {
10160 let app_state = workspace::AppState::test(cx);
10161 workspace::init(app_state.clone(), cx);
10162 app_state
10163 });
10164
10165 // Set up the remote server side.
10166 let server_fs = FakeFs::new(server_cx.executor());
10167 server_fs
10168 .insert_tree(
10169 "/project",
10170 serde_json::json!({
10171 ".git": {},
10172 "src": { "main.rs": "fn main() {}" }
10173 }),
10174 )
10175 .await;
10176 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10177
10178 // Create the linked worktree checkout path on the remote server,
10179 // but do not yet register it as a git-linked worktree. The real
10180 // regrouping update in this test should happen only after the
10181 // sidebar opens the closed remote thread.
10182 server_fs
10183 .insert_tree(
10184 "/project-wt-1",
10185 serde_json::json!({
10186 "src": { "main.rs": "fn main() {}" }
10187 }),
10188 )
10189 .await;
10190
10191 server_cx.update(|cx| {
10192 release_channel::init(semver::Version::new(0, 0, 0), cx);
10193 });
10194
10195 let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
10196
10197 server_cx.update(remote_server::HeadlessProject::init);
10198 let server_executor = server_cx.executor();
10199 let _headless = server_cx.new(|cx| {
10200 remote_server::HeadlessProject::new(
10201 remote_server::HeadlessAppState {
10202 session: server_session,
10203 fs: server_fs.clone(),
10204 http_client: Arc::new(http_client::BlockedHttpClient),
10205 node_runtime: node_runtime::NodeRuntime::unavailable(),
10206 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10207 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10208 startup_time: std::time::Instant::now(),
10209 },
10210 false,
10211 cx,
10212 )
10213 });
10214
10215 // Connect the client side and build a remote project.
10216 let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
10217 let project = cx.update(|cx| {
10218 let project_client = client::Client::new(
10219 Arc::new(clock::FakeSystemClock::new()),
10220 http_client::FakeHttpClient::with_404_response(),
10221 cx,
10222 );
10223 let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
10224 project::Project::remote(
10225 remote_client,
10226 project_client,
10227 node_runtime::NodeRuntime::unavailable(),
10228 user_store,
10229 app_state.languages.clone(),
10230 app_state.fs.clone(),
10231 false,
10232 cx,
10233 )
10234 });
10235
10236 // Open the remote worktree.
10237 project
10238 .update(cx, |project, cx| {
10239 project.find_or_create_worktree(Path::new("/project"), true, cx)
10240 })
10241 .await
10242 .expect("should open remote worktree");
10243 cx.run_until_parked();
10244
10245 // Verify the project is remote.
10246 project.read_with(cx, |project, cx| {
10247 assert!(!project.is_local(), "project should be remote");
10248 assert!(
10249 project.remote_connection_options(cx).is_some(),
10250 "project should have remote connection options"
10251 );
10252 });
10253
10254 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10255
10256 // Create MultiWorkspace with the remote project.
10257 let (multi_workspace, cx) =
10258 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10259 let sidebar = setup_sidebar(&multi_workspace, cx);
10260
10261 cx.run_until_parked();
10262
10263 // Save a thread for the main remote workspace (folder_paths match
10264 // the open workspace, so it will be classified as Open).
10265 let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
10266 save_thread_metadata(
10267 main_thread_id.clone(),
10268 Some("Main Thread".into()),
10269 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10270 None,
10271 None,
10272 &project,
10273 cx,
10274 );
10275 cx.run_until_parked();
10276
10277 // Save a thread whose folder_paths point to a linked worktree path
10278 // that doesn't have an open workspace ("/project-wt-1"), but whose
10279 // main_worktree_paths match the project group key so it appears
10280 // in the sidebar under the same remote group. This simulates a
10281 // linked worktree workspace that was closed.
10282 let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10283 let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
10284 (
10285 p.project_group_key(cx).path_list().clone(),
10286 p.remote_connection_options(cx),
10287 )
10288 });
10289 cx.update(|_window, cx| {
10290 let metadata = ThreadMetadata {
10291 thread_id: ThreadId::new(),
10292 session_id: Some(remote_thread_id.clone()),
10293 agent_id: agent::ZED_AGENT_ID.clone(),
10294 title: Some("Worktree Thread".into()),
10295 updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10296 created_at: None,
10297 interacted_at: None,
10298 worktree_paths: WorktreePaths::from_path_lists(
10299 main_worktree_paths,
10300 PathList::new(&[PathBuf::from("/project-wt-1")]),
10301 )
10302 .unwrap(),
10303 archived: false,
10304 remote_connection,
10305 };
10306 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10307 });
10308 cx.run_until_parked();
10309
10310 focus_sidebar(&sidebar, cx);
10311 sidebar.update_in(cx, |sidebar, _window, _cx| {
10312 sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10313 matches!(
10314 entry,
10315 ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10316 )
10317 });
10318 });
10319
10320 let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10321 let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10322
10323 sidebar
10324 .update(cx, |_, cx| {
10325 cx.observe_self(move |sidebar, _cx| {
10326 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10327 if let ListEntry::ProjectHeader { label, .. } = entry {
10328 Some(label.as_ref())
10329 } else {
10330 None
10331 }
10332 });
10333
10334 let Some(project_header) = project_headers.next() else {
10335 saw_separate_project_header_for_observer
10336 .store(true, std::sync::atomic::Ordering::SeqCst);
10337 return;
10338 };
10339
10340 if project_header != "project" || project_headers.next().is_some() {
10341 saw_separate_project_header_for_observer
10342 .store(true, std::sync::atomic::Ordering::SeqCst);
10343 }
10344 })
10345 })
10346 .detach();
10347
10348 multi_workspace.update(cx, |multi_workspace, cx| {
10349 let workspace = multi_workspace.workspace().clone();
10350 workspace.update(cx, |workspace: &mut Workspace, cx| {
10351 let remote_client = workspace
10352 .project()
10353 .read(cx)
10354 .remote_client()
10355 .expect("main remote project should have a remote client");
10356 remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10357 remote_client.force_server_not_running(cx);
10358 });
10359 });
10360 });
10361 cx.run_until_parked();
10362
10363 let (server_session_2, connect_guard_2) =
10364 remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10365 let _headless_2 = server_cx.new(|cx| {
10366 remote_server::HeadlessProject::new(
10367 remote_server::HeadlessAppState {
10368 session: server_session_2,
10369 fs: server_fs.clone(),
10370 http_client: Arc::new(http_client::BlockedHttpClient),
10371 node_runtime: node_runtime::NodeRuntime::unavailable(),
10372 languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10373 extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10374 startup_time: std::time::Instant::now(),
10375 },
10376 false,
10377 cx,
10378 )
10379 });
10380 drop(connect_guard_2);
10381
10382 let window = cx.windows()[0];
10383 cx.update_window(window, |_, window, cx| {
10384 window.dispatch_action(Confirm.boxed_clone(), cx);
10385 })
10386 .unwrap();
10387
10388 cx.run_until_parked();
10389
10390 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10391 assert_eq!(
10392 mw.workspaces().count(),
10393 2,
10394 "confirming a closed remote thread should open a second workspace"
10395 );
10396 mw.workspaces()
10397 .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10398 .unwrap()
10399 .clone()
10400 });
10401
10402 server_fs
10403 .add_linked_worktree_for_repo(
10404 Path::new("/project/.git"),
10405 true,
10406 git::repository::Worktree {
10407 path: PathBuf::from("/project-wt-1"),
10408 ref_name: Some("refs/heads/feature-wt".into()),
10409 sha: "abc123".into(),
10410 is_main: false,
10411 is_bare: false,
10412 },
10413 )
10414 .await;
10415
10416 server_cx.run_until_parked();
10417 cx.run_until_parked();
10418 server_cx.run_until_parked();
10419 cx.run_until_parked();
10420
10421 let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10422 let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10423 workspace.project().read(cx).project_group_key(cx)
10424 });
10425
10426 assert_eq!(
10427 group_after_update,
10428 project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10429 "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10430 final sidebar entries: {:?}",
10431 entries_after_update,
10432 );
10433
10434 sidebar.update(cx, |sidebar, _cx| {
10435 assert_remote_project_integration_sidebar_state(
10436 sidebar,
10437 &main_thread_id,
10438 &remote_thread_id,
10439 );
10440 });
10441
10442 assert!(
10443 !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10444 "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10445 final group: {:?}; final sidebar entries: {:?}",
10446 group_after_update,
10447 entries_after_update,
10448 );
10449}
10450
10451#[gpui::test]
10452async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10453 // When the thread's folder_paths don't exactly match any workspace's
10454 // root paths (e.g. because a folder was added to the workspace after
10455 // the thread was created), workspace_to_remove is None. But the linked
10456 // worktree workspace still needs to be removed so that its worktree
10457 // entities are released, allowing git worktree removal to proceed.
10458 //
10459 // With the fix, archive_thread scans roots_to_archive for any linked
10460 // worktree workspaces and includes them in the removal set, even when
10461 // the thread's folder_paths don't match the workspace's root paths.
10462 init_test(cx);
10463 let fs = FakeFs::new(cx.executor());
10464
10465 fs.insert_tree(
10466 "/project",
10467 serde_json::json!({
10468 ".git": {
10469 "worktrees": {
10470 "feature-a": {
10471 "commondir": "../../",
10472 "HEAD": "ref: refs/heads/feature-a",
10473 },
10474 },
10475 },
10476 "src": {},
10477 }),
10478 )
10479 .await;
10480
10481 fs.insert_tree(
10482 "/worktrees/project/feature-a/project",
10483 serde_json::json!({
10484 ".git": "gitdir: /project/.git/worktrees/feature-a",
10485 "src": {
10486 "main.rs": "fn main() {}",
10487 },
10488 }),
10489 )
10490 .await;
10491
10492 fs.add_linked_worktree_for_repo(
10493 Path::new("/project/.git"),
10494 false,
10495 git::repository::Worktree {
10496 path: PathBuf::from("/worktrees/project/feature-a/project"),
10497 ref_name: Some("refs/heads/feature-a".into()),
10498 sha: "abc".into(),
10499 is_main: false,
10500 is_bare: false,
10501 },
10502 )
10503 .await;
10504
10505 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10506
10507 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10508 let worktree_project = project::Project::test(
10509 fs.clone(),
10510 ["/worktrees/project/feature-a/project".as_ref()],
10511 cx,
10512 )
10513 .await;
10514
10515 main_project
10516 .update(cx, |p, cx| p.git_scans_complete(cx))
10517 .await;
10518 worktree_project
10519 .update(cx, |p, cx| p.git_scans_complete(cx))
10520 .await;
10521
10522 let (multi_workspace, cx) =
10523 cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10524 let sidebar = setup_sidebar(&multi_workspace, cx);
10525
10526 multi_workspace.update_in(cx, |mw, window, cx| {
10527 mw.test_add_workspace(worktree_project.clone(), window, cx)
10528 });
10529
10530 // Save thread metadata using folder_paths that DON'T match the
10531 // workspace's root paths. This simulates the case where the workspace's
10532 // paths diverged (e.g. a folder was added after thread creation).
10533 // This causes workspace_to_remove to be None because
10534 // workspace_for_paths can't find a workspace with these exact paths.
10535 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10536 save_thread_metadata_with_main_paths(
10537 "worktree-thread",
10538 "Worktree Thread",
10539 PathList::new(&[
10540 PathBuf::from("/worktrees/project/feature-a/project"),
10541 PathBuf::from("/nonexistent"),
10542 ]),
10543 PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10544 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10545 cx,
10546 );
10547
10548 // Also save a main thread so the sidebar has something to show.
10549 save_thread_metadata(
10550 acp::SessionId::new(Arc::from("main-thread")),
10551 Some("Main Thread".into()),
10552 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10553 None,
10554 None,
10555 &main_project,
10556 cx,
10557 );
10558 cx.run_until_parked();
10559
10560 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10561 cx.run_until_parked();
10562
10563 assert_eq!(
10564 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10565 2,
10566 "should start with 2 workspaces (main + linked worktree)"
10567 );
10568
10569 // Archive the worktree thread.
10570 sidebar.update_in(cx, |sidebar, window, cx| {
10571 sidebar.archive_thread(&wt_thread_id, window, cx);
10572 });
10573
10574 cx.run_until_parked();
10575
10576 // The linked worktree workspace should have been removed, even though
10577 // workspace_to_remove was None (paths didn't match).
10578 assert_eq!(
10579 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10580 1,
10581 "linked worktree workspace should be removed after archiving, \
10582 even when folder_paths don't match workspace root paths"
10583 );
10584
10585 // The thread should still be archived (not unarchived due to an error).
10586 let still_archived = cx.update(|_, cx| {
10587 ThreadMetadataStore::global(cx)
10588 .read(cx)
10589 .entry_by_session(&wt_thread_id)
10590 .map(|t| t.archived)
10591 });
10592 assert_eq!(
10593 still_archived,
10594 Some(true),
10595 "thread should still be archived (not rolled back due to error)"
10596 );
10597
10598 // The linked worktree directory should be removed from disk.
10599 assert!(
10600 !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
10601 .await,
10602 "linked worktree directory should be removed from disk"
10603 );
10604}
10605
10606#[gpui::test]
10607async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10608 // When a workspace contains both a worktree being archived and other
10609 // worktrees that should remain, only the editor items referencing the
10610 // archived worktree should be closed — the workspace itself must be
10611 // preserved.
10612 init_test(cx);
10613 let fs = FakeFs::new(cx.executor());
10614
10615 fs.insert_tree(
10616 "/main-repo",
10617 serde_json::json!({
10618 ".git": {
10619 "worktrees": {
10620 "feature-b": {
10621 "commondir": "../../",
10622 "HEAD": "ref: refs/heads/feature-b",
10623 },
10624 },
10625 },
10626 "src": {
10627 "lib.rs": "pub fn hello() {}",
10628 },
10629 }),
10630 )
10631 .await;
10632
10633 fs.insert_tree(
10634 "/worktrees/main-repo/feature-b/main-repo",
10635 serde_json::json!({
10636 ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10637 "src": {
10638 "main.rs": "fn main() { hello(); }",
10639 },
10640 }),
10641 )
10642 .await;
10643
10644 fs.add_linked_worktree_for_repo(
10645 Path::new("/main-repo/.git"),
10646 false,
10647 git::repository::Worktree {
10648 path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10649 ref_name: Some("refs/heads/feature-b".into()),
10650 sha: "def".into(),
10651 is_main: false,
10652 is_bare: false,
10653 },
10654 )
10655 .await;
10656
10657 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10658
10659 // Create a single project that contains BOTH the main repo and the
10660 // linked worktree — this makes it a "mixed" workspace.
10661 let mixed_project = project::Project::test(
10662 fs.clone(),
10663 [
10664 "/main-repo".as_ref(),
10665 "/worktrees/main-repo/feature-b/main-repo".as_ref(),
10666 ],
10667 cx,
10668 )
10669 .await;
10670
10671 mixed_project
10672 .update(cx, |p, cx| p.git_scans_complete(cx))
10673 .await;
10674
10675 let (multi_workspace, cx) = cx
10676 .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10677 let sidebar = setup_sidebar(&multi_workspace, cx);
10678
10679 // Open editor items in both worktrees so we can verify which ones
10680 // get closed.
10681 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10682
10683 let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10684 ws.project()
10685 .read(cx)
10686 .visible_worktrees(cx)
10687 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10688 .collect()
10689 });
10690
10691 let main_repo_wt_id = worktree_ids
10692 .iter()
10693 .find(|(_, path)| path.as_ref() == Path::new("/main-repo"))
10694 .map(|(id, _)| *id)
10695 .expect("should find main-repo worktree");
10696
10697 let feature_b_wt_id = worktree_ids
10698 .iter()
10699 .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo"))
10700 .map(|(id, _)| *id)
10701 .expect("should find feature-b worktree");
10702
10703 // Open files from both worktrees.
10704 let main_repo_path = project::ProjectPath {
10705 worktree_id: main_repo_wt_id,
10706 path: Arc::from(rel_path("src/lib.rs")),
10707 };
10708 let feature_b_path = project::ProjectPath {
10709 worktree_id: feature_b_wt_id,
10710 path: Arc::from(rel_path("src/main.rs")),
10711 };
10712
10713 workspace
10714 .update_in(cx, |ws, window, cx| {
10715 ws.open_path(main_repo_path.clone(), None, true, window, cx)
10716 })
10717 .await
10718 .expect("should open main-repo file");
10719 workspace
10720 .update_in(cx, |ws, window, cx| {
10721 ws.open_path(feature_b_path.clone(), None, true, window, cx)
10722 })
10723 .await
10724 .expect("should open feature-b file");
10725
10726 cx.run_until_parked();
10727
10728 // Verify both items are open.
10729 let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10730 ws.panes()
10731 .iter()
10732 .flat_map(|pane| {
10733 pane.read(cx)
10734 .items()
10735 .filter_map(|item| item.project_path(cx))
10736 })
10737 .collect()
10738 });
10739 assert!(
10740 open_paths_before
10741 .iter()
10742 .any(|pp| pp.worktree_id == main_repo_wt_id),
10743 "main-repo file should be open"
10744 );
10745 assert!(
10746 open_paths_before
10747 .iter()
10748 .any(|pp| pp.worktree_id == feature_b_wt_id),
10749 "feature-b file should be open"
10750 );
10751
10752 // Save thread metadata for the linked worktree with deliberately
10753 // mismatched folder_paths to trigger the scan-based detection.
10754 save_thread_metadata_with_main_paths(
10755 "feature-b-thread",
10756 "Feature B Thread",
10757 PathList::new(&[
10758 PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10759 PathBuf::from("/nonexistent"),
10760 ]),
10761 PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10762 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10763 cx,
10764 );
10765
10766 // Save another thread that references only the main repo (not the
10767 // linked worktree) so archiving the feature-b thread's worktree isn't
10768 // blocked by another unarchived thread referencing the same path.
10769 save_thread_metadata_with_main_paths(
10770 "other-thread",
10771 "Other Thread",
10772 PathList::new(&[PathBuf::from("/main-repo")]),
10773 PathList::new(&[PathBuf::from("/main-repo")]),
10774 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10775 cx,
10776 );
10777 cx.run_until_parked();
10778
10779 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10780 cx.run_until_parked();
10781
10782 // There should still be exactly 1 workspace.
10783 assert_eq!(
10784 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10785 1,
10786 "should have 1 workspace (the mixed workspace)"
10787 );
10788
10789 // Archive the feature-b thread.
10790 let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10791 sidebar.update_in(cx, |sidebar, window, cx| {
10792 sidebar.archive_thread(&fb_session_id, window, cx);
10793 });
10794
10795 cx.run_until_parked();
10796
10797 // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10798 assert_eq!(
10799 multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10800 1,
10801 "mixed workspace should be preserved"
10802 );
10803
10804 // Only the feature-b editor item should have been closed.
10805 let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10806 ws.panes()
10807 .iter()
10808 .flat_map(|pane| {
10809 pane.read(cx)
10810 .items()
10811 .filter_map(|item| item.project_path(cx))
10812 })
10813 .collect()
10814 });
10815 assert!(
10816 open_paths_after
10817 .iter()
10818 .any(|pp| pp.worktree_id == main_repo_wt_id),
10819 "main-repo file should still be open"
10820 );
10821 assert!(
10822 !open_paths_after
10823 .iter()
10824 .any(|pp| pp.worktree_id == feature_b_wt_id),
10825 "feature-b file should have been closed"
10826 );
10827}
10828
10829#[test]
10830fn test_worktree_info_branch_names_for_main_worktrees() {
10831 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10832 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10833
10834 let branch_by_path: HashMap<PathBuf, SharedString> =
10835 [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10836 .into_iter()
10837 .collect();
10838
10839 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10840 assert_eq!(infos.len(), 1);
10841 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10842 assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10843 assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10844}
10845
10846#[test]
10847fn test_worktree_info_branch_names_for_linked_worktrees() {
10848 let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10849 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10850 let worktree_paths =
10851 WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10852
10853 let branch_by_path: HashMap<PathBuf, SharedString> = [(
10854 PathBuf::from("/projects/myapp-feature"),
10855 "feature-branch".into(),
10856 )]
10857 .into_iter()
10858 .collect();
10859
10860 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10861 assert_eq!(infos.len(), 1);
10862 assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10863 assert_eq!(
10864 infos[0].branch_name,
10865 Some(SharedString::from("feature-branch"))
10866 );
10867}
10868
10869#[test]
10870fn test_worktree_info_missing_branch_returns_none() {
10871 let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10872 let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10873
10874 let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10875
10876 let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10877 assert_eq!(infos.len(), 1);
10878 assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10879 assert_eq!(infos[0].branch_name, None);
10880 assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10881}
10882
10883#[gpui::test]
10884async fn test_remote_archive_thread_with_active_connection(
10885 cx: &mut TestAppContext,
10886 server_cx: &mut TestAppContext,
10887) {
10888 // End-to-end test of archiving a remote thread tied to a linked git
10889 // worktree. Archival should:
10890 // 1. Persist the worktree's git state via the remote repository RPCs
10891 // (head_sha / create_archive_checkpoint / update_ref).
10892 // 2. Remove the linked worktree directory from the *remote* filesystem
10893 // via the GitRemoveWorktree RPC.
10894 // 3. Mark the thread metadata archived and hide it from the sidebar.
10895 //
10896 // The mock remote transport only supports one live `RemoteClient` per
10897 // connection at a time (each client's `start_proxy` replaces the
10898 // previous server channel), so we can't split the main repo and the
10899 // linked worktree across two remote projects the way Zed does in
10900 // production. Opening both as visible worktrees of a single remote
10901 // project still exercises every interesting path of the archive flow
10902 // while staying within the mock's multiplexing limits.
10903 init_test(cx);
10904
10905 cx.update(|cx| {
10906 release_channel::init(semver::Version::new(0, 0, 0), cx);
10907 });
10908
10909 let app_state = cx.update(|cx| {
10910 let app_state = workspace::AppState::test(cx);
10911 workspace::init(app_state.clone(), cx);
10912 app_state
10913 });
10914
10915 server_cx.update(|cx| {
10916 release_channel::init(semver::Version::new(0, 0, 0), cx);
10917 });
10918
10919 // Set up the remote filesystem with a main repo and one linked worktree.
10920 let server_fs = FakeFs::new(server_cx.executor());
10921 server_fs
10922 .insert_tree(
10923 "/project",
10924 serde_json::json!({
10925 ".git": {
10926 "worktrees": {
10927 "feature-a": {
10928 "commondir": "../../",
10929 "HEAD": "ref: refs/heads/feature-a",
10930 },
10931 },
10932 },
10933 "src": { "main.rs": "fn main() {}" },
10934 }),
10935 )
10936 .await;
10937 server_fs
10938 .insert_tree(
10939 "/worktrees/project/feature-a/project",
10940 serde_json::json!({
10941 ".git": "gitdir: /project/.git/worktrees/feature-a",
10942 "src": { "lib.rs": "// feature" },
10943 }),
10944 )
10945 .await;
10946 server_fs
10947 .add_linked_worktree_for_repo(
10948 Path::new("/project/.git"),
10949 false,
10950 git::repository::Worktree {
10951 path: PathBuf::from("/worktrees/project/feature-a/project"),
10952 ref_name: Some("refs/heads/feature-a".into()),
10953 sha: "abc".into(),
10954 is_main: false,
10955 is_bare: false,
10956 },
10957 )
10958 .await;
10959 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10960 server_fs.set_head_for_repo(
10961 Path::new("/project/.git"),
10962 &[("src/main.rs", "fn main() {}".into())],
10963 "head-sha",
10964 );
10965
10966 // Open a single remote project with both the main repo and the linked
10967 // worktree as visible worktrees. The mock transport doesn't multiplex
10968 // multiple `RemoteClient`s over one pooled connection cleanly (each
10969 // client's `start_proxy` clobbers the previous one's server channel),
10970 // so we can't build two separate `Project::remote` instances in this
10971 // test. Folding both worktrees into one project still exercises the
10972 // archive flow's interesting paths: `build_root_plan` classifies the
10973 // linked worktree correctly, and `find_or_create_repository` finds
10974 // the main repo live on that same project — avoiding the temp-project
10975 // fallback that would also run into the multiplexing limitation.
10976 let (project, _headless, _opts) = start_remote_project(
10977 &server_fs,
10978 Path::new("/project"),
10979 &app_state,
10980 None,
10981 cx,
10982 server_cx,
10983 )
10984 .await;
10985 project
10986 .update(cx, |project, cx| {
10987 project.find_or_create_worktree(
10988 Path::new("/worktrees/project/feature-a/project"),
10989 true,
10990 cx,
10991 )
10992 })
10993 .await
10994 .expect("should open linked worktree on remote");
10995 project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10996 cx.run_until_parked();
10997
10998 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10999
11000 let (multi_workspace, cx) =
11001 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11002 let sidebar = setup_sidebar(&multi_workspace, cx);
11003
11004 // The worktree thread's (main_worktree_path, folder_path) pair points
11005 // the folder at the linked worktree checkout and the main at the
11006 // parent repo, so `build_root_plan` targets the linked worktree
11007 // specifically and knows which main repo owns it.
11008 let remote_connection = project.read_with(cx, |p, cx| p.remote_connection_options(cx));
11009 let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
11010 cx.update(|_window, cx| {
11011 let metadata = ThreadMetadata {
11012 thread_id: ThreadId::new(),
11013 session_id: Some(wt_thread_id.clone()),
11014 agent_id: agent::ZED_AGENT_ID.clone(),
11015 title: Some("Worktree Thread".into()),
11016 updated_at: chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
11017 .unwrap(),
11018 created_at: None,
11019 interacted_at: None,
11020 worktree_paths: WorktreePaths::from_path_lists(
11021 PathList::new(&[PathBuf::from("/project")]),
11022 PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]),
11023 )
11024 .unwrap(),
11025 archived: false,
11026 remote_connection,
11027 };
11028 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
11029 });
11030 cx.run_until_parked();
11031
11032 assert!(
11033 server_fs
11034 .is_dir(Path::new("/worktrees/project/feature-a/project"))
11035 .await,
11036 "linked worktree directory should exist on remote before archiving"
11037 );
11038
11039 sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
11040 sidebar.archive_thread(&wt_thread_id, window, cx);
11041 });
11042 cx.run_until_parked();
11043 server_cx.run_until_parked();
11044
11045 let is_archived = cx.update(|_window, cx| {
11046 ThreadMetadataStore::global(cx)
11047 .read(cx)
11048 .entry_by_session(&wt_thread_id)
11049 .map(|t| t.archived)
11050 .unwrap_or(false)
11051 });
11052 assert!(is_archived, "worktree thread should be archived");
11053
11054 assert!(
11055 !server_fs
11056 .is_dir(Path::new("/worktrees/project/feature-a/project"))
11057 .await,
11058 "linked worktree directory should be removed from remote fs \
11059 (the GitRemoveWorktree RPC runs `Repository::remove_worktree` \
11060 on the headless server, which deletes the directory via `Fs::remove_dir` \
11061 before running `git worktree remove --force`)"
11062 );
11063
11064 let entries = visible_entries_as_strings(&sidebar, cx);
11065 assert!(
11066 !entries.iter().any(|e| e.contains("Worktree Thread")),
11067 "archived worktree thread should be hidden from sidebar: {entries:?}"
11068 );
11069}
11070
11071#[gpui::test]
11072async fn test_remote_archive_thread_with_disconnected_remote(
11073 cx: &mut TestAppContext,
11074 server_cx: &mut TestAppContext,
11075) {
11076 // When a remote thread has no linked-worktree state to archive (only
11077 // a main worktree), archival is a pure metadata operation: no RPCs
11078 // are issued against the remote server. This must succeed even when
11079 // the connection has dropped out, because losing connectivity should
11080 // not block users from cleaning up their thread list.
11081 //
11082 // Threads that *do* have linked-worktree state require a live
11083 // connection to run the git worktree removal on the server; that
11084 // path is covered by `test_remote_archive_thread_with_active_connection`.
11085 init_test(cx);
11086
11087 cx.update(|cx| {
11088 release_channel::init(semver::Version::new(0, 0, 0), cx);
11089 });
11090
11091 let app_state = cx.update(|cx| {
11092 let app_state = workspace::AppState::test(cx);
11093 workspace::init(app_state.clone(), cx);
11094 app_state
11095 });
11096
11097 server_cx.update(|cx| {
11098 release_channel::init(semver::Version::new(0, 0, 0), cx);
11099 });
11100
11101 let server_fs = FakeFs::new(server_cx.executor());
11102 server_fs
11103 .insert_tree(
11104 "/project",
11105 serde_json::json!({
11106 ".git": {},
11107 "src": { "main.rs": "fn main() {}" },
11108 }),
11109 )
11110 .await;
11111 server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
11112
11113 let (project, _headless, _opts) = start_remote_project(
11114 &server_fs,
11115 Path::new("/project"),
11116 &app_state,
11117 None,
11118 cx,
11119 server_cx,
11120 )
11121 .await;
11122 let remote_client = project
11123 .read_with(cx, |project, _cx| project.remote_client())
11124 .expect("remote project should expose its client");
11125
11126 cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
11127
11128 let (multi_workspace, cx) =
11129 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11130 let sidebar = setup_sidebar(&multi_workspace, cx);
11131
11132 let thread_id = acp::SessionId::new(Arc::from("remote-thread"));
11133 save_thread_metadata(
11134 thread_id.clone(),
11135 Some("Remote Thread".into()),
11136 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
11137 None,
11138 None,
11139 &project,
11140 cx,
11141 );
11142 cx.run_until_parked();
11143
11144 // Sanity-check: there is nothing on the remote fs outside the main
11145 // repo, so archival should not need to touch the server.
11146 assert!(
11147 !server_fs.is_dir(Path::new("/worktrees")).await,
11148 "no linked worktrees on the server before archiving"
11149 );
11150
11151 // Disconnect the remote connection before archiving. We don't
11152 // `run_until_parked` here because the disconnect itself triggers
11153 // reconnection work that can't complete in the test environment.
11154 remote_client.update(cx, |client, cx| {
11155 client.simulate_disconnect(cx).detach();
11156 });
11157
11158 sidebar.update_in(cx, |sidebar, window, cx| {
11159 sidebar.archive_thread(&thread_id, window, cx);
11160 });
11161 cx.run_until_parked();
11162
11163 let is_archived = cx.update(|_window, cx| {
11164 ThreadMetadataStore::global(cx)
11165 .read(cx)
11166 .entry_by_session(&thread_id)
11167 .map(|t| t.archived)
11168 .unwrap_or(false)
11169 });
11170 assert!(
11171 is_archived,
11172 "thread should be archived even when remote is disconnected"
11173 );
11174
11175 let entries = visible_entries_as_strings(&sidebar, cx);
11176 assert!(
11177 !entries.iter().any(|e| e.contains("Remote Thread")),
11178 "archived thread should be hidden from sidebar: {entries:?}"
11179 );
11180}
11181
11182#[gpui::test]
11183async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) {
11184 init_test(cx);
11185 let fs = FakeFs::new(cx.executor());
11186 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
11187 .await;
11188 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11189 .await;
11190 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11191 let project = project::Project::test(fs, ["/project-a".as_ref()], cx).await;
11192
11193 let (multi_workspace, cx) =
11194 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11195
11196 // Set up the sidebar while the project is local. This registers the
11197 // WorktreePathsChanged subscription for the project.
11198 let _sidebar = setup_sidebar(&multi_workspace, cx);
11199
11200 let session_id = acp::SessionId::new(Arc::from("test-thread"));
11201 save_named_thread_metadata("test-thread", "My Thread", &project, cx).await;
11202
11203 let thread_id = cx.update(|_window, cx| {
11204 ThreadMetadataStore::global(cx)
11205 .read(cx)
11206 .entry_by_session(&session_id)
11207 .map(|e| e.thread_id)
11208 .expect("thread must be in the store")
11209 });
11210
11211 cx.update(|_window, cx| {
11212 let store = ThreadMetadataStore::global(cx);
11213 let entry = store.read(cx).entry(thread_id).unwrap();
11214 assert_eq!(
11215 entry.folder_paths().paths(),
11216 &[PathBuf::from("/project-a")],
11217 "thread must be saved with /project-a before collab"
11218 );
11219 });
11220
11221 // Transition the project into collab mode. The sidebar's subscription is
11222 // still active from when the project was local.
11223 project.update(cx, |project, _cx| {
11224 project.mark_as_collab_for_testing();
11225 });
11226
11227 // Adding a worktree fires WorktreePathsChanged with old_paths = {/project-a}.
11228 // The sidebar's subscription is still active, so move_thread_paths is called.
11229 // Without the is_via_collab() guard inside move_thread_paths, this would
11230 // update the stored thread paths from {/project-a} to {/project-a, /project-b}.
11231 project
11232 .update(cx, |project, cx| {
11233 project.find_or_create_worktree("/project-b", true, cx)
11234 })
11235 .await
11236 .expect("should add worktree");
11237 cx.run_until_parked();
11238
11239 cx.update(|_window, cx| {
11240 let store = ThreadMetadataStore::global(cx);
11241 let entry = store
11242 .read(cx)
11243 .entry(thread_id)
11244 .expect("thread must still exist");
11245 assert_eq!(
11246 entry.folder_paths().paths(),
11247 &[PathBuf::from("/project-a")],
11248 "thread path must not change when project is via collab"
11249 );
11250 });
11251}
11252
11253#[gpui::test]
11254async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace(
11255 cx: &mut TestAppContext,
11256) {
11257 // Regression test for: cmd-clicking a project group header should return
11258 // the user to the workspace they most recently had active in that group,
11259 // including workspaces rooted at a linked worktree.
11260 init_test(cx);
11261 let fs = FakeFs::new(cx.executor());
11262
11263 fs.insert_tree(
11264 "/project-a",
11265 serde_json::json!({
11266 ".git": {},
11267 "src": {},
11268 }),
11269 )
11270 .await;
11271 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11272 .await;
11273
11274 fs.add_linked_worktree_for_repo(
11275 Path::new("/project-a/.git"),
11276 false,
11277 git::repository::Worktree {
11278 path: std::path::PathBuf::from("/wt-feature-a"),
11279 ref_name: Some("refs/heads/feature-a".into()),
11280 sha: "aaa".into(),
11281 is_main: false,
11282 is_bare: false,
11283 },
11284 )
11285 .await;
11286
11287 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11288
11289 let main_project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
11290 let worktree_project_a =
11291 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
11292 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
11293
11294 main_project_a
11295 .update(cx, |p, cx| p.git_scans_complete(cx))
11296 .await;
11297 worktree_project_a
11298 .update(cx, |p, cx| p.git_scans_complete(cx))
11299 .await;
11300
11301 // The multi-workspace starts with the main-paths workspace of group A
11302 // as the initially active workspace.
11303 let (multi_workspace, cx) = cx
11304 .add_window_view(|window, cx| MultiWorkspace::test_new(main_project_a.clone(), window, cx));
11305
11306 let sidebar = setup_sidebar(&multi_workspace, cx);
11307
11308 // Capture the initially active workspace (group A's main-paths workspace)
11309 // *before* registering additional workspaces, since `workspaces()` returns
11310 // retained workspaces in registration order — not activation order — and
11311 // the multi-workspace's starting workspace may not be retained yet.
11312 let main_workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11313
11314 // Register the linked-worktree workspace (group A) and the group-B
11315 // workspace. Both get retained by the multi-workspace.
11316 let worktree_workspace_a = multi_workspace.update_in(cx, |mw, window, cx| {
11317 mw.test_add_workspace(worktree_project_a.clone(), window, cx)
11318 });
11319 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
11320 mw.test_add_workspace(project_b.clone(), window, cx)
11321 });
11322
11323 cx.run_until_parked();
11324
11325 // Step 1: activate the linked-worktree workspace. The MultiWorkspace
11326 // records this as the last-active workspace for group A on its
11327 // ProjectGroupState. (We don't assert on the initial active workspace
11328 // because `test_add_workspace` may auto-activate newly registered
11329 // workspaces — what matters for this test is the explicit sequence of
11330 // activations below.)
11331 multi_workspace.update_in(cx, |mw, window, cx| {
11332 mw.activate(worktree_workspace_a.clone(), None, window, cx);
11333 });
11334 cx.run_until_parked();
11335 assert_eq!(
11336 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11337 worktree_workspace_a,
11338 "linked-worktree workspace should be active after step 1"
11339 );
11340
11341 // Step 2: switch to the workspace for group B. Group A's last-active
11342 // workspace remains the linked-worktree one (group B getting activated
11343 // records *its own* last-active workspace, not group A's).
11344 multi_workspace.update_in(cx, |mw, window, cx| {
11345 mw.activate(workspace_b.clone(), None, window, cx);
11346 });
11347 cx.run_until_parked();
11348 assert_eq!(
11349 multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11350 workspace_b,
11351 "group B's workspace should be active after step 2"
11352 );
11353
11354 // Step 3: simulate cmd-click on group A's header. The project group key
11355 // for group A is derived from the *main-paths* workspace (linked-worktree
11356 // workspaces share the same key because it normalizes to main-worktree
11357 // paths).
11358 let group_a_key = main_workspace_a.read_with(cx, |ws, cx| ws.project_group_key(cx));
11359 sidebar.update_in(cx, |sidebar, window, cx| {
11360 sidebar.activate_or_open_workspace_for_group(&group_a_key, window, cx);
11361 });
11362 cx.run_until_parked();
11363
11364 // Expected: we're back in the linked-worktree workspace, not the
11365 // main-paths one.
11366 let active_after_cmd_click = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11367 assert_eq!(
11368 active_after_cmd_click, worktree_workspace_a,
11369 "cmd-click on group A's header should return to the last-active \
11370 linked-worktree workspace, not the main-paths workspace"
11371 );
11372 assert_ne!(
11373 active_after_cmd_click, main_workspace_a,
11374 "cmd-click must not fall back to the main-paths workspace when a \
11375 linked-worktree workspace was the last-active one for the group"
11376 );
11377}