sidebar_tests.rs

   1use super::*;
   2use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
   3use agent::ThreadStore;
   4use agent_ui::{
   5    test_support::{active_session_id, open_thread_with_connection, send_message},
   6    thread_metadata_store::{ThreadMetadata, ThreadWorktreePaths},
   7};
   8use chrono::DateTime;
   9use fs::{FakeFs, Fs};
  10use gpui::TestAppContext;
  11use pretty_assertions::assert_eq;
  12use project::AgentId;
  13use settings::SettingsStore;
  14use std::{
  15    path::{Path, PathBuf},
  16    sync::Arc,
  17};
  18use util::path_list::PathList;
  19
  20fn init_test(cx: &mut TestAppContext) {
  21    cx.update(|cx| {
  22        let settings_store = SettingsStore::test(cx);
  23        cx.set_global(settings_store);
  24        theme_settings::init(theme::LoadThemes::JustBase, cx);
  25        editor::init(cx);
  26        ThreadStore::init_global(cx);
  27        ThreadMetadataStore::init_global(cx);
  28        language_model::LanguageModelRegistry::test(cx);
  29        prompt_store::init(cx);
  30    });
  31}
  32
  33#[track_caller]
  34fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
  35    assert!(
  36        sidebar
  37            .active_entry
  38            .as_ref()
  39            .is_some_and(|e| e.is_active_thread(session_id)),
  40        "{msg}: expected active_entry to be Thread({session_id:?}), got {:?}",
  41        sidebar.active_entry,
  42    );
  43}
  44
  45#[track_caller]
  46fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
  47    assert!(
  48        matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == workspace),
  49        "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
  50        workspace.entity_id(),
  51        sidebar.active_entry,
  52    );
  53}
  54
  55fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
  56    sidebar
  57        .contents
  58        .entries
  59        .iter()
  60        .any(|entry| matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id))
  61}
  62
  63#[track_caller]
  64fn assert_remote_project_integration_sidebar_state(
  65    sidebar: &mut Sidebar,
  66    main_thread_id: &acp::SessionId,
  67    remote_thread_id: &acp::SessionId,
  68) {
  69    let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
  70        if let ListEntry::ProjectHeader { label, .. } = entry {
  71            Some(label.as_ref())
  72        } else {
  73            None
  74        }
  75    });
  76
  77    let Some(project_header) = project_headers.next() else {
  78        panic!("expected exactly one sidebar project header named `project`, found none");
  79    };
  80    assert_eq!(
  81        project_header, "project",
  82        "expected the only sidebar project header to be `project`"
  83    );
  84    if let Some(unexpected_header) = project_headers.next() {
  85        panic!(
  86            "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
  87        );
  88    }
  89
  90    let mut saw_main_thread = false;
  91    let mut saw_remote_thread = false;
  92    for entry in &sidebar.contents.entries {
  93        match entry {
  94            ListEntry::ProjectHeader { label, .. } => {
  95                assert_eq!(
  96                    label.as_ref(),
  97                    "project",
  98                    "expected the only sidebar project header to be `project`"
  99                );
 100            }
 101            ListEntry::Thread(thread) if &thread.metadata.session_id == main_thread_id => {
 102                saw_main_thread = true;
 103            }
 104            ListEntry::Thread(thread) if &thread.metadata.session_id == remote_thread_id => {
 105                saw_remote_thread = true;
 106            }
 107            ListEntry::Thread(thread) => {
 108                let title = thread.metadata.title.as_ref();
 109                panic!(
 110                    "unexpected sidebar thread while simulating remote project integration flicker: title=`{title}`"
 111                );
 112            }
 113            ListEntry::ViewMore { .. } => {
 114                panic!(
 115                    "unexpected `View More` entry while simulating remote project integration flicker"
 116                );
 117            }
 118            ListEntry::DraftThread { .. } => {}
 119        }
 120    }
 121
 122    assert!(
 123        saw_main_thread,
 124        "expected the sidebar to keep showing `Main Thread` under `project`"
 125    );
 126    assert!(
 127        saw_remote_thread,
 128        "expected the sidebar to keep showing `Worktree Thread` under `project`"
 129    );
 130}
 131
 132async fn init_test_project(
 133    worktree_path: &str,
 134    cx: &mut TestAppContext,
 135) -> Entity<project::Project> {
 136    init_test(cx);
 137    let fs = FakeFs::new(cx.executor());
 138    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
 139        .await;
 140    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 141    project::Project::test(fs, [worktree_path.as_ref()], cx).await
 142}
 143
 144fn setup_sidebar(
 145    multi_workspace: &Entity<MultiWorkspace>,
 146    cx: &mut gpui::VisualTestContext,
 147) -> Entity<Sidebar> {
 148    let sidebar = setup_sidebar_closed(multi_workspace, cx);
 149    multi_workspace.update_in(cx, |mw, window, cx| {
 150        mw.toggle_sidebar(window, cx);
 151    });
 152    cx.run_until_parked();
 153    sidebar
 154}
 155
 156fn setup_sidebar_closed(
 157    multi_workspace: &Entity<MultiWorkspace>,
 158    cx: &mut gpui::VisualTestContext,
 159) -> Entity<Sidebar> {
 160    let multi_workspace = multi_workspace.clone();
 161    let sidebar =
 162        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
 163    multi_workspace.update(cx, |mw, cx| {
 164        mw.register_sidebar(sidebar.clone(), cx);
 165    });
 166    cx.run_until_parked();
 167    sidebar
 168}
 169
 170async fn save_n_test_threads(
 171    count: u32,
 172    project: &Entity<project::Project>,
 173    cx: &mut gpui::VisualTestContext,
 174) {
 175    for i in 0..count {
 176        save_thread_metadata(
 177            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
 178            format!("Thread {}", i + 1).into(),
 179            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
 180            None,
 181            project,
 182            cx,
 183        )
 184    }
 185    cx.run_until_parked();
 186}
 187
 188async fn save_test_thread_metadata(
 189    session_id: &acp::SessionId,
 190    project: &Entity<project::Project>,
 191    cx: &mut TestAppContext,
 192) {
 193    save_thread_metadata(
 194        session_id.clone(),
 195        "Test".into(),
 196        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 197        None,
 198        project,
 199        cx,
 200    )
 201}
 202
 203async fn save_named_thread_metadata(
 204    session_id: &str,
 205    title: &str,
 206    project: &Entity<project::Project>,
 207    cx: &mut gpui::VisualTestContext,
 208) {
 209    save_thread_metadata(
 210        acp::SessionId::new(Arc::from(session_id)),
 211        SharedString::from(title.to_string()),
 212        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 213        None,
 214        project,
 215        cx,
 216    );
 217    cx.run_until_parked();
 218}
 219
 220fn save_thread_metadata(
 221    session_id: acp::SessionId,
 222    title: SharedString,
 223    updated_at: DateTime<Utc>,
 224    created_at: Option<DateTime<Utc>>,
 225    project: &Entity<project::Project>,
 226    cx: &mut TestAppContext,
 227) {
 228    cx.update(|cx| {
 229        let worktree_paths = ThreadWorktreePaths::from_project(project.read(cx), cx);
 230        let metadata = ThreadMetadata {
 231            session_id,
 232            agent_id: agent::ZED_AGENT_ID.clone(),
 233            title,
 234            updated_at,
 235            created_at,
 236            worktree_paths,
 237            archived: false,
 238            remote_connection: None,
 239        };
 240        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
 241    });
 242    cx.run_until_parked();
 243}
 244
 245fn save_thread_metadata_with_main_paths(
 246    session_id: &str,
 247    title: &str,
 248    folder_paths: PathList,
 249    main_worktree_paths: PathList,
 250    cx: &mut TestAppContext,
 251) {
 252    let session_id = acp::SessionId::new(Arc::from(session_id));
 253    let title = SharedString::from(title.to_string());
 254    let updated_at = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap();
 255    let metadata = ThreadMetadata {
 256        session_id,
 257        agent_id: agent::ZED_AGENT_ID.clone(),
 258        title,
 259        updated_at,
 260        created_at: None,
 261        worktree_paths: ThreadWorktreePaths::from_path_lists(main_worktree_paths, folder_paths)
 262            .unwrap(),
 263        archived: false,
 264        remote_connection: None,
 265    };
 266    cx.update(|cx| {
 267        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
 268    });
 269    cx.run_until_parked();
 270}
 271
 272fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
 273    sidebar.update_in(cx, |_, window, cx| {
 274        cx.focus_self(window);
 275    });
 276    cx.run_until_parked();
 277}
 278
 279fn request_test_tool_authorization(
 280    thread: &Entity<AcpThread>,
 281    tool_call_id: &str,
 282    option_id: &str,
 283    cx: &mut gpui::VisualTestContext,
 284) {
 285    let tool_call_id = acp::ToolCallId::new(tool_call_id);
 286    let label = format!("Tool {tool_call_id}");
 287    let option_id = acp::PermissionOptionId::new(option_id);
 288    let _authorization_task = cx.update(|_, cx| {
 289        thread.update(cx, |thread, cx| {
 290            thread
 291                .request_tool_call_authorization(
 292                    acp::ToolCall::new(tool_call_id, label)
 293                        .kind(acp::ToolKind::Edit)
 294                        .into(),
 295                    PermissionOptions::Flat(vec![acp::PermissionOption::new(
 296                        option_id,
 297                        "Allow",
 298                        acp::PermissionOptionKind::AllowOnce,
 299                    )]),
 300                    cx,
 301                )
 302                .unwrap()
 303        })
 304    });
 305    cx.run_until_parked();
 306}
 307
 308fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
 309    let mut seen = Vec::new();
 310    let mut chips = Vec::new();
 311    for wt in worktrees {
 312        if wt.kind == ui::WorktreeKind::Main {
 313            continue;
 314        }
 315        if !seen.contains(&wt.name) {
 316            seen.push(wt.name.clone());
 317            chips.push(format!("{{{}}}", wt.name));
 318        }
 319    }
 320    if chips.is_empty() {
 321        String::new()
 322    } else {
 323        format!(" {}", chips.join(", "))
 324    }
 325}
 326
 327fn visible_entries_as_strings(
 328    sidebar: &Entity<Sidebar>,
 329    cx: &mut gpui::VisualTestContext,
 330) -> Vec<String> {
 331    sidebar.read_with(cx, |sidebar, _cx| {
 332        sidebar
 333            .contents
 334            .entries
 335            .iter()
 336            .enumerate()
 337            .map(|(ix, entry)| {
 338                let selected = if sidebar.selection == Some(ix) {
 339                    "  <== selected"
 340                } else {
 341                    ""
 342                };
 343                let is_active = sidebar
 344                    .active_entry
 345                    .as_ref()
 346                    .is_some_and(|active| active.matches_entry(entry));
 347                let active_indicator = if is_active { " (active)" } else { "" };
 348                match entry {
 349                    ListEntry::ProjectHeader {
 350                        label,
 351                        key,
 352                        highlight_positions: _,
 353                        ..
 354                    } => {
 355                        let icon = if sidebar.collapsed_groups.contains(key) {
 356                            ">"
 357                        } else {
 358                            "v"
 359                        };
 360                        format!("{} [{}]{}", icon, label, selected)
 361                    }
 362                    ListEntry::Thread(thread) => {
 363                        let title = thread.metadata.title.as_ref();
 364                        let live = if thread.is_live { " *" } else { "" };
 365                        let status_str = match thread.status {
 366                            AgentThreadStatus::Running => " (running)",
 367                            AgentThreadStatus::Error => " (error)",
 368                            AgentThreadStatus::WaitingForConfirmation => " (waiting)",
 369                            _ => "",
 370                        };
 371                        let notified = if sidebar
 372                            .contents
 373                            .is_thread_notified(&thread.metadata.session_id)
 374                        {
 375                            " (!)"
 376                        } else {
 377                            ""
 378                        };
 379                        let worktree = format_linked_worktree_chips(&thread.worktrees);
 380                        format!("  {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}")
 381                    }
 382                    ListEntry::ViewMore {
 383                        is_fully_expanded, ..
 384                    } => {
 385                        if *is_fully_expanded {
 386                            format!("  - Collapse{}", selected)
 387                        } else {
 388                            format!("  + View More{}", selected)
 389                        }
 390                    }
 391                    ListEntry::DraftThread { worktrees, .. } => {
 392                        let worktree = format_linked_worktree_chips(worktrees);
 393                        let is_active = sidebar
 394                            .active_entry
 395                            .as_ref()
 396                            .is_some_and(|e| e.matches_entry(entry));
 397                        let active_marker = if is_active { " *" } else { "" };
 398                        format!("  [~ Draft{}]{}{}", worktree, active_marker, selected)
 399                    }
 400                }
 401            })
 402            .collect()
 403    })
 404}
 405
 406#[gpui::test]
 407async fn test_serialization_round_trip(cx: &mut TestAppContext) {
 408    let project = init_test_project("/my-project", cx).await;
 409    let (multi_workspace, cx) =
 410        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 411    let sidebar = setup_sidebar(&multi_workspace, cx);
 412
 413    save_n_test_threads(3, &project, cx).await;
 414
 415    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
 416
 417    // Set a custom width, collapse the group, and expand "View More".
 418    sidebar.update_in(cx, |sidebar, window, cx| {
 419        sidebar.set_width(Some(px(420.0)), cx);
 420        sidebar.toggle_collapse(&project_group_key, window, cx);
 421        sidebar.expanded_groups.insert(project_group_key.clone(), 2);
 422    });
 423    cx.run_until_parked();
 424
 425    // Capture the serialized state from the first sidebar.
 426    let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
 427    let serialized = serialized.expect("serialized_state should return Some");
 428
 429    // Create a fresh sidebar and restore into it.
 430    let sidebar2 =
 431        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
 432    cx.run_until_parked();
 433
 434    sidebar2.update_in(cx, |sidebar, window, cx| {
 435        sidebar.restore_serialized_state(&serialized, window, cx);
 436    });
 437    cx.run_until_parked();
 438
 439    // Assert all serialized fields match.
 440    let (width1, collapsed1, expanded1) = sidebar.read_with(cx, |s, _| {
 441        (
 442            s.width,
 443            s.collapsed_groups.clone(),
 444            s.expanded_groups.clone(),
 445        )
 446    });
 447    let (width2, collapsed2, expanded2) = sidebar2.read_with(cx, |s, _| {
 448        (
 449            s.width,
 450            s.collapsed_groups.clone(),
 451            s.expanded_groups.clone(),
 452        )
 453    });
 454
 455    assert_eq!(width1, width2);
 456    assert_eq!(collapsed1, collapsed2);
 457    assert_eq!(expanded1, expanded2);
 458    assert_eq!(width1, px(420.0));
 459    assert!(collapsed1.contains(&project_group_key));
 460    assert_eq!(expanded1.get(&project_group_key), Some(&2));
 461}
 462
 463#[gpui::test]
 464async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
 465    // A regression test to ensure that restoring a serialized archive view does not panic.
 466    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 467    let (multi_workspace, cx) =
 468        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 469    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 470    cx.update(|_window, cx| {
 471        AgentRegistryStore::init_test_global(cx, vec![]);
 472    });
 473
 474    let serialized = serde_json::to_string(&SerializedSidebar {
 475        width: Some(400.0),
 476        collapsed_groups: Vec::new(),
 477        expanded_groups: Vec::new(),
 478        active_view: SerializedSidebarView::Archive,
 479    })
 480    .expect("serialization should succeed");
 481
 482    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
 483        if let Some(sidebar) = multi_workspace.sidebar() {
 484            sidebar.restore_serialized_state(&serialized, window, cx);
 485        }
 486    });
 487    cx.run_until_parked();
 488
 489    // After the deferred `show_archive` runs, the view should be Archive.
 490    sidebar.read_with(cx, |sidebar, _cx| {
 491        assert!(
 492            matches!(sidebar.view, SidebarView::Archive(_)),
 493            "expected sidebar view to be Archive after restore, got ThreadList"
 494        );
 495    });
 496}
 497
 498#[test]
 499fn test_clean_mention_links() {
 500    // Simple mention link
 501    assert_eq!(
 502        Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
 503        "check @Button.tsx"
 504    );
 505
 506    // Multiple mention links
 507    assert_eq!(
 508        Sidebar::clean_mention_links(
 509            "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
 510        ),
 511        "look at @foo.rs and @bar.rs"
 512    );
 513
 514    // No mention links — passthrough
 515    assert_eq!(
 516        Sidebar::clean_mention_links("plain text with no mentions"),
 517        "plain text with no mentions"
 518    );
 519
 520    // Incomplete link syntax — preserved as-is
 521    assert_eq!(
 522        Sidebar::clean_mention_links("broken [@mention without closing"),
 523        "broken [@mention without closing"
 524    );
 525
 526    // Regular markdown link (no @) — not touched
 527    assert_eq!(
 528        Sidebar::clean_mention_links("see [docs](https://example.com)"),
 529        "see [docs](https://example.com)"
 530    );
 531
 532    // Empty input
 533    assert_eq!(Sidebar::clean_mention_links(""), "");
 534}
 535
 536#[gpui::test]
 537async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
 538    let project = init_test_project("/my-project", cx).await;
 539    let (multi_workspace, cx) =
 540        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 541    let sidebar = setup_sidebar(&multi_workspace, cx);
 542
 543    let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
 544    let weak_sidebar = sidebar.downgrade();
 545    let weak_multi_workspace = multi_workspace.downgrade();
 546
 547    drop(sidebar);
 548    drop(multi_workspace);
 549    cx.update(|window, _cx| window.remove_window());
 550    cx.run_until_parked();
 551
 552    weak_multi_workspace.assert_released();
 553    weak_sidebar.assert_released();
 554    weak_workspace.assert_released();
 555}
 556
 557#[gpui::test]
 558async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
 559    let project = init_test_project("/my-project", cx).await;
 560    let (multi_workspace, cx) =
 561        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 562    let sidebar = setup_sidebar(&multi_workspace, cx);
 563
 564    assert_eq!(
 565        visible_entries_as_strings(&sidebar, cx),
 566        vec!["v [my-project]", "  [~ Draft]"]
 567    );
 568}
 569
 570#[gpui::test]
 571async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
 572    let project = init_test_project("/my-project", cx).await;
 573    let (multi_workspace, cx) =
 574        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 575    let sidebar = setup_sidebar(&multi_workspace, cx);
 576
 577    save_thread_metadata(
 578        acp::SessionId::new(Arc::from("thread-1")),
 579        "Fix crash in project panel".into(),
 580        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
 581        None,
 582        &project,
 583        cx,
 584    );
 585
 586    save_thread_metadata(
 587        acp::SessionId::new(Arc::from("thread-2")),
 588        "Add inline diff view".into(),
 589        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 590        None,
 591        &project,
 592        cx,
 593    );
 594    cx.run_until_parked();
 595
 596    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 597    cx.run_until_parked();
 598
 599    assert_eq!(
 600        visible_entries_as_strings(&sidebar, cx),
 601        vec![
 602            //
 603            "v [my-project]",
 604            "  Fix crash in project panel",
 605            "  Add inline diff view",
 606        ]
 607    );
 608}
 609
 610#[gpui::test]
 611async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
 612    let project = init_test_project("/project-a", cx).await;
 613    let (multi_workspace, cx) =
 614        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 615    let sidebar = setup_sidebar(&multi_workspace, cx);
 616
 617    // Single workspace with a thread
 618    save_thread_metadata(
 619        acp::SessionId::new(Arc::from("thread-a1")),
 620        "Thread A1".into(),
 621        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 622        None,
 623        &project,
 624        cx,
 625    );
 626    cx.run_until_parked();
 627
 628    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 629    cx.run_until_parked();
 630
 631    assert_eq!(
 632        visible_entries_as_strings(&sidebar, cx),
 633        vec![
 634            //
 635            "v [project-a]",
 636            "  Thread A1",
 637        ]
 638    );
 639
 640    // Add a second workspace
 641    multi_workspace.update_in(cx, |mw, window, cx| {
 642        mw.create_test_workspace(window, cx).detach();
 643    });
 644    cx.run_until_parked();
 645
 646    assert_eq!(
 647        visible_entries_as_strings(&sidebar, cx),
 648        vec![
 649            //
 650            "v [project-a]",
 651            "  Thread A1",
 652        ]
 653    );
 654}
 655
 656#[gpui::test]
 657async fn test_view_more_pagination(cx: &mut TestAppContext) {
 658    let project = init_test_project("/my-project", cx).await;
 659    let (multi_workspace, cx) =
 660        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 661    let sidebar = setup_sidebar(&multi_workspace, cx);
 662
 663    save_n_test_threads(12, &project, cx).await;
 664
 665    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 666    cx.run_until_parked();
 667
 668    assert_eq!(
 669        visible_entries_as_strings(&sidebar, cx),
 670        vec![
 671            //
 672            "v [my-project]",
 673            "  Thread 12",
 674            "  Thread 11",
 675            "  Thread 10",
 676            "  Thread 9",
 677            "  Thread 8",
 678            "  + View More",
 679        ]
 680    );
 681}
 682
 683#[gpui::test]
 684async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
 685    let project = init_test_project("/my-project", cx).await;
 686    let (multi_workspace, cx) =
 687        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 688    let sidebar = setup_sidebar(&multi_workspace, cx);
 689
 690    // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
 691    save_n_test_threads(17, &project, cx).await;
 692
 693    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
 694
 695    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 696    cx.run_until_parked();
 697
 698    // Initially shows 5 threads + View More
 699    let entries = visible_entries_as_strings(&sidebar, cx);
 700    assert_eq!(entries.len(), 7); // header + 5 threads + View More
 701    assert!(entries.iter().any(|e| e.contains("View More")));
 702
 703    // Focus and navigate to View More, then confirm to expand by one batch
 704    focus_sidebar(&sidebar, cx);
 705    for _ in 0..7 {
 706        cx.dispatch_action(SelectNext);
 707    }
 708    cx.dispatch_action(Confirm);
 709    cx.run_until_parked();
 710
 711    // Now shows 10 threads + View More
 712    let entries = visible_entries_as_strings(&sidebar, cx);
 713    assert_eq!(entries.len(), 12); // header + 10 threads + View More
 714    assert!(entries.iter().any(|e| e.contains("View More")));
 715
 716    // Expand again by one batch
 717    sidebar.update_in(cx, |s, _window, cx| {
 718        let current = s
 719            .expanded_groups
 720            .get(&project_group_key)
 721            .copied()
 722            .unwrap_or(0);
 723        s.expanded_groups
 724            .insert(project_group_key.clone(), current + 1);
 725        s.update_entries(cx);
 726    });
 727    cx.run_until_parked();
 728
 729    // Now shows 15 threads + View More
 730    let entries = visible_entries_as_strings(&sidebar, cx);
 731    assert_eq!(entries.len(), 17); // header + 15 threads + View More
 732    assert!(entries.iter().any(|e| e.contains("View More")));
 733
 734    // Expand one more time - should show all 17 threads with Collapse button
 735    sidebar.update_in(cx, |s, _window, cx| {
 736        let current = s
 737            .expanded_groups
 738            .get(&project_group_key)
 739            .copied()
 740            .unwrap_or(0);
 741        s.expanded_groups
 742            .insert(project_group_key.clone(), current + 1);
 743        s.update_entries(cx);
 744    });
 745    cx.run_until_parked();
 746
 747    // All 17 threads shown with Collapse button
 748    let entries = visible_entries_as_strings(&sidebar, cx);
 749    assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
 750    assert!(!entries.iter().any(|e| e.contains("View More")));
 751    assert!(entries.iter().any(|e| e.contains("Collapse")));
 752
 753    // Click collapse - should go back to showing 5 threads
 754    sidebar.update_in(cx, |s, _window, cx| {
 755        s.expanded_groups.remove(&project_group_key);
 756        s.update_entries(cx);
 757    });
 758    cx.run_until_parked();
 759
 760    // Back to initial state: 5 threads + View More
 761    let entries = visible_entries_as_strings(&sidebar, cx);
 762    assert_eq!(entries.len(), 7); // header + 5 threads + View More
 763    assert!(entries.iter().any(|e| e.contains("View More")));
 764}
 765
 766#[gpui::test]
 767async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
 768    let project = init_test_project("/my-project", cx).await;
 769    let (multi_workspace, cx) =
 770        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 771    let sidebar = setup_sidebar(&multi_workspace, cx);
 772
 773    save_n_test_threads(1, &project, cx).await;
 774
 775    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
 776
 777    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 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    // Collapse
 790    sidebar.update_in(cx, |s, window, cx| {
 791        s.toggle_collapse(&project_group_key, window, cx);
 792    });
 793    cx.run_until_parked();
 794
 795    assert_eq!(
 796        visible_entries_as_strings(&sidebar, cx),
 797        vec![
 798            //
 799            "> [my-project]",
 800        ]
 801    );
 802
 803    // Expand
 804    sidebar.update_in(cx, |s, window, cx| {
 805        s.toggle_collapse(&project_group_key, window, cx);
 806    });
 807    cx.run_until_parked();
 808
 809    assert_eq!(
 810        visible_entries_as_strings(&sidebar, cx),
 811        vec![
 812            //
 813            "v [my-project]",
 814            "  Thread 1",
 815        ]
 816    );
 817}
 818
 819#[gpui::test]
 820async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
 821    let project = init_test_project("/my-project", cx).await;
 822    let (multi_workspace, cx) =
 823        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 824    let sidebar = setup_sidebar(&multi_workspace, cx);
 825
 826    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 827    let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
 828    let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
 829
 830    sidebar.update_in(cx, |s, _window, _cx| {
 831        s.collapsed_groups
 832            .insert(project::ProjectGroupKey::new(None, collapsed_path.clone()));
 833        s.contents
 834            .notified_threads
 835            .insert(acp::SessionId::new(Arc::from("t-5")));
 836        s.contents.entries = vec![
 837            // Expanded project header
 838            ListEntry::ProjectHeader {
 839                key: project::ProjectGroupKey::new(None, expanded_path.clone()),
 840                label: "expanded-project".into(),
 841                highlight_positions: Vec::new(),
 842                has_running_threads: false,
 843                waiting_thread_count: 0,
 844                is_active: true,
 845                has_threads: true,
 846            },
 847            ListEntry::Thread(ThreadEntry {
 848                metadata: ThreadMetadata {
 849                    session_id: acp::SessionId::new(Arc::from("t-1")),
 850                    agent_id: AgentId::new("zed-agent"),
 851                    worktree_paths: ThreadWorktreePaths::default(),
 852                    title: "Completed thread".into(),
 853                    updated_at: Utc::now(),
 854                    created_at: Some(Utc::now()),
 855                    archived: false,
 856                    remote_connection: None,
 857                },
 858                icon: IconName::ZedAgent,
 859                icon_from_external_svg: None,
 860                status: AgentThreadStatus::Completed,
 861                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 862                is_live: false,
 863                is_background: false,
 864                is_title_generating: false,
 865                highlight_positions: Vec::new(),
 866                worktrees: Vec::new(),
 867                diff_stats: DiffStats::default(),
 868            }),
 869            // Active thread with Running status
 870            ListEntry::Thread(ThreadEntry {
 871                metadata: ThreadMetadata {
 872                    session_id: acp::SessionId::new(Arc::from("t-2")),
 873                    agent_id: AgentId::new("zed-agent"),
 874                    worktree_paths: ThreadWorktreePaths::default(),
 875                    title: "Running thread".into(),
 876                    updated_at: Utc::now(),
 877                    created_at: Some(Utc::now()),
 878                    archived: false,
 879                    remote_connection: None,
 880                },
 881                icon: IconName::ZedAgent,
 882                icon_from_external_svg: None,
 883                status: AgentThreadStatus::Running,
 884                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 885                is_live: true,
 886                is_background: false,
 887                is_title_generating: false,
 888                highlight_positions: Vec::new(),
 889                worktrees: Vec::new(),
 890                diff_stats: DiffStats::default(),
 891            }),
 892            // Active thread with Error status
 893            ListEntry::Thread(ThreadEntry {
 894                metadata: ThreadMetadata {
 895                    session_id: acp::SessionId::new(Arc::from("t-3")),
 896                    agent_id: AgentId::new("zed-agent"),
 897                    worktree_paths: ThreadWorktreePaths::default(),
 898                    title: "Error thread".into(),
 899                    updated_at: Utc::now(),
 900                    created_at: Some(Utc::now()),
 901                    archived: false,
 902                    remote_connection: None,
 903                },
 904                icon: IconName::ZedAgent,
 905                icon_from_external_svg: None,
 906                status: AgentThreadStatus::Error,
 907                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 908                is_live: true,
 909                is_background: false,
 910                is_title_generating: false,
 911                highlight_positions: Vec::new(),
 912                worktrees: Vec::new(),
 913                diff_stats: DiffStats::default(),
 914            }),
 915            // Thread with WaitingForConfirmation status, not active
 916            // remote_connection: None,
 917            ListEntry::Thread(ThreadEntry {
 918                metadata: ThreadMetadata {
 919                    session_id: acp::SessionId::new(Arc::from("t-4")),
 920                    agent_id: AgentId::new("zed-agent"),
 921                    worktree_paths: ThreadWorktreePaths::default(),
 922                    title: "Waiting thread".into(),
 923                    updated_at: Utc::now(),
 924                    created_at: Some(Utc::now()),
 925                    archived: false,
 926                    remote_connection: None,
 927                },
 928                icon: IconName::ZedAgent,
 929                icon_from_external_svg: None,
 930                status: AgentThreadStatus::WaitingForConfirmation,
 931                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 932                is_live: false,
 933                is_background: false,
 934                is_title_generating: false,
 935                highlight_positions: Vec::new(),
 936                worktrees: Vec::new(),
 937                diff_stats: DiffStats::default(),
 938            }),
 939            // Background thread that completed (should show notification)
 940            // remote_connection: None,
 941            ListEntry::Thread(ThreadEntry {
 942                metadata: ThreadMetadata {
 943                    session_id: acp::SessionId::new(Arc::from("t-5")),
 944                    agent_id: AgentId::new("zed-agent"),
 945                    worktree_paths: ThreadWorktreePaths::default(),
 946                    title: "Notified thread".into(),
 947                    updated_at: Utc::now(),
 948                    created_at: Some(Utc::now()),
 949                    archived: false,
 950                    remote_connection: None,
 951                },
 952                icon: IconName::ZedAgent,
 953                icon_from_external_svg: None,
 954                status: AgentThreadStatus::Completed,
 955                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 956                is_live: true,
 957                is_background: true,
 958                is_title_generating: false,
 959                highlight_positions: Vec::new(),
 960                worktrees: Vec::new(),
 961                diff_stats: DiffStats::default(),
 962            }),
 963            // View More entry
 964            ListEntry::ViewMore {
 965                key: project::ProjectGroupKey::new(None, expanded_path.clone()),
 966                is_fully_expanded: false,
 967            },
 968            // Collapsed project header
 969            ListEntry::ProjectHeader {
 970                key: project::ProjectGroupKey::new(None, collapsed_path.clone()),
 971                label: "collapsed-project".into(),
 972                highlight_positions: Vec::new(),
 973                has_running_threads: false,
 974                waiting_thread_count: 0,
 975                is_active: false,
 976                has_threads: false,
 977            },
 978        ];
 979
 980        // Select the Running thread (index 2)
 981        s.selection = Some(2);
 982    });
 983
 984    assert_eq!(
 985        visible_entries_as_strings(&sidebar, cx),
 986        vec![
 987            //
 988            "v [expanded-project]",
 989            "  Completed thread",
 990            "  Running thread * (running)  <== selected",
 991            "  Error thread * (error)",
 992            "  Waiting thread (waiting)",
 993            "  Notified thread * (!)",
 994            "  + View More",
 995            "> [collapsed-project]",
 996        ]
 997    );
 998
 999    // Move selection to the collapsed header
1000    sidebar.update_in(cx, |s, _window, _cx| {
1001        s.selection = Some(7);
1002    });
1003
1004    assert_eq!(
1005        visible_entries_as_strings(&sidebar, cx).last().cloned(),
1006        Some("> [collapsed-project]  <== selected".to_string()),
1007    );
1008
1009    // Clear selection
1010    sidebar.update_in(cx, |s, _window, _cx| {
1011        s.selection = None;
1012    });
1013
1014    // No entry should have the selected marker
1015    let entries = visible_entries_as_strings(&sidebar, cx);
1016    for entry in &entries {
1017        assert!(
1018            !entry.contains("<== selected"),
1019            "unexpected selection marker in: {}",
1020            entry
1021        );
1022    }
1023}
1024
1025#[gpui::test]
1026async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
1027    let project = init_test_project("/my-project", cx).await;
1028    let (multi_workspace, cx) =
1029        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1030    let sidebar = setup_sidebar(&multi_workspace, cx);
1031
1032    save_n_test_threads(3, &project, cx).await;
1033
1034    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1035    cx.run_until_parked();
1036
1037    // Entries: [header, thread3, thread2, thread1]
1038    // Focusing the sidebar does not set a selection; select_next/select_previous
1039    // handle None gracefully by starting from the first or last entry.
1040    focus_sidebar(&sidebar, cx);
1041    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1042
1043    // First SelectNext from None starts at index 0
1044    cx.dispatch_action(SelectNext);
1045    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1046
1047    // Move down through remaining entries
1048    cx.dispatch_action(SelectNext);
1049    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1050
1051    cx.dispatch_action(SelectNext);
1052    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1053
1054    cx.dispatch_action(SelectNext);
1055    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1056
1057    // At the end, wraps back to first entry
1058    cx.dispatch_action(SelectNext);
1059    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1060
1061    // Navigate back to the end
1062    cx.dispatch_action(SelectNext);
1063    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1064    cx.dispatch_action(SelectNext);
1065    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1066    cx.dispatch_action(SelectNext);
1067    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1068
1069    // Move back up
1070    cx.dispatch_action(SelectPrevious);
1071    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1072
1073    cx.dispatch_action(SelectPrevious);
1074    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1075
1076    cx.dispatch_action(SelectPrevious);
1077    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1078
1079    // At the top, selection clears (focus returns to editor)
1080    cx.dispatch_action(SelectPrevious);
1081    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1082}
1083
1084#[gpui::test]
1085async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
1086    let project = init_test_project("/my-project", cx).await;
1087    let (multi_workspace, cx) =
1088        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1089    let sidebar = setup_sidebar(&multi_workspace, cx);
1090
1091    save_n_test_threads(3, &project, cx).await;
1092    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1093    cx.run_until_parked();
1094
1095    focus_sidebar(&sidebar, cx);
1096
1097    // SelectLast jumps to the end
1098    cx.dispatch_action(SelectLast);
1099    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1100
1101    // SelectFirst jumps to the beginning
1102    cx.dispatch_action(SelectFirst);
1103    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1104}
1105
1106#[gpui::test]
1107async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
1108    let project = init_test_project("/my-project", cx).await;
1109    let (multi_workspace, cx) =
1110        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1111    let sidebar = setup_sidebar(&multi_workspace, cx);
1112
1113    // Initially no selection
1114    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1115
1116    // Open the sidebar so it's rendered, then focus it to trigger focus_in.
1117    // focus_in no longer sets a default selection.
1118    focus_sidebar(&sidebar, cx);
1119    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1120
1121    // Manually set a selection, blur, then refocus — selection should be preserved
1122    sidebar.update_in(cx, |sidebar, _window, _cx| {
1123        sidebar.selection = Some(0);
1124    });
1125
1126    cx.update(|window, _cx| {
1127        window.blur();
1128    });
1129    cx.run_until_parked();
1130
1131    sidebar.update_in(cx, |_, window, cx| {
1132        cx.focus_self(window);
1133    });
1134    cx.run_until_parked();
1135    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1136}
1137
1138#[gpui::test]
1139async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
1140    let project = init_test_project("/my-project", cx).await;
1141    let (multi_workspace, cx) =
1142        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1143    let sidebar = setup_sidebar(&multi_workspace, cx);
1144
1145    save_n_test_threads(1, &project, cx).await;
1146    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1147    cx.run_until_parked();
1148
1149    assert_eq!(
1150        visible_entries_as_strings(&sidebar, cx),
1151        vec![
1152            //
1153            "v [my-project]",
1154            "  Thread 1",
1155        ]
1156    );
1157
1158    // Focus the sidebar and select the header
1159    focus_sidebar(&sidebar, cx);
1160    sidebar.update_in(cx, |sidebar, _window, _cx| {
1161        sidebar.selection = Some(0);
1162    });
1163
1164    // Confirm on project header collapses the group
1165    cx.dispatch_action(Confirm);
1166    cx.run_until_parked();
1167
1168    assert_eq!(
1169        visible_entries_as_strings(&sidebar, cx),
1170        vec![
1171            //
1172            "> [my-project]  <== selected",
1173        ]
1174    );
1175
1176    // Confirm again expands the group
1177    cx.dispatch_action(Confirm);
1178    cx.run_until_parked();
1179
1180    assert_eq!(
1181        visible_entries_as_strings(&sidebar, cx),
1182        vec![
1183            //
1184            "v [my-project]  <== selected",
1185            "  Thread 1",
1186        ]
1187    );
1188}
1189
1190#[gpui::test]
1191async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
1192    let project = init_test_project("/my-project", cx).await;
1193    let (multi_workspace, cx) =
1194        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1195    let sidebar = setup_sidebar(&multi_workspace, cx);
1196
1197    save_n_test_threads(8, &project, cx).await;
1198    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1199    cx.run_until_parked();
1200
1201    // Should show header + 5 threads + "View More"
1202    let entries = visible_entries_as_strings(&sidebar, cx);
1203    assert_eq!(entries.len(), 7);
1204    assert!(entries.iter().any(|e| e.contains("View More")));
1205
1206    // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
1207    focus_sidebar(&sidebar, cx);
1208    for _ in 0..7 {
1209        cx.dispatch_action(SelectNext);
1210    }
1211    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
1212
1213    // Confirm on "View More" to expand
1214    cx.dispatch_action(Confirm);
1215    cx.run_until_parked();
1216
1217    // All 8 threads should now be visible with a "Collapse" button
1218    let entries = visible_entries_as_strings(&sidebar, cx);
1219    assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
1220    assert!(!entries.iter().any(|e| e.contains("View More")));
1221    assert!(entries.iter().any(|e| e.contains("Collapse")));
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("/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 = setup_sidebar(&multi_workspace, cx);
1325
1326    // An empty project has the header and an auto-created draft.
1327    assert_eq!(
1328        visible_entries_as_strings(&sidebar, cx),
1329        vec!["v [empty-project]", "  [~ Draft]"]
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 advances to index 1 (draft entry)
1341    cx.dispatch_action(SelectNext);
1342    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1343
1344    // At the end (two entries), wraps back to first entry
1345    cx.dispatch_action(SelectNext);
1346    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1347
1348    // SelectPrevious from first entry clears selection (returns to editor)
1349    cx.dispatch_action(SelectPrevious);
1350    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
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        ThreadStore::init_global(cx);
1392        ThreadMetadataStore::init_global(cx);
1393        language_model::LanguageModelRegistry::test(cx);
1394        prompt_store::init(cx);
1395    });
1396
1397    let fs = FakeFs::new(cx.executor());
1398    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1399        .await;
1400    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1401    project::Project::test(fs, [worktree_path.as_ref()], cx).await
1402}
1403
1404fn add_agent_panel(
1405    workspace: &Entity<Workspace>,
1406    cx: &mut gpui::VisualTestContext,
1407) -> Entity<AgentPanel> {
1408    workspace.update_in(cx, |workspace, window, cx| {
1409        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
1410        workspace.add_panel(panel.clone(), window, cx);
1411        panel
1412    })
1413}
1414
1415fn setup_sidebar_with_agent_panel(
1416    multi_workspace: &Entity<MultiWorkspace>,
1417    cx: &mut gpui::VisualTestContext,
1418) -> (Entity<Sidebar>, Entity<AgentPanel>) {
1419    let sidebar = setup_sidebar(multi_workspace, cx);
1420    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
1421    let panel = add_agent_panel(&workspace, cx);
1422    (sidebar, panel)
1423}
1424
1425#[gpui::test]
1426async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
1427    let project = init_test_project_with_agent_panel("/my-project", cx).await;
1428    let (multi_workspace, cx) =
1429        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1430    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1431
1432    // Open thread A and keep it generating.
1433    let connection = StubAgentConnection::new();
1434    open_thread_with_connection(&panel, connection.clone(), cx);
1435    send_message(&panel, cx);
1436
1437    let session_id_a = active_session_id(&panel, cx);
1438    save_test_thread_metadata(&session_id_a, &project, cx).await;
1439
1440    cx.update(|_, cx| {
1441        connection.send_update(
1442            session_id_a.clone(),
1443            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
1444            cx,
1445        );
1446    });
1447    cx.run_until_parked();
1448
1449    // Open thread B (idle, default response) — thread A goes to background.
1450    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1451        acp::ContentChunk::new("Done".into()),
1452    )]);
1453    open_thread_with_connection(&panel, connection, cx);
1454    send_message(&panel, cx);
1455
1456    let session_id_b = active_session_id(&panel, cx);
1457    save_test_thread_metadata(&session_id_b, &project, cx).await;
1458
1459    cx.run_until_parked();
1460
1461    let mut entries = visible_entries_as_strings(&sidebar, cx);
1462    entries[1..].sort();
1463    assert_eq!(
1464        entries,
1465        vec![
1466            //
1467            "v [my-project]",
1468            "  Hello * (active)",
1469            "  Hello * (running)",
1470        ]
1471    );
1472}
1473
1474#[gpui::test]
1475async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
1476    cx: &mut TestAppContext,
1477) {
1478    let project = init_test_project_with_agent_panel("/my-project", cx).await;
1479    let (multi_workspace, cx) =
1480        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1481    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1482
1483    let connection = StubAgentConnection::new().with_supports_load_session(true);
1484    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1485        acp::ContentChunk::new("Done".into()),
1486    )]);
1487    open_thread_with_connection(&panel, connection, cx);
1488    send_message(&panel, cx);
1489
1490    let parent_session_id = active_session_id(&panel, cx);
1491    save_test_thread_metadata(&parent_session_id, &project, cx).await;
1492
1493    let subagent_session_id = acp::SessionId::new("subagent-session");
1494    cx.update(|_, cx| {
1495        let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
1496        parent_thread.update(cx, |thread: &mut AcpThread, cx| {
1497            thread.subagent_spawned(subagent_session_id.clone(), cx);
1498        });
1499    });
1500    cx.run_until_parked();
1501
1502    let subagent_thread = panel.read_with(cx, |panel, cx| {
1503        panel
1504            .active_conversation_view()
1505            .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id))
1506            .map(|thread_view| thread_view.read(cx).thread.clone())
1507            .expect("Expected subagent thread to be loaded into the conversation")
1508    });
1509    request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
1510
1511    let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
1512        sidebar
1513            .contents
1514            .entries
1515            .iter()
1516            .find_map(|entry| match entry {
1517                ListEntry::Thread(thread) if thread.metadata.session_id == parent_session_id => {
1518                    Some(thread.status)
1519                }
1520                _ => None,
1521            })
1522            .expect("Expected parent thread entry in sidebar")
1523    });
1524
1525    assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
1526}
1527
1528#[gpui::test]
1529async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
1530    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
1531    let (multi_workspace, cx) =
1532        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1533    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1534
1535    // Open thread on workspace A and keep it generating.
1536    let connection_a = StubAgentConnection::new();
1537    open_thread_with_connection(&panel_a, connection_a.clone(), cx);
1538    send_message(&panel_a, cx);
1539
1540    let session_id_a = active_session_id(&panel_a, cx);
1541    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
1542
1543    cx.update(|_, cx| {
1544        connection_a.send_update(
1545            session_id_a.clone(),
1546            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
1547            cx,
1548        );
1549    });
1550    cx.run_until_parked();
1551
1552    // Add a second workspace and activate it (making workspace A the background).
1553    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
1554    let project_b = project::Project::test(fs, [], cx).await;
1555    multi_workspace.update_in(cx, |mw, window, cx| {
1556        mw.test_add_workspace(project_b, window, cx);
1557    });
1558    cx.run_until_parked();
1559
1560    // Thread A is still running; no notification yet.
1561    assert_eq!(
1562        visible_entries_as_strings(&sidebar, cx),
1563        vec![
1564            //
1565            "v [project-a]",
1566            "  Hello * (running) (active)",
1567        ]
1568    );
1569
1570    // Complete thread A's turn (transition Running → Completed).
1571    connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
1572    cx.run_until_parked();
1573
1574    // The completed background thread shows a notification indicator.
1575    assert_eq!(
1576        visible_entries_as_strings(&sidebar, cx),
1577        vec![
1578            //
1579            "v [project-a]",
1580            "  Hello * (!) (active)",
1581        ]
1582    );
1583}
1584
1585fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
1586    sidebar.update_in(cx, |sidebar, window, cx| {
1587        window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
1588        sidebar.filter_editor.update(cx, |editor, cx| {
1589            editor.set_text(query, window, cx);
1590        });
1591    });
1592    cx.run_until_parked();
1593}
1594
1595#[gpui::test]
1596async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
1597    let project = init_test_project("/my-project", cx).await;
1598    let (multi_workspace, cx) =
1599        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1600    let sidebar = setup_sidebar(&multi_workspace, cx);
1601
1602    for (id, title, hour) in [
1603        ("t-1", "Fix crash in project panel", 3),
1604        ("t-2", "Add inline diff view", 2),
1605        ("t-3", "Refactor settings module", 1),
1606    ] {
1607        save_thread_metadata(
1608            acp::SessionId::new(Arc::from(id)),
1609            title.into(),
1610            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1611            None,
1612            &project,
1613            cx,
1614        );
1615    }
1616    cx.run_until_parked();
1617
1618    assert_eq!(
1619        visible_entries_as_strings(&sidebar, cx),
1620        vec![
1621            //
1622            "v [my-project]",
1623            "  Fix crash in project panel",
1624            "  Add inline diff view",
1625            "  Refactor settings module",
1626        ]
1627    );
1628
1629    // User types "diff" in the search box — only the matching thread remains,
1630    // with its workspace header preserved for context.
1631    type_in_search(&sidebar, "diff", cx);
1632    assert_eq!(
1633        visible_entries_as_strings(&sidebar, cx),
1634        vec![
1635            //
1636            "v [my-project]",
1637            "  Add inline diff view  <== selected",
1638        ]
1639    );
1640
1641    // User changes query to something with no matches — list is empty.
1642    type_in_search(&sidebar, "nonexistent", cx);
1643    assert_eq!(
1644        visible_entries_as_strings(&sidebar, cx),
1645        Vec::<String>::new()
1646    );
1647}
1648
1649#[gpui::test]
1650async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
1651    // Scenario: A user remembers a thread title but not the exact casing.
1652    // Search should match case-insensitively so they can still find it.
1653    let project = init_test_project("/my-project", cx).await;
1654    let (multi_workspace, cx) =
1655        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1656    let sidebar = setup_sidebar(&multi_workspace, cx);
1657
1658    save_thread_metadata(
1659        acp::SessionId::new(Arc::from("thread-1")),
1660        "Fix Crash In Project Panel".into(),
1661        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1662        None,
1663        &project,
1664        cx,
1665    );
1666    cx.run_until_parked();
1667
1668    // Lowercase query matches mixed-case title.
1669    type_in_search(&sidebar, "fix crash", cx);
1670    assert_eq!(
1671        visible_entries_as_strings(&sidebar, cx),
1672        vec![
1673            //
1674            "v [my-project]",
1675            "  Fix Crash In Project Panel  <== selected",
1676        ]
1677    );
1678
1679    // Uppercase query also matches the same title.
1680    type_in_search(&sidebar, "FIX CRASH", cx);
1681    assert_eq!(
1682        visible_entries_as_strings(&sidebar, cx),
1683        vec![
1684            //
1685            "v [my-project]",
1686            "  Fix Crash In Project Panel  <== selected",
1687        ]
1688    );
1689}
1690
1691#[gpui::test]
1692async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
1693    // Scenario: A user searches, finds what they need, then presses Escape
1694    // to dismiss the filter and see the full list again.
1695    let project = init_test_project("/my-project", cx).await;
1696    let (multi_workspace, cx) =
1697        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1698    let sidebar = setup_sidebar(&multi_workspace, cx);
1699
1700    for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
1701        save_thread_metadata(
1702            acp::SessionId::new(Arc::from(id)),
1703            title.into(),
1704            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1705            None,
1706            &project,
1707            cx,
1708        )
1709    }
1710    cx.run_until_parked();
1711
1712    // Confirm the full list is showing.
1713    assert_eq!(
1714        visible_entries_as_strings(&sidebar, cx),
1715        vec![
1716            //
1717            "v [my-project]",
1718            "  Alpha thread",
1719            "  Beta thread",
1720        ]
1721    );
1722
1723    // User types a search query to filter down.
1724    focus_sidebar(&sidebar, cx);
1725    type_in_search(&sidebar, "alpha", cx);
1726    assert_eq!(
1727        visible_entries_as_strings(&sidebar, cx),
1728        vec![
1729            //
1730            "v [my-project]",
1731            "  Alpha thread  <== selected",
1732        ]
1733    );
1734
1735    // User presses Escape — filter clears, full list is restored.
1736    // The selection index (1) now points at the first thread entry.
1737    cx.dispatch_action(Cancel);
1738    cx.run_until_parked();
1739    assert_eq!(
1740        visible_entries_as_strings(&sidebar, cx),
1741        vec![
1742            //
1743            "v [my-project]",
1744            "  Alpha thread  <== selected",
1745            "  Beta thread",
1746        ]
1747    );
1748}
1749
1750#[gpui::test]
1751async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
1752    let project_a = init_test_project("/project-a", cx).await;
1753    let (multi_workspace, cx) =
1754        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1755    let sidebar = setup_sidebar(&multi_workspace, cx);
1756
1757    for (id, title, hour) in [
1758        ("a1", "Fix bug in sidebar", 2),
1759        ("a2", "Add tests for editor", 1),
1760    ] {
1761        save_thread_metadata(
1762            acp::SessionId::new(Arc::from(id)),
1763            title.into(),
1764            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1765            None,
1766            &project_a,
1767            cx,
1768        )
1769    }
1770
1771    // Add a second workspace.
1772    multi_workspace.update_in(cx, |mw, window, cx| {
1773        mw.create_test_workspace(window, cx).detach();
1774    });
1775    cx.run_until_parked();
1776
1777    let project_b = multi_workspace.read_with(cx, |mw, cx| {
1778        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1779    });
1780
1781    for (id, title, hour) in [
1782        ("b1", "Refactor sidebar layout", 3),
1783        ("b2", "Fix typo in README", 1),
1784    ] {
1785        save_thread_metadata(
1786            acp::SessionId::new(Arc::from(id)),
1787            title.into(),
1788            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1789            None,
1790            &project_b,
1791            cx,
1792        )
1793    }
1794    cx.run_until_parked();
1795
1796    assert_eq!(
1797        visible_entries_as_strings(&sidebar, cx),
1798        vec![
1799            //
1800            "v [project-a]",
1801            "  Fix bug in sidebar",
1802            "  Add tests for editor",
1803        ]
1804    );
1805
1806    // "sidebar" matches a thread in each workspace — both headers stay visible.
1807    type_in_search(&sidebar, "sidebar", cx);
1808    assert_eq!(
1809        visible_entries_as_strings(&sidebar, cx),
1810        vec![
1811            //
1812            "v [project-a]",
1813            "  Fix bug in sidebar  <== selected",
1814        ]
1815    );
1816
1817    // "typo" only matches in the second workspace — the first header disappears.
1818    type_in_search(&sidebar, "typo", cx);
1819    assert_eq!(
1820        visible_entries_as_strings(&sidebar, cx),
1821        Vec::<String>::new()
1822    );
1823
1824    // "project-a" matches the first workspace name — the header appears
1825    // with all child threads included.
1826    type_in_search(&sidebar, "project-a", cx);
1827    assert_eq!(
1828        visible_entries_as_strings(&sidebar, cx),
1829        vec![
1830            //
1831            "v [project-a]",
1832            "  Fix bug in sidebar  <== selected",
1833            "  Add tests for editor",
1834        ]
1835    );
1836}
1837
1838#[gpui::test]
1839async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
1840    let project_a = init_test_project("/alpha-project", cx).await;
1841    let (multi_workspace, cx) =
1842        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1843    let sidebar = setup_sidebar(&multi_workspace, cx);
1844
1845    for (id, title, hour) in [
1846        ("a1", "Fix bug in sidebar", 2),
1847        ("a2", "Add tests for editor", 1),
1848    ] {
1849        save_thread_metadata(
1850            acp::SessionId::new(Arc::from(id)),
1851            title.into(),
1852            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1853            None,
1854            &project_a,
1855            cx,
1856        )
1857    }
1858
1859    // Add a second workspace.
1860    multi_workspace.update_in(cx, |mw, window, cx| {
1861        mw.create_test_workspace(window, cx).detach();
1862    });
1863    cx.run_until_parked();
1864
1865    let project_b = multi_workspace.read_with(cx, |mw, cx| {
1866        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1867    });
1868
1869    for (id, title, hour) in [
1870        ("b1", "Refactor sidebar layout", 3),
1871        ("b2", "Fix typo in README", 1),
1872    ] {
1873        save_thread_metadata(
1874            acp::SessionId::new(Arc::from(id)),
1875            title.into(),
1876            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1877            None,
1878            &project_b,
1879            cx,
1880        )
1881    }
1882    cx.run_until_parked();
1883
1884    // "alpha" matches the workspace name "alpha-project" but no thread titles.
1885    // The workspace header should appear with all child threads included.
1886    type_in_search(&sidebar, "alpha", cx);
1887    assert_eq!(
1888        visible_entries_as_strings(&sidebar, cx),
1889        vec![
1890            //
1891            "v [alpha-project]",
1892            "  Fix bug in sidebar  <== selected",
1893            "  Add tests for editor",
1894        ]
1895    );
1896
1897    // "sidebar" matches thread titles in both workspaces but not workspace names.
1898    // Both headers appear with their matching threads.
1899    type_in_search(&sidebar, "sidebar", cx);
1900    assert_eq!(
1901        visible_entries_as_strings(&sidebar, cx),
1902        vec![
1903            //
1904            "v [alpha-project]",
1905            "  Fix bug in sidebar  <== selected",
1906        ]
1907    );
1908
1909    // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
1910    // doesn't match) — but does not match either workspace name or any thread.
1911    // Actually let's test something simpler: a query that matches both a workspace
1912    // name AND some threads in that workspace. Matching threads should still appear.
1913    type_in_search(&sidebar, "fix", cx);
1914    assert_eq!(
1915        visible_entries_as_strings(&sidebar, cx),
1916        vec![
1917            //
1918            "v [alpha-project]",
1919            "  Fix bug in sidebar  <== selected",
1920        ]
1921    );
1922
1923    // A query that matches a workspace name AND a thread in that same workspace.
1924    // Both the header (highlighted) and all child threads should appear.
1925    type_in_search(&sidebar, "alpha", cx);
1926    assert_eq!(
1927        visible_entries_as_strings(&sidebar, cx),
1928        vec![
1929            //
1930            "v [alpha-project]",
1931            "  Fix bug in sidebar  <== selected",
1932            "  Add tests for editor",
1933        ]
1934    );
1935
1936    // Now search for something that matches only a workspace name when there
1937    // are also threads with matching titles — the non-matching workspace's
1938    // threads should still appear if their titles match.
1939    type_in_search(&sidebar, "alp", cx);
1940    assert_eq!(
1941        visible_entries_as_strings(&sidebar, cx),
1942        vec![
1943            //
1944            "v [alpha-project]",
1945            "  Fix bug in sidebar  <== selected",
1946            "  Add tests for editor",
1947        ]
1948    );
1949}
1950
1951#[gpui::test]
1952async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
1953    let project = init_test_project("/my-project", cx).await;
1954    let (multi_workspace, cx) =
1955        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1956    let sidebar = setup_sidebar(&multi_workspace, cx);
1957
1958    // Create 8 threads. The oldest one has a unique name and will be
1959    // behind View More (only 5 shown by default).
1960    for i in 0..8u32 {
1961        let title = if i == 0 {
1962            "Hidden gem thread".to_string()
1963        } else {
1964            format!("Thread {}", i + 1)
1965        };
1966        save_thread_metadata(
1967            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1968            title.into(),
1969            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1970            None,
1971            &project,
1972            cx,
1973        )
1974    }
1975    cx.run_until_parked();
1976
1977    // Confirm the thread is not visible and View More is shown.
1978    let entries = visible_entries_as_strings(&sidebar, cx);
1979    assert!(
1980        entries.iter().any(|e| e.contains("View More")),
1981        "should have View More button"
1982    );
1983    assert!(
1984        !entries.iter().any(|e| e.contains("Hidden gem")),
1985        "Hidden gem should be behind View More"
1986    );
1987
1988    // User searches for the hidden thread — it appears, and View More is gone.
1989    type_in_search(&sidebar, "hidden gem", cx);
1990    let filtered = visible_entries_as_strings(&sidebar, cx);
1991    assert_eq!(
1992        filtered,
1993        vec![
1994            //
1995            "v [my-project]",
1996            "  Hidden gem thread  <== selected",
1997        ]
1998    );
1999    assert!(
2000        !filtered.iter().any(|e| e.contains("View More")),
2001        "View More should not appear when filtering"
2002    );
2003}
2004
2005#[gpui::test]
2006async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
2007    let project = init_test_project("/my-project", cx).await;
2008    let (multi_workspace, cx) =
2009        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2010    let sidebar = setup_sidebar(&multi_workspace, cx);
2011
2012    save_thread_metadata(
2013        acp::SessionId::new(Arc::from("thread-1")),
2014        "Important thread".into(),
2015        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2016        None,
2017        &project,
2018        cx,
2019    );
2020    cx.run_until_parked();
2021
2022    // User focuses the sidebar and collapses the group using keyboard:
2023    // manually select the header, then press SelectParent to collapse.
2024    focus_sidebar(&sidebar, cx);
2025    sidebar.update_in(cx, |sidebar, _window, _cx| {
2026        sidebar.selection = Some(0);
2027    });
2028    cx.dispatch_action(SelectParent);
2029    cx.run_until_parked();
2030
2031    assert_eq!(
2032        visible_entries_as_strings(&sidebar, cx),
2033        vec![
2034            //
2035            "> [my-project]  <== selected",
2036        ]
2037    );
2038
2039    // User types a search — the thread appears even though its group is collapsed.
2040    type_in_search(&sidebar, "important", cx);
2041    assert_eq!(
2042        visible_entries_as_strings(&sidebar, cx),
2043        vec![
2044            //
2045            "> [my-project]",
2046            "  Important thread  <== selected",
2047        ]
2048    );
2049}
2050
2051#[gpui::test]
2052async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
2053    let project = init_test_project("/my-project", cx).await;
2054    let (multi_workspace, cx) =
2055        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2056    let sidebar = setup_sidebar(&multi_workspace, cx);
2057
2058    for (id, title, hour) in [
2059        ("t-1", "Fix crash in panel", 3),
2060        ("t-2", "Fix lint warnings", 2),
2061        ("t-3", "Add new feature", 1),
2062    ] {
2063        save_thread_metadata(
2064            acp::SessionId::new(Arc::from(id)),
2065            title.into(),
2066            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2067            None,
2068            &project,
2069            cx,
2070        )
2071    }
2072    cx.run_until_parked();
2073
2074    focus_sidebar(&sidebar, cx);
2075
2076    // User types "fix" — two threads match.
2077    type_in_search(&sidebar, "fix", cx);
2078    assert_eq!(
2079        visible_entries_as_strings(&sidebar, cx),
2080        vec![
2081            //
2082            "v [my-project]",
2083            "  Fix crash in panel  <== selected",
2084            "  Fix lint warnings",
2085        ]
2086    );
2087
2088    // Selection starts on the first matching thread. User presses
2089    // SelectNext to move to the second match.
2090    cx.dispatch_action(SelectNext);
2091    assert_eq!(
2092        visible_entries_as_strings(&sidebar, cx),
2093        vec![
2094            //
2095            "v [my-project]",
2096            "  Fix crash in panel",
2097            "  Fix lint warnings  <== selected",
2098        ]
2099    );
2100
2101    // User can also jump back with SelectPrevious.
2102    cx.dispatch_action(SelectPrevious);
2103    assert_eq!(
2104        visible_entries_as_strings(&sidebar, cx),
2105        vec![
2106            //
2107            "v [my-project]",
2108            "  Fix crash in panel  <== selected",
2109            "  Fix lint warnings",
2110        ]
2111    );
2112}
2113
2114#[gpui::test]
2115async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
2116    let project = init_test_project("/my-project", cx).await;
2117    let (multi_workspace, cx) =
2118        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2119    let sidebar = setup_sidebar(&multi_workspace, cx);
2120
2121    multi_workspace.update_in(cx, |mw, window, cx| {
2122        mw.create_test_workspace(window, cx).detach();
2123    });
2124    cx.run_until_parked();
2125
2126    let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
2127        (
2128            mw.workspaces().next().unwrap().clone(),
2129            mw.workspaces().nth(1).unwrap().clone(),
2130        )
2131    });
2132
2133    save_thread_metadata(
2134        acp::SessionId::new(Arc::from("hist-1")),
2135        "Historical Thread".into(),
2136        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
2137        None,
2138        &project,
2139        cx,
2140    );
2141    cx.run_until_parked();
2142    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2143    cx.run_until_parked();
2144
2145    assert_eq!(
2146        visible_entries_as_strings(&sidebar, cx),
2147        vec![
2148            //
2149            "v [my-project]",
2150            "  Historical Thread",
2151        ]
2152    );
2153
2154    // Switch to workspace 1 so we can verify the confirm switches back.
2155    multi_workspace.update_in(cx, |mw, window, cx| {
2156        let workspace = mw.workspaces().nth(1).unwrap().clone();
2157        mw.activate(workspace, window, cx);
2158    });
2159    cx.run_until_parked();
2160    assert_eq!(
2161        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2162        workspace_1
2163    );
2164
2165    // Confirm on the historical (non-live) thread at index 1.
2166    // Before a previous fix, the workspace field was Option<usize> and
2167    // historical threads had None, so activate_thread early-returned
2168    // without switching the workspace.
2169    sidebar.update_in(cx, |sidebar, window, cx| {
2170        sidebar.selection = Some(1);
2171        sidebar.confirm(&Confirm, window, cx);
2172    });
2173    cx.run_until_parked();
2174
2175    assert_eq!(
2176        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
2177        workspace_0
2178    );
2179}
2180
2181#[gpui::test]
2182async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
2183    let project = init_test_project("/my-project", cx).await;
2184    let (multi_workspace, cx) =
2185        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2186    let sidebar = setup_sidebar(&multi_workspace, cx);
2187
2188    save_thread_metadata(
2189        acp::SessionId::new(Arc::from("t-1")),
2190        "Thread A".into(),
2191        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2192        None,
2193        &project,
2194        cx,
2195    );
2196
2197    save_thread_metadata(
2198        acp::SessionId::new(Arc::from("t-2")),
2199        "Thread B".into(),
2200        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2201        None,
2202        &project,
2203        cx,
2204    );
2205
2206    cx.run_until_parked();
2207    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2208    cx.run_until_parked();
2209
2210    assert_eq!(
2211        visible_entries_as_strings(&sidebar, cx),
2212        vec![
2213            //
2214            "v [my-project]",
2215            "  Thread A",
2216            "  Thread B",
2217        ]
2218    );
2219
2220    // Keyboard confirm preserves selection.
2221    sidebar.update_in(cx, |sidebar, window, cx| {
2222        sidebar.selection = Some(1);
2223        sidebar.confirm(&Confirm, window, cx);
2224    });
2225    assert_eq!(
2226        sidebar.read_with(cx, |sidebar, _| sidebar.selection),
2227        Some(1)
2228    );
2229
2230    // Click handlers clear selection to None so no highlight lingers
2231    // after a click regardless of focus state. The hover style provides
2232    // visual feedback during mouse interaction instead.
2233    sidebar.update_in(cx, |sidebar, window, cx| {
2234        sidebar.selection = None;
2235        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2236        let project_group_key = project::ProjectGroupKey::new(None, path_list);
2237        sidebar.toggle_collapse(&project_group_key, window, cx);
2238    });
2239    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2240
2241    // When the user tabs back into the sidebar, focus_in no longer
2242    // restores selection — it stays None.
2243    sidebar.update_in(cx, |sidebar, window, cx| {
2244        sidebar.focus_in(window, cx);
2245    });
2246    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2247}
2248
2249#[gpui::test]
2250async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
2251    let project = init_test_project_with_agent_panel("/my-project", cx).await;
2252    let (multi_workspace, cx) =
2253        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2254    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2255
2256    let connection = StubAgentConnection::new();
2257    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2258        acp::ContentChunk::new("Hi there!".into()),
2259    )]);
2260    open_thread_with_connection(&panel, connection, cx);
2261    send_message(&panel, cx);
2262
2263    let session_id = active_session_id(&panel, cx);
2264    save_test_thread_metadata(&session_id, &project, cx).await;
2265    cx.run_until_parked();
2266
2267    assert_eq!(
2268        visible_entries_as_strings(&sidebar, cx),
2269        vec![
2270            //
2271            "v [my-project]",
2272            "  Hello * (active)",
2273        ]
2274    );
2275
2276    // Simulate the agent generating a title. The notification chain is:
2277    // AcpThread::set_title emits TitleUpdated →
2278    // ConnectionView::handle_thread_event calls cx.notify() →
2279    // AgentPanel observer fires and emits AgentPanelEvent →
2280    // Sidebar subscription calls update_entries / rebuild_contents.
2281    //
2282    // Before the fix, handle_thread_event did NOT call cx.notify() for
2283    // TitleUpdated, so the AgentPanel observer never fired and the
2284    // sidebar kept showing the old title.
2285    let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2286    thread.update(cx, |thread, cx| {
2287        thread
2288            .set_title("Friendly Greeting with AI".into(), cx)
2289            .detach();
2290    });
2291    cx.run_until_parked();
2292
2293    assert_eq!(
2294        visible_entries_as_strings(&sidebar, cx),
2295        vec![
2296            //
2297            "v [my-project]",
2298            "  Friendly Greeting with AI * (active)",
2299        ]
2300    );
2301}
2302
2303#[gpui::test]
2304async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
2305    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2306    let (multi_workspace, cx) =
2307        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2308    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2309
2310    // Save a thread so it appears in the list.
2311    let connection_a = StubAgentConnection::new();
2312    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2313        acp::ContentChunk::new("Done".into()),
2314    )]);
2315    open_thread_with_connection(&panel_a, connection_a, cx);
2316    send_message(&panel_a, cx);
2317    let session_id_a = active_session_id(&panel_a, cx);
2318    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
2319
2320    // Add a second workspace with its own agent panel.
2321    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2322    fs.as_fake()
2323        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2324        .await;
2325    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2326    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2327        mw.test_add_workspace(project_b.clone(), window, cx)
2328    });
2329    let panel_b = add_agent_panel(&workspace_b, cx);
2330    cx.run_until_parked();
2331
2332    let workspace_a =
2333        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2334
2335    // ── 1. Initial state: focused thread derived from active panel ─────
2336    sidebar.read_with(cx, |sidebar, _cx| {
2337        assert_active_thread(
2338            sidebar,
2339            &session_id_a,
2340            "The active panel's thread should be focused on startup",
2341        );
2342    });
2343
2344    sidebar.update_in(cx, |sidebar, window, cx| {
2345        sidebar.activate_thread(
2346            ThreadMetadata {
2347                session_id: session_id_a.clone(),
2348                agent_id: agent::ZED_AGENT_ID.clone(),
2349                title: "Test".into(),
2350                updated_at: Utc::now(),
2351                created_at: None,
2352                worktree_paths: ThreadWorktreePaths::default(),
2353                archived: false,
2354                remote_connection: None,
2355            },
2356            &workspace_a,
2357            false,
2358            window,
2359            cx,
2360        );
2361    });
2362    cx.run_until_parked();
2363
2364    sidebar.read_with(cx, |sidebar, _cx| {
2365        assert_active_thread(
2366            sidebar,
2367            &session_id_a,
2368            "After clicking a thread, it should be the focused thread",
2369        );
2370        assert!(
2371            has_thread_entry(sidebar, &session_id_a),
2372            "The clicked thread should be present in the entries"
2373        );
2374    });
2375
2376    workspace_a.read_with(cx, |workspace, cx| {
2377        assert!(
2378            workspace.panel::<AgentPanel>(cx).is_some(),
2379            "Agent panel should exist"
2380        );
2381        let dock = workspace.left_dock().read(cx);
2382        assert!(
2383            dock.is_open(),
2384            "Clicking a thread should open the agent panel dock"
2385        );
2386    });
2387
2388    let connection_b = StubAgentConnection::new();
2389    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2390        acp::ContentChunk::new("Thread B".into()),
2391    )]);
2392    open_thread_with_connection(&panel_b, connection_b, cx);
2393    send_message(&panel_b, cx);
2394    let session_id_b = active_session_id(&panel_b, cx);
2395    save_test_thread_metadata(&session_id_b, &project_b, cx).await;
2396    cx.run_until_parked();
2397
2398    // Workspace A is currently active. Click a thread in workspace B,
2399    // which also triggers a workspace switch.
2400    sidebar.update_in(cx, |sidebar, window, cx| {
2401        sidebar.activate_thread(
2402            ThreadMetadata {
2403                session_id: session_id_b.clone(),
2404                agent_id: agent::ZED_AGENT_ID.clone(),
2405                title: "Thread B".into(),
2406                updated_at: Utc::now(),
2407                created_at: None,
2408                worktree_paths: ThreadWorktreePaths::default(),
2409                archived: false,
2410                remote_connection: None,
2411            },
2412            &workspace_b,
2413            false,
2414            window,
2415            cx,
2416        );
2417    });
2418    cx.run_until_parked();
2419
2420    sidebar.read_with(cx, |sidebar, _cx| {
2421        assert_active_thread(
2422            sidebar,
2423            &session_id_b,
2424            "Clicking a thread in another workspace should focus that thread",
2425        );
2426        assert!(
2427            has_thread_entry(sidebar, &session_id_b),
2428            "The cross-workspace thread should be present in the entries"
2429        );
2430    });
2431
2432    multi_workspace.update_in(cx, |mw, window, cx| {
2433        let workspace = mw.workspaces().next().unwrap().clone();
2434        mw.activate(workspace, window, cx);
2435    });
2436    cx.run_until_parked();
2437
2438    sidebar.read_with(cx, |sidebar, _cx| {
2439        assert_active_thread(
2440            sidebar,
2441            &session_id_a,
2442            "Switching workspace should seed focused_thread from the new active panel",
2443        );
2444        assert!(
2445            has_thread_entry(sidebar, &session_id_a),
2446            "The seeded thread should be present in the entries"
2447        );
2448    });
2449
2450    let connection_b2 = StubAgentConnection::new();
2451    connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2452        acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
2453    )]);
2454    open_thread_with_connection(&panel_b, connection_b2, cx);
2455    send_message(&panel_b, cx);
2456    let session_id_b2 = active_session_id(&panel_b, cx);
2457    save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
2458    cx.run_until_parked();
2459
2460    // Panel B is not the active workspace's panel (workspace A is
2461    // active), so opening a thread there should not change focused_thread.
2462    // This prevents running threads in background workspaces from causing
2463    // the selection highlight to jump around.
2464    sidebar.read_with(cx, |sidebar, _cx| {
2465        assert_active_thread(
2466            sidebar,
2467            &session_id_a,
2468            "Opening a thread in a non-active panel should not change focused_thread",
2469        );
2470    });
2471
2472    workspace_b.update_in(cx, |workspace, window, cx| {
2473        workspace.focus_handle(cx).focus(window, cx);
2474    });
2475    cx.run_until_parked();
2476
2477    sidebar.read_with(cx, |sidebar, _cx| {
2478        assert_active_thread(
2479            sidebar,
2480            &session_id_a,
2481            "Defocusing the sidebar should not change focused_thread",
2482        );
2483    });
2484
2485    // Switching workspaces via the multi_workspace (simulates clicking
2486    // a workspace header) should clear focused_thread.
2487    multi_workspace.update_in(cx, |mw, window, cx| {
2488        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
2489        if let Some(workspace) = workspace {
2490            mw.activate(workspace, window, cx);
2491        }
2492    });
2493    cx.run_until_parked();
2494
2495    sidebar.read_with(cx, |sidebar, _cx| {
2496        assert_active_thread(
2497            sidebar,
2498            &session_id_b2,
2499            "Switching workspace should seed focused_thread from the new active panel",
2500        );
2501        assert!(
2502            has_thread_entry(sidebar, &session_id_b2),
2503            "The seeded thread should be present in the entries"
2504        );
2505    });
2506
2507    // ── 8. Focusing the agent panel thread keeps focused_thread ────
2508    // Workspace B still has session_id_b2 loaded in the agent panel.
2509    // Clicking into the thread (simulated by focusing its view) should
2510    // keep focused_thread since it was already seeded on workspace switch.
2511    panel_b.update_in(cx, |panel, window, cx| {
2512        if let Some(thread_view) = panel.active_conversation_view() {
2513            thread_view.read(cx).focus_handle(cx).focus(window, cx);
2514        }
2515    });
2516    cx.run_until_parked();
2517
2518    sidebar.read_with(cx, |sidebar, _cx| {
2519        assert_active_thread(
2520            sidebar,
2521            &session_id_b2,
2522            "Focusing the agent panel thread should set focused_thread",
2523        );
2524        assert!(
2525            has_thread_entry(sidebar, &session_id_b2),
2526            "The focused thread should be present in the entries"
2527        );
2528    });
2529}
2530
2531#[gpui::test]
2532async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
2533    let project = init_test_project_with_agent_panel("/project-a", cx).await;
2534    let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
2535    let (multi_workspace, cx) =
2536        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2537    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2538
2539    // Start a thread and send a message so it has history.
2540    let connection = StubAgentConnection::new();
2541    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2542        acp::ContentChunk::new("Done".into()),
2543    )]);
2544    open_thread_with_connection(&panel, connection, cx);
2545    send_message(&panel, cx);
2546    let session_id = active_session_id(&panel, cx);
2547    save_test_thread_metadata(&session_id, &project, cx).await;
2548    cx.run_until_parked();
2549
2550    // Verify the thread appears in the sidebar.
2551    assert_eq!(
2552        visible_entries_as_strings(&sidebar, cx),
2553        vec![
2554            //
2555            "v [project-a]",
2556            "  Hello * (active)",
2557        ]
2558    );
2559
2560    // The "New Thread" button should NOT be in "active/draft" state
2561    // because the panel has a thread with messages.
2562    sidebar.read_with(cx, |sidebar, _cx| {
2563        assert!(
2564            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
2565            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
2566            sidebar.active_entry,
2567        );
2568    });
2569
2570    // Now add a second folder to the workspace, changing the path_list.
2571    fs.as_fake()
2572        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2573        .await;
2574    project
2575        .update(cx, |project, cx| {
2576            project.find_or_create_worktree("/project-b", true, cx)
2577        })
2578        .await
2579        .expect("should add worktree");
2580    cx.run_until_parked();
2581
2582    // The workspace path_list is now [project-a, project-b]. The active
2583    // thread's metadata was re-saved with the new paths by the agent panel's
2584    // project subscription. The old [project-a] key is replaced by the new
2585    // key since no other workspace claims it.
2586    assert_eq!(
2587        visible_entries_as_strings(&sidebar, cx),
2588        vec![
2589            "v [project-a, project-b]", //
2590            "  Hello *",
2591            "v [project-a]",
2592            "  [~ Draft]",
2593        ]
2594    );
2595
2596    // The "New Thread" button must still be clickable (not stuck in
2597    // "active/draft" state). Verify that `active_thread_is_draft` is
2598    // false — the panel still has the old thread with messages.
2599    sidebar.read_with(cx, |sidebar, _cx| {
2600        assert!(
2601            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
2602            "After adding a folder the panel still has a thread with messages, \
2603                 so active_entry should be Thread, got {:?}",
2604            sidebar.active_entry,
2605        );
2606    });
2607
2608    // Actually click "New Thread" by calling create_new_thread and
2609    // verify a new draft is created.
2610    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2611    sidebar.update_in(cx, |sidebar, window, cx| {
2612        sidebar.create_new_thread(&workspace, window, cx);
2613    });
2614    cx.run_until_parked();
2615
2616    // After creating a new thread, the panel should now be in draft
2617    // state (no messages on the new thread).
2618    sidebar.read_with(cx, |sidebar, _cx| {
2619        assert_active_draft(
2620            sidebar,
2621            &workspace,
2622            "After creating a new thread active_entry should be Draft",
2623        );
2624    });
2625}
2626
2627#[gpui::test]
2628async fn test_worktree_add_and_remove_migrates_threads(cx: &mut TestAppContext) {
2629    // When a worktree is added to a project, the project group key changes
2630    // and all historical threads should be migrated to the new key. Removing
2631    // the worktree should migrate them back.
2632    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
2633    let (multi_workspace, cx) =
2634        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2635    let sidebar = setup_sidebar(&multi_workspace, cx);
2636
2637    // Save two threads against the initial project group [/project-a].
2638    save_n_test_threads(2, &project, cx).await;
2639    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
2640    cx.run_until_parked();
2641
2642    assert_eq!(
2643        visible_entries_as_strings(&sidebar, cx),
2644        vec![
2645            //
2646            "v [project-a]",
2647            "  Thread 2",
2648            "  Thread 1",
2649        ]
2650    );
2651
2652    // Verify the metadata store has threads under the old key.
2653    let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
2654    cx.update(|_window, cx| {
2655        let store = ThreadMetadataStore::global(cx).read(cx);
2656        assert_eq!(
2657            store.entries_for_main_worktree_path(&old_key_paths).count(),
2658            2,
2659            "should have 2 threads under old key before add"
2660        );
2661    });
2662
2663    // Add a second worktree to the same project.
2664    project
2665        .update(cx, |project, cx| {
2666            project.find_or_create_worktree("/project-b", true, cx)
2667        })
2668        .await
2669        .expect("should add worktree");
2670    cx.run_until_parked();
2671
2672    // The project group key should now be [/project-a, /project-b].
2673    let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
2674
2675    // Verify multi-workspace state: exactly one project group key, the new one.
2676    multi_workspace.read_with(cx, |mw, _cx| {
2677        let keys: Vec<_> = mw.project_group_keys().cloned().collect();
2678        assert_eq!(
2679            keys.len(),
2680            1,
2681            "should have exactly 1 project group key after add"
2682        );
2683        assert_eq!(
2684            keys[0].path_list(),
2685            &new_key_paths,
2686            "the key should be the new combined path list"
2687        );
2688    });
2689
2690    // Verify threads were migrated to the new key.
2691    cx.update(|_window, cx| {
2692        let store = ThreadMetadataStore::global(cx).read(cx);
2693        assert_eq!(
2694            store.entries_for_main_worktree_path(&old_key_paths).count(),
2695            0,
2696            "should have 0 threads under old key after migration"
2697        );
2698        assert_eq!(
2699            store.entries_for_main_worktree_path(&new_key_paths).count(),
2700            2,
2701            "should have 2 threads under new key after migration"
2702        );
2703    });
2704
2705    // Sidebar should show threads under the new header.
2706    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
2707    cx.run_until_parked();
2708
2709    assert_eq!(
2710        visible_entries_as_strings(&sidebar, cx),
2711        vec![
2712            //
2713            "v [project-a, project-b]",
2714            "  Thread 2",
2715            "  Thread 1",
2716        ]
2717    );
2718
2719    // Now remove the second worktree.
2720    let worktree_id = project.read_with(cx, |project, cx| {
2721        project
2722            .visible_worktrees(cx)
2723            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
2724            .map(|wt| wt.read(cx).id())
2725            .expect("should find project-b worktree")
2726    });
2727    project.update(cx, |project, cx| {
2728        project.remove_worktree(worktree_id, cx);
2729    });
2730    cx.run_until_parked();
2731
2732    // The key should revert to [/project-a].
2733    multi_workspace.read_with(cx, |mw, _cx| {
2734        let keys: Vec<_> = mw.project_group_keys().cloned().collect();
2735        assert_eq!(
2736            keys.len(),
2737            1,
2738            "should have exactly 1 project group key after remove"
2739        );
2740        assert_eq!(
2741            keys[0].path_list(),
2742            &old_key_paths,
2743            "the key should revert to the original path list"
2744        );
2745    });
2746
2747    // Threads should be migrated back to the old key.
2748    cx.update(|_window, cx| {
2749        let store = ThreadMetadataStore::global(cx).read(cx);
2750        assert_eq!(
2751            store.entries_for_main_worktree_path(&new_key_paths).count(),
2752            0,
2753            "should have 0 threads under new key after revert"
2754        );
2755        assert_eq!(
2756            store.entries_for_main_worktree_path(&old_key_paths).count(),
2757            2,
2758            "should have 2 threads under old key after revert"
2759        );
2760    });
2761
2762    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
2763    cx.run_until_parked();
2764
2765    assert_eq!(
2766        visible_entries_as_strings(&sidebar, cx),
2767        vec![
2768            //
2769            "v [project-a]",
2770            "  Thread 2",
2771            "  Thread 1",
2772        ]
2773    );
2774}
2775
2776#[gpui::test]
2777async fn test_worktree_add_and_remove_preserves_thread_path_associations(cx: &mut TestAppContext) {
2778    // Verifies that adding/removing folders to a project correctly updates
2779    // each thread's worktree_paths (both folder_paths and main_worktree_paths)
2780    // while preserving per-path associations for linked worktrees.
2781    init_test(cx);
2782    let fs = FakeFs::new(cx.executor());
2783    fs.insert_tree(
2784        "/project",
2785        serde_json::json!({
2786            ".git": {},
2787            "src": {},
2788        }),
2789    )
2790    .await;
2791    fs.add_linked_worktree_for_repo(
2792        Path::new("/project/.git"),
2793        false,
2794        git::repository::Worktree {
2795            path: PathBuf::from("/wt-feature"),
2796            ref_name: Some("refs/heads/feature".into()),
2797            sha: "aaa".into(),
2798            is_main: false,
2799        },
2800    )
2801    .await;
2802    fs.insert_tree("/other-project", serde_json::json!({ ".git": {} }))
2803        .await;
2804    cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
2805
2806    // Start with a linked worktree workspace: visible root is /wt-feature,
2807    // main repo is /project.
2808    let project =
2809        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/wt-feature".as_ref()], cx).await;
2810    let (multi_workspace, cx) =
2811        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2812    let _sidebar = setup_sidebar(&multi_workspace, cx);
2813
2814    // Save a thread. It should have folder_paths=[/wt-feature], main=[/project].
2815    save_named_thread_metadata("thread-1", "Thread 1", &project, cx).await;
2816
2817    let session_id = acp::SessionId::new(Arc::from("thread-1"));
2818    cx.update(|_window, cx| {
2819        let store = ThreadMetadataStore::global(cx).read(cx);
2820        let thread = store.entry(&session_id).expect("thread should exist");
2821        assert_eq!(
2822            thread.folder_paths().paths(),
2823            &[PathBuf::from("/wt-feature")],
2824            "initial folder_paths should be the linked worktree"
2825        );
2826        assert_eq!(
2827            thread.main_worktree_paths().paths(),
2828            &[PathBuf::from("/project")],
2829            "initial main_worktree_paths should be the main repo"
2830        );
2831    });
2832
2833    // Add /other-project to the workspace.
2834    project
2835        .update(cx, |project, cx| {
2836            project.find_or_create_worktree("/other-project", true, cx)
2837        })
2838        .await
2839        .expect("should add worktree");
2840    cx.run_until_parked();
2841
2842    // Thread should now have both paths, with correct associations.
2843    cx.update(|_window, cx| {
2844        let store = ThreadMetadataStore::global(cx).read(cx);
2845        let thread = store.entry(&session_id).expect("thread should exist");
2846        let pairs: Vec<_> = thread
2847            .worktree_paths
2848            .ordered_pairs()
2849            .map(|(m, f)| (m.clone(), f.clone()))
2850            .collect();
2851        assert!(
2852            pairs.contains(&(PathBuf::from("/project"), PathBuf::from("/wt-feature"))),
2853            "linked worktree association should be preserved, got: {:?}",
2854            pairs
2855        );
2856        assert!(
2857            pairs.contains(&(
2858                PathBuf::from("/other-project"),
2859                PathBuf::from("/other-project")
2860            )),
2861            "new folder should have main == folder, got: {:?}",
2862            pairs
2863        );
2864    });
2865
2866    // Remove /other-project.
2867    let worktree_id = project.read_with(cx, |project, cx| {
2868        project
2869            .visible_worktrees(cx)
2870            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/other-project"))
2871            .map(|wt| wt.read(cx).id())
2872            .expect("should find other-project worktree")
2873    });
2874    project.update(cx, |project, cx| {
2875        project.remove_worktree(worktree_id, cx);
2876    });
2877    cx.run_until_parked();
2878
2879    // Thread should be back to original state.
2880    cx.update(|_window, cx| {
2881        let store = ThreadMetadataStore::global(cx).read(cx);
2882        let thread = store.entry(&session_id).expect("thread should exist");
2883        assert_eq!(
2884            thread.folder_paths().paths(),
2885            &[PathBuf::from("/wt-feature")],
2886            "folder_paths should revert to just the linked worktree"
2887        );
2888        assert_eq!(
2889            thread.main_worktree_paths().paths(),
2890            &[PathBuf::from("/project")],
2891            "main_worktree_paths should revert to just the main repo"
2892        );
2893        let pairs: Vec<_> = thread
2894            .worktree_paths
2895            .ordered_pairs()
2896            .map(|(m, f)| (m.clone(), f.clone()))
2897            .collect();
2898        assert_eq!(
2899            pairs,
2900            vec![(PathBuf::from("/project"), PathBuf::from("/wt-feature"))],
2901            "linked worktree association should be preserved through add+remove cycle"
2902        );
2903    });
2904}
2905
2906#[gpui::test]
2907async fn test_worktree_add_key_collision_removes_duplicate_workspace(cx: &mut TestAppContext) {
2908    // When a worktree is added to workspace A and the resulting key matches
2909    // an existing workspace B's key (and B has the same root paths), B
2910    // should be removed as a true duplicate.
2911    let (fs, project_a) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
2912    let (multi_workspace, cx) =
2913        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2914    let sidebar = setup_sidebar(&multi_workspace, cx);
2915
2916    // Save a thread against workspace A [/project-a].
2917    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
2918
2919    // Create workspace B with both worktrees [/project-a, /project-b].
2920    let project_b = project::Project::test(
2921        fs.clone() as Arc<dyn Fs>,
2922        ["/project-a".as_ref(), "/project-b".as_ref()],
2923        cx,
2924    )
2925    .await;
2926    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2927        mw.test_add_workspace(project_b.clone(), window, cx)
2928    });
2929    cx.run_until_parked();
2930
2931    // Switch back to workspace A so it's the active workspace when the collision happens.
2932    let workspace_a =
2933        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
2934    multi_workspace.update_in(cx, |mw, window, cx| {
2935        mw.activate(workspace_a, window, cx);
2936    });
2937    cx.run_until_parked();
2938
2939    // Save a thread against workspace B [/project-a, /project-b].
2940    save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
2941
2942    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
2943    cx.run_until_parked();
2944
2945    // Both project groups should be visible.
2946    assert_eq!(
2947        visible_entries_as_strings(&sidebar, cx),
2948        vec![
2949            //
2950            "v [project-a, project-b]",
2951            "  Thread B",
2952            "v [project-a]",
2953            "  Thread A",
2954        ]
2955    );
2956
2957    let workspace_b_id = workspace_b.entity_id();
2958
2959    // Now add /project-b to workspace A's project, causing a key collision.
2960    project_a
2961        .update(cx, |project, cx| {
2962            project.find_or_create_worktree("/project-b", true, cx)
2963        })
2964        .await
2965        .expect("should add worktree");
2966    cx.run_until_parked();
2967
2968    // Workspace B should have been removed (true duplicate — same root paths).
2969    multi_workspace.read_with(cx, |mw, _cx| {
2970        let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
2971        assert!(
2972            !workspace_ids.contains(&workspace_b_id),
2973            "workspace B should have been removed after key collision"
2974        );
2975    });
2976
2977    // There should be exactly one project group key now.
2978    let combined_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
2979    multi_workspace.read_with(cx, |mw, _cx| {
2980        let keys: Vec<_> = mw.project_group_keys().cloned().collect();
2981        assert_eq!(
2982            keys.len(),
2983            1,
2984            "should have exactly 1 project group key after collision"
2985        );
2986        assert_eq!(
2987            keys[0].path_list(),
2988            &combined_paths,
2989            "the remaining key should be the combined paths"
2990        );
2991    });
2992
2993    // Both threads should be visible under the merged group.
2994    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
2995    cx.run_until_parked();
2996
2997    assert_eq!(
2998        visible_entries_as_strings(&sidebar, cx),
2999        vec![
3000            //
3001            "v [project-a, project-b]",
3002            "  Thread A",
3003            "  Thread B",
3004        ]
3005    );
3006}
3007
3008#[gpui::test]
3009async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext) {
3010    // When workspace A adds a folder that makes it collide with workspace B,
3011    // and B is the *active* workspace, A (the incoming one) should be
3012    // dropped so the user stays on B. A linked worktree sibling of A
3013    // should migrate into B's group.
3014    init_test(cx);
3015    let fs = FakeFs::new(cx.executor());
3016
3017    // Set up /project-a with a linked worktree.
3018    fs.insert_tree(
3019        "/project-a",
3020        serde_json::json!({
3021            ".git": {
3022                "worktrees": {
3023                    "feature": {
3024                        "commondir": "../../",
3025                        "HEAD": "ref: refs/heads/feature",
3026                    },
3027                },
3028            },
3029            "src": {},
3030        }),
3031    )
3032    .await;
3033    fs.insert_tree(
3034        "/wt-feature",
3035        serde_json::json!({
3036            ".git": "gitdir: /project-a/.git/worktrees/feature",
3037            "src": {},
3038        }),
3039    )
3040    .await;
3041    fs.add_linked_worktree_for_repo(
3042        Path::new("/project-a/.git"),
3043        false,
3044        git::repository::Worktree {
3045            path: PathBuf::from("/wt-feature"),
3046            ref_name: Some("refs/heads/feature".into()),
3047            sha: "aaa".into(),
3048            is_main: false,
3049        },
3050    )
3051    .await;
3052    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
3053        .await;
3054    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3055
3056    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3057    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3058
3059    // Linked worktree sibling of A.
3060    let project_wt = project::Project::test(fs.clone(), ["/wt-feature".as_ref()], cx).await;
3061    project_wt
3062        .update(cx, |p, cx| p.git_scans_complete(cx))
3063        .await;
3064
3065    // Workspace B has both folders already.
3066    let project_b = project::Project::test(
3067        fs.clone() as Arc<dyn Fs>,
3068        ["/project-a".as_ref(), "/project-b".as_ref()],
3069        cx,
3070    )
3071    .await;
3072
3073    let (multi_workspace, cx) =
3074        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3075    let sidebar = setup_sidebar(&multi_workspace, cx);
3076
3077    // Add agent panels to all workspaces.
3078    let workspace_a_entity = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3079    add_agent_panel(&workspace_a_entity, cx);
3080
3081    // Add the linked worktree workspace (sibling of A).
3082    let workspace_wt = multi_workspace.update_in(cx, |mw, window, cx| {
3083        mw.test_add_workspace(project_wt.clone(), window, cx)
3084    });
3085    add_agent_panel(&workspace_wt, cx);
3086    cx.run_until_parked();
3087
3088    // Add workspace B (will become active).
3089    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3090        mw.test_add_workspace(project_b.clone(), window, cx)
3091    });
3092    add_agent_panel(&workspace_b, cx);
3093    cx.run_until_parked();
3094
3095    // Save threads in each group.
3096    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
3097    save_thread_metadata_with_main_paths(
3098        "thread-wt",
3099        "Worktree Thread",
3100        PathList::new(&[PathBuf::from("/wt-feature")]),
3101        PathList::new(&[PathBuf::from("/project-a")]),
3102        cx,
3103    );
3104    save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
3105
3106    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
3107    cx.run_until_parked();
3108
3109    // B is active, A and wt-feature are in one group, B in another.
3110    assert_eq!(
3111        multi_workspace.read_with(cx, |mw, _| mw.workspace().entity_id()),
3112        workspace_b.entity_id(),
3113        "workspace B should be active"
3114    );
3115    multi_workspace.read_with(cx, |mw, _cx| {
3116        assert_eq!(mw.project_group_keys().count(), 2, "should have 2 groups");
3117        assert_eq!(mw.workspaces().count(), 3, "should have 3 workspaces");
3118    });
3119
3120    assert_eq!(
3121        visible_entries_as_strings(&sidebar, cx),
3122        vec![
3123            //
3124            "v [project-a, project-b]",
3125            "  [~ Draft] (active)",
3126            "  Thread B",
3127            "v [project-a]",
3128            "  Thread A",
3129            "  Worktree Thread {wt-feature}",
3130        ]
3131    );
3132
3133    let workspace_a = multi_workspace.read_with(cx, |mw, _| {
3134        mw.workspaces()
3135            .find(|ws| {
3136                ws.entity_id() != workspace_b.entity_id()
3137                    && ws.entity_id() != workspace_wt.entity_id()
3138            })
3139            .unwrap()
3140            .clone()
3141    });
3142
3143    // Add /project-b to workspace A's project, causing a collision with B.
3144    project_a
3145        .update(cx, |project, cx| {
3146            project.find_or_create_worktree("/project-b", true, cx)
3147        })
3148        .await
3149        .expect("should add worktree");
3150    cx.run_until_parked();
3151
3152    // Workspace A (the incoming duplicate) should have been dropped.
3153    multi_workspace.read_with(cx, |mw, _cx| {
3154        let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
3155        assert!(
3156            !workspace_ids.contains(&workspace_a.entity_id()),
3157            "workspace A should have been dropped"
3158        );
3159    });
3160
3161    // The active workspace should still be B.
3162    assert_eq!(
3163        multi_workspace.read_with(cx, |mw, _| mw.workspace().entity_id()),
3164        workspace_b.entity_id(),
3165        "workspace B should still be active"
3166    );
3167
3168    // The linked worktree sibling should have migrated into B's group
3169    // (it got the folder add and now shares the same key).
3170    multi_workspace.read_with(cx, |mw, _cx| {
3171        let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
3172        assert!(
3173            workspace_ids.contains(&workspace_wt.entity_id()),
3174            "linked worktree workspace should still exist"
3175        );
3176        assert_eq!(
3177            mw.project_group_keys().count(),
3178            1,
3179            "should have 1 group after merge"
3180        );
3181        assert_eq!(
3182            mw.workspaces().count(),
3183            2,
3184            "should have 2 workspaces (B + linked worktree)"
3185        );
3186    });
3187
3188    // The linked worktree workspace should have gotten the new folder.
3189    let wt_worktree_count =
3190        project_wt.read_with(cx, |project, cx| project.visible_worktrees(cx).count());
3191    assert_eq!(
3192        wt_worktree_count, 2,
3193        "linked worktree project should have gotten /project-b"
3194    );
3195
3196    // After: everything merged under one group. Thread A migrated,
3197    // worktree thread shows its chip, B's thread and draft remain.
3198    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
3199    cx.run_until_parked();
3200
3201    assert_eq!(
3202        visible_entries_as_strings(&sidebar, cx),
3203        vec![
3204            //
3205            "v [project-a, project-b]",
3206            "  [~ Draft] (active)",
3207            "  Thread A",
3208            "  Worktree Thread {project-a:wt-feature}",
3209            "  Thread B",
3210        ]
3211    );
3212}
3213
3214#[gpui::test]
3215async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext) {
3216    // When a worktree is added to the main workspace, a linked worktree
3217    // sibling (different root paths, same project group key) should also
3218    // get the new folder added to its project.
3219    init_test(cx);
3220    let fs = FakeFs::new(cx.executor());
3221
3222    fs.insert_tree(
3223        "/project",
3224        serde_json::json!({
3225            ".git": {
3226                "worktrees": {
3227                    "feature": {
3228                        "commondir": "../../",
3229                        "HEAD": "ref: refs/heads/feature",
3230                    },
3231                },
3232            },
3233            "src": {},
3234        }),
3235    )
3236    .await;
3237
3238    fs.insert_tree(
3239        "/wt-feature",
3240        serde_json::json!({
3241            ".git": "gitdir: /project/.git/worktrees/feature",
3242            "src": {},
3243        }),
3244    )
3245    .await;
3246
3247    fs.add_linked_worktree_for_repo(
3248        Path::new("/project/.git"),
3249        false,
3250        git::repository::Worktree {
3251            path: PathBuf::from("/wt-feature"),
3252            ref_name: Some("refs/heads/feature".into()),
3253            sha: "aaa".into(),
3254            is_main: false,
3255        },
3256    )
3257    .await;
3258
3259    // Create a second independent project to add as a folder later.
3260    fs.insert_tree(
3261        "/other-project",
3262        serde_json::json!({ ".git": {}, "src": {} }),
3263    )
3264    .await;
3265
3266    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3267
3268    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3269    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature".as_ref()], cx).await;
3270
3271    main_project
3272        .update(cx, |p, cx| p.git_scans_complete(cx))
3273        .await;
3274    worktree_project
3275        .update(cx, |p, cx| p.git_scans_complete(cx))
3276        .await;
3277
3278    let (multi_workspace, cx) =
3279        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3280    let sidebar = setup_sidebar(&multi_workspace, cx);
3281
3282    // Add agent panel to the main workspace.
3283    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3284    add_agent_panel(&main_workspace, cx);
3285
3286    // Open the linked worktree as a separate workspace.
3287    let wt_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3288        mw.test_add_workspace(worktree_project.clone(), window, cx)
3289    });
3290    add_agent_panel(&wt_workspace, cx);
3291    cx.run_until_parked();
3292
3293    // Both workspaces should share the same project group key [/project].
3294    multi_workspace.read_with(cx, |mw, _cx| {
3295        assert_eq!(
3296            mw.project_group_keys().count(),
3297            1,
3298            "should have 1 project group key before add"
3299        );
3300        assert_eq!(mw.workspaces().count(), 2, "should have 2 workspaces");
3301    });
3302
3303    // Save threads against each workspace.
3304    save_named_thread_metadata("main-thread", "Main Thread", &main_project, cx).await;
3305    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
3306
3307    // Verify both threads are under the old key [/project].
3308    let old_key_paths = PathList::new(&[PathBuf::from("/project")]);
3309    cx.update(|_window, cx| {
3310        let store = ThreadMetadataStore::global(cx).read(cx);
3311        assert_eq!(
3312            store.entries_for_main_worktree_path(&old_key_paths).count(),
3313            2,
3314            "should have 2 threads under old key before add"
3315        );
3316    });
3317
3318    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
3319    cx.run_until_parked();
3320
3321    assert_eq!(
3322        visible_entries_as_strings(&sidebar, cx),
3323        vec![
3324            //
3325            "v [project]",
3326            "  [~ Draft {wt-feature}] (active)",
3327            "  Worktree Thread {wt-feature}",
3328            "  Main Thread",
3329        ]
3330    );
3331
3332    // Add /other-project as a folder to the main workspace.
3333    main_project
3334        .update(cx, |project, cx| {
3335            project.find_or_create_worktree("/other-project", true, cx)
3336        })
3337        .await
3338        .expect("should add worktree");
3339    cx.run_until_parked();
3340
3341    // The linked worktree workspace should have gotten the new folder too.
3342    let wt_worktree_count =
3343        worktree_project.read_with(cx, |project, cx| project.visible_worktrees(cx).count());
3344    assert_eq!(
3345        wt_worktree_count, 2,
3346        "linked worktree project should have gotten the new folder"
3347    );
3348
3349    // Both workspaces should still exist under one key.
3350    multi_workspace.read_with(cx, |mw, _cx| {
3351        assert_eq!(mw.workspaces().count(), 2, "both workspaces should survive");
3352        assert_eq!(
3353            mw.project_group_keys().count(),
3354            1,
3355            "should still have 1 project group key"
3356        );
3357    });
3358
3359    // Threads should have been migrated to the new key.
3360    let new_key_paths =
3361        PathList::new(&[PathBuf::from("/other-project"), PathBuf::from("/project")]);
3362    cx.update(|_window, cx| {
3363        let store = ThreadMetadataStore::global(cx).read(cx);
3364        assert_eq!(
3365            store.entries_for_main_worktree_path(&old_key_paths).count(),
3366            0,
3367            "should have 0 threads under old key after migration"
3368        );
3369        assert_eq!(
3370            store.entries_for_main_worktree_path(&new_key_paths).count(),
3371            2,
3372            "should have 2 threads under new key after migration"
3373        );
3374    });
3375
3376    // Both threads should still be visible in the sidebar.
3377    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
3378    cx.run_until_parked();
3379
3380    assert_eq!(
3381        visible_entries_as_strings(&sidebar, cx),
3382        vec![
3383            //
3384            "v [other-project, project]",
3385            "  [~ Draft {project:wt-feature}] (active)",
3386            "  Worktree Thread {project:wt-feature}",
3387            "  Main Thread",
3388        ]
3389    );
3390}
3391
3392#[gpui::test]
3393async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
3394    // When the user presses Cmd-N (NewThread action) while viewing a
3395    // non-empty thread, the sidebar should show the "New Thread" entry.
3396    // This exercises the same code path as the workspace action handler
3397    // (which bypasses the sidebar's create_new_thread method).
3398    let project = init_test_project_with_agent_panel("/my-project", cx).await;
3399    let (multi_workspace, cx) =
3400        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3401    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3402
3403    // Create a non-empty thread (has messages).
3404    let connection = StubAgentConnection::new();
3405    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3406        acp::ContentChunk::new("Done".into()),
3407    )]);
3408    open_thread_with_connection(&panel, connection, cx);
3409    send_message(&panel, cx);
3410
3411    let session_id = active_session_id(&panel, cx);
3412    save_test_thread_metadata(&session_id, &project, cx).await;
3413    cx.run_until_parked();
3414
3415    assert_eq!(
3416        visible_entries_as_strings(&sidebar, cx),
3417        vec![
3418            //
3419            "v [my-project]",
3420            "  Hello * (active)",
3421        ]
3422    );
3423
3424    // Simulate cmd-n
3425    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
3426    panel.update_in(cx, |panel, window, cx| {
3427        panel.new_thread(&NewThread, window, cx);
3428    });
3429    workspace.update_in(cx, |workspace, window, cx| {
3430        workspace.focus_panel::<AgentPanel>(window, cx);
3431    });
3432    cx.run_until_parked();
3433
3434    assert_eq!(
3435        visible_entries_as_strings(&sidebar, cx),
3436        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
3437        "After Cmd-N the sidebar should show a highlighted Draft entry"
3438    );
3439
3440    sidebar.read_with(cx, |sidebar, _cx| {
3441        assert_active_draft(
3442            sidebar,
3443            &workspace,
3444            "active_entry should be Draft after Cmd-N",
3445        );
3446    });
3447}
3448
3449#[gpui::test]
3450async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
3451    let project = init_test_project_with_agent_panel("/my-project", cx).await;
3452    let (multi_workspace, cx) =
3453        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3454    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3455
3456    // Create a saved thread so the workspace has history.
3457    let connection = StubAgentConnection::new();
3458    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3459        acp::ContentChunk::new("Done".into()),
3460    )]);
3461    open_thread_with_connection(&panel, connection, cx);
3462    send_message(&panel, cx);
3463    let saved_session_id = active_session_id(&panel, cx);
3464    save_test_thread_metadata(&saved_session_id, &project, cx).await;
3465    cx.run_until_parked();
3466
3467    assert_eq!(
3468        visible_entries_as_strings(&sidebar, cx),
3469        vec![
3470            //
3471            "v [my-project]",
3472            "  Hello * (active)",
3473        ]
3474    );
3475
3476    // Create a new draft via Cmd-N. Since new_thread() now creates a
3477    // tracked draft in the AgentPanel, it appears in the sidebar.
3478    panel.update_in(cx, |panel, window, cx| {
3479        panel.new_thread(&NewThread, window, cx);
3480    });
3481    cx.run_until_parked();
3482
3483    assert_eq!(
3484        visible_entries_as_strings(&sidebar, cx),
3485        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
3486    );
3487
3488    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
3489    sidebar.read_with(cx, |sidebar, _cx| {
3490        assert_active_draft(
3491            sidebar,
3492            &workspace,
3493            "Draft with server session should be Draft, not Thread",
3494        );
3495    });
3496}
3497
3498#[gpui::test]
3499async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
3500    // When the active workspace is an absorbed git worktree, cmd-n
3501    // should still show the "New Thread" entry under the main repo's
3502    // header and highlight it as active.
3503    agent_ui::test_support::init_test(cx);
3504    cx.update(|cx| {
3505        ThreadStore::init_global(cx);
3506        ThreadMetadataStore::init_global(cx);
3507        language_model::LanguageModelRegistry::test(cx);
3508        prompt_store::init(cx);
3509    });
3510
3511    let fs = FakeFs::new(cx.executor());
3512
3513    // Main repo with a linked worktree.
3514    fs.insert_tree(
3515        "/project",
3516        serde_json::json!({
3517            ".git": {},
3518            "src": {},
3519        }),
3520    )
3521    .await;
3522
3523    // Worktree checkout pointing back to the main repo.
3524    fs.add_linked_worktree_for_repo(
3525        Path::new("/project/.git"),
3526        false,
3527        git::repository::Worktree {
3528            path: std::path::PathBuf::from("/wt-feature-a"),
3529            ref_name: Some("refs/heads/feature-a".into()),
3530            sha: "aaa".into(),
3531            is_main: false,
3532        },
3533    )
3534    .await;
3535
3536    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3537
3538    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3539    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3540
3541    main_project
3542        .update(cx, |p, cx| p.git_scans_complete(cx))
3543        .await;
3544    worktree_project
3545        .update(cx, |p, cx| p.git_scans_complete(cx))
3546        .await;
3547
3548    let (multi_workspace, cx) =
3549        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3550
3551    let sidebar = setup_sidebar(&multi_workspace, cx);
3552
3553    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3554        mw.test_add_workspace(worktree_project.clone(), window, cx)
3555    });
3556
3557    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3558
3559    // Switch to the worktree workspace.
3560    multi_workspace.update_in(cx, |mw, window, cx| {
3561        let workspace = mw.workspaces().nth(1).unwrap().clone();
3562        mw.activate(workspace, window, cx);
3563    });
3564
3565    // Create a non-empty thread in the worktree workspace.
3566    let connection = StubAgentConnection::new();
3567    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3568        acp::ContentChunk::new("Done".into()),
3569    )]);
3570    open_thread_with_connection(&worktree_panel, connection, cx);
3571    send_message(&worktree_panel, cx);
3572
3573    let session_id = active_session_id(&worktree_panel, cx);
3574    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3575    cx.run_until_parked();
3576
3577    assert_eq!(
3578        visible_entries_as_strings(&sidebar, cx),
3579        vec![
3580            //
3581            "v [project]",
3582            "  Hello {wt-feature-a} * (active)",
3583        ]
3584    );
3585
3586    // Simulate Cmd-N in the worktree workspace.
3587    worktree_panel.update_in(cx, |panel, window, cx| {
3588        panel.new_thread(&NewThread, window, cx);
3589    });
3590    worktree_workspace.update_in(cx, |workspace, window, cx| {
3591        workspace.focus_panel::<AgentPanel>(window, cx);
3592    });
3593    cx.run_until_parked();
3594
3595    assert_eq!(
3596        visible_entries_as_strings(&sidebar, cx),
3597        vec![
3598            //
3599            "v [project]",
3600            "  [~ Draft {wt-feature-a}] *",
3601            "  Hello {wt-feature-a} *"
3602        ],
3603        "After Cmd-N in an absorbed worktree, the sidebar should show \
3604             a highlighted Draft entry under the main repo header"
3605    );
3606
3607    sidebar.read_with(cx, |sidebar, _cx| {
3608        assert_active_draft(
3609            sidebar,
3610            &worktree_workspace,
3611            "active_entry should be Draft after Cmd-N",
3612        );
3613    });
3614}
3615
3616async fn init_test_project_with_git(
3617    worktree_path: &str,
3618    cx: &mut TestAppContext,
3619) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
3620    init_test(cx);
3621    let fs = FakeFs::new(cx.executor());
3622    fs.insert_tree(
3623        worktree_path,
3624        serde_json::json!({
3625            ".git": {},
3626            "src": {},
3627        }),
3628    )
3629    .await;
3630    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3631    let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
3632    (project, fs)
3633}
3634
3635#[gpui::test]
3636async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
3637    let (project, fs) = init_test_project_with_git("/project", cx).await;
3638
3639    fs.as_fake()
3640        .add_linked_worktree_for_repo(
3641            Path::new("/project/.git"),
3642            false,
3643            git::repository::Worktree {
3644                path: std::path::PathBuf::from("/wt/rosewood"),
3645                ref_name: Some("refs/heads/rosewood".into()),
3646                sha: "abc".into(),
3647                is_main: false,
3648            },
3649        )
3650        .await;
3651
3652    project
3653        .update(cx, |project, cx| project.git_scans_complete(cx))
3654        .await;
3655
3656    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3657    worktree_project
3658        .update(cx, |p, cx| p.git_scans_complete(cx))
3659        .await;
3660
3661    let (multi_workspace, cx) =
3662        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3663    let sidebar = setup_sidebar(&multi_workspace, cx);
3664
3665    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
3666    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
3667
3668    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3669    cx.run_until_parked();
3670
3671    // Search for "rosewood" — should match the worktree name, not the title.
3672    type_in_search(&sidebar, "rosewood", cx);
3673
3674    assert_eq!(
3675        visible_entries_as_strings(&sidebar, cx),
3676        vec![
3677            //
3678            "v [project]",
3679            "  Fix Bug {rosewood}  <== selected",
3680        ],
3681    );
3682}
3683
3684#[gpui::test]
3685async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
3686    let (project, fs) = init_test_project_with_git("/project", cx).await;
3687
3688    project
3689        .update(cx, |project, cx| project.git_scans_complete(cx))
3690        .await;
3691
3692    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
3693    worktree_project
3694        .update(cx, |p, cx| p.git_scans_complete(cx))
3695        .await;
3696
3697    let (multi_workspace, cx) =
3698        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3699    let sidebar = setup_sidebar(&multi_workspace, cx);
3700
3701    // Save a thread against a worktree path with the correct main
3702    // worktree association (as if the git state had been resolved).
3703    save_thread_metadata_with_main_paths(
3704        "wt-thread",
3705        "Worktree Thread",
3706        PathList::new(&[PathBuf::from("/wt/rosewood")]),
3707        PathList::new(&[PathBuf::from("/project")]),
3708        cx,
3709    );
3710
3711    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3712    cx.run_until_parked();
3713
3714    // Thread is visible because its main_worktree_paths match the group.
3715    // The chip name is derived from the path even before git discovery.
3716    assert_eq!(
3717        visible_entries_as_strings(&sidebar, cx),
3718        vec!["v [project]", "  Worktree Thread {rosewood}"]
3719    );
3720
3721    // Now add the worktree to the git state and trigger a rescan.
3722    fs.as_fake()
3723        .add_linked_worktree_for_repo(
3724            Path::new("/project/.git"),
3725            true,
3726            git::repository::Worktree {
3727                path: std::path::PathBuf::from("/wt/rosewood"),
3728                ref_name: Some("refs/heads/rosewood".into()),
3729                sha: "abc".into(),
3730                is_main: false,
3731            },
3732        )
3733        .await;
3734
3735    cx.run_until_parked();
3736
3737    assert_eq!(
3738        visible_entries_as_strings(&sidebar, cx),
3739        vec![
3740            //
3741            "v [project]",
3742            "  Worktree Thread {rosewood}",
3743        ]
3744    );
3745}
3746
3747#[gpui::test]
3748async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
3749    init_test(cx);
3750    let fs = FakeFs::new(cx.executor());
3751
3752    // Create the main repo directory (not opened as a workspace yet).
3753    fs.insert_tree(
3754        "/project",
3755        serde_json::json!({
3756            ".git": {
3757            },
3758            "src": {},
3759        }),
3760    )
3761    .await;
3762
3763    // Two worktree checkouts whose .git files point back to the main repo.
3764    fs.add_linked_worktree_for_repo(
3765        Path::new("/project/.git"),
3766        false,
3767        git::repository::Worktree {
3768            path: std::path::PathBuf::from("/wt-feature-a"),
3769            ref_name: Some("refs/heads/feature-a".into()),
3770            sha: "aaa".into(),
3771            is_main: false,
3772        },
3773    )
3774    .await;
3775    fs.add_linked_worktree_for_repo(
3776        Path::new("/project/.git"),
3777        false,
3778        git::repository::Worktree {
3779            path: std::path::PathBuf::from("/wt-feature-b"),
3780            ref_name: Some("refs/heads/feature-b".into()),
3781            sha: "bbb".into(),
3782            is_main: false,
3783        },
3784    )
3785    .await;
3786
3787    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3788
3789    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3790    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3791
3792    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3793    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3794
3795    // Open both worktrees as workspaces — no main repo yet.
3796    let (multi_workspace, cx) =
3797        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3798    multi_workspace.update_in(cx, |mw, window, cx| {
3799        mw.test_add_workspace(project_b.clone(), window, cx);
3800    });
3801    let sidebar = setup_sidebar(&multi_workspace, cx);
3802
3803    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
3804    save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
3805
3806    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3807    cx.run_until_parked();
3808
3809    // Without the main repo, each worktree has its own header.
3810    assert_eq!(
3811        visible_entries_as_strings(&sidebar, cx),
3812        vec![
3813            //
3814            "v [project]",
3815            "  Thread A {wt-feature-a}",
3816            "  Thread B {wt-feature-b}",
3817        ]
3818    );
3819
3820    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3821    main_project
3822        .update(cx, |p, cx| p.git_scans_complete(cx))
3823        .await;
3824
3825    multi_workspace.update_in(cx, |mw, window, cx| {
3826        mw.test_add_workspace(main_project.clone(), window, cx);
3827    });
3828    cx.run_until_parked();
3829
3830    // Both worktree workspaces should now be absorbed under the main
3831    // repo header, with worktree chips.
3832    assert_eq!(
3833        visible_entries_as_strings(&sidebar, cx),
3834        vec![
3835            //
3836            "v [project]",
3837            "  Thread A {wt-feature-a}",
3838            "  Thread B {wt-feature-b}",
3839        ]
3840    );
3841}
3842
3843#[gpui::test]
3844async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
3845    // When a group has two workspaces — one with threads and one
3846    // without — the threadless workspace should appear as a
3847    // "New Thread" button with its worktree chip.
3848    init_test(cx);
3849    let fs = FakeFs::new(cx.executor());
3850
3851    // Main repo with two linked worktrees.
3852    fs.insert_tree(
3853        "/project",
3854        serde_json::json!({
3855            ".git": {},
3856            "src": {},
3857        }),
3858    )
3859    .await;
3860    fs.add_linked_worktree_for_repo(
3861        Path::new("/project/.git"),
3862        false,
3863        git::repository::Worktree {
3864            path: std::path::PathBuf::from("/wt-feature-a"),
3865            ref_name: Some("refs/heads/feature-a".into()),
3866            sha: "aaa".into(),
3867            is_main: false,
3868        },
3869    )
3870    .await;
3871    fs.add_linked_worktree_for_repo(
3872        Path::new("/project/.git"),
3873        false,
3874        git::repository::Worktree {
3875            path: std::path::PathBuf::from("/wt-feature-b"),
3876            ref_name: Some("refs/heads/feature-b".into()),
3877            sha: "bbb".into(),
3878            is_main: false,
3879        },
3880    )
3881    .await;
3882
3883    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3884
3885    // Workspace A: worktree feature-a (has threads).
3886    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3887    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3888
3889    // Workspace B: worktree feature-b (no threads).
3890    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
3891    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3892
3893    let (multi_workspace, cx) =
3894        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3895    multi_workspace.update_in(cx, |mw, window, cx| {
3896        mw.test_add_workspace(project_b.clone(), window, cx);
3897    });
3898    let sidebar = setup_sidebar(&multi_workspace, cx);
3899
3900    // Only save a thread for workspace A.
3901    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
3902
3903    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3904    cx.run_until_parked();
3905
3906    // Workspace A's thread appears normally. Workspace B (threadless)
3907    // appears as a "New Thread" button with its worktree chip.
3908    assert_eq!(
3909        visible_entries_as_strings(&sidebar, cx),
3910        vec!["v [project]", "  Thread A {wt-feature-a}",]
3911    );
3912}
3913
3914#[gpui::test]
3915async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
3916    // A thread created in a workspace with roots from different git
3917    // worktrees should show a chip for each distinct worktree name.
3918    init_test(cx);
3919    let fs = FakeFs::new(cx.executor());
3920
3921    // Two main repos.
3922    fs.insert_tree(
3923        "/project_a",
3924        serde_json::json!({
3925            ".git": {},
3926            "src": {},
3927        }),
3928    )
3929    .await;
3930    fs.insert_tree(
3931        "/project_b",
3932        serde_json::json!({
3933            ".git": {},
3934            "src": {},
3935        }),
3936    )
3937    .await;
3938
3939    // Worktree checkouts.
3940    for repo in &["project_a", "project_b"] {
3941        let git_path = format!("/{repo}/.git");
3942        for branch in &["olivetti", "selectric"] {
3943            fs.add_linked_worktree_for_repo(
3944                Path::new(&git_path),
3945                false,
3946                git::repository::Worktree {
3947                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
3948                    ref_name: Some(format!("refs/heads/{branch}").into()),
3949                    sha: "aaa".into(),
3950                    is_main: false,
3951                },
3952            )
3953            .await;
3954        }
3955    }
3956
3957    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3958
3959    // Open a workspace with the worktree checkout paths as roots
3960    // (this is the workspace the thread was created in).
3961    let project = project::Project::test(
3962        fs.clone(),
3963        [
3964            "/worktrees/project_a/olivetti/project_a".as_ref(),
3965            "/worktrees/project_b/selectric/project_b".as_ref(),
3966        ],
3967        cx,
3968    )
3969    .await;
3970    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3971
3972    let (multi_workspace, cx) =
3973        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3974    let sidebar = setup_sidebar(&multi_workspace, cx);
3975
3976    // Save a thread under the same paths as the workspace roots.
3977    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
3978
3979    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3980    cx.run_until_parked();
3981
3982    // Should show two distinct worktree chips.
3983    assert_eq!(
3984        visible_entries_as_strings(&sidebar, cx),
3985        vec![
3986            //
3987            "v [project_a, project_b]",
3988            "  Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
3989        ]
3990    );
3991}
3992
3993#[gpui::test]
3994async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
3995    // When a thread's roots span multiple repos but share the same
3996    // worktree name (e.g. both in "olivetti"), only one chip should
3997    // appear.
3998    init_test(cx);
3999    let fs = FakeFs::new(cx.executor());
4000
4001    fs.insert_tree(
4002        "/project_a",
4003        serde_json::json!({
4004            ".git": {},
4005            "src": {},
4006        }),
4007    )
4008    .await;
4009    fs.insert_tree(
4010        "/project_b",
4011        serde_json::json!({
4012            ".git": {},
4013            "src": {},
4014        }),
4015    )
4016    .await;
4017
4018    for repo in &["project_a", "project_b"] {
4019        let git_path = format!("/{repo}/.git");
4020        fs.add_linked_worktree_for_repo(
4021            Path::new(&git_path),
4022            false,
4023            git::repository::Worktree {
4024                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
4025                ref_name: Some("refs/heads/olivetti".into()),
4026                sha: "aaa".into(),
4027                is_main: false,
4028            },
4029        )
4030        .await;
4031    }
4032
4033    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4034
4035    let project = project::Project::test(
4036        fs.clone(),
4037        [
4038            "/worktrees/project_a/olivetti/project_a".as_ref(),
4039            "/worktrees/project_b/olivetti/project_b".as_ref(),
4040        ],
4041        cx,
4042    )
4043    .await;
4044    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4045
4046    let (multi_workspace, cx) =
4047        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4048    let sidebar = setup_sidebar(&multi_workspace, cx);
4049
4050    // Thread with roots in both repos' "olivetti" worktrees.
4051    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
4052
4053    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4054    cx.run_until_parked();
4055
4056    // Both worktree paths have the name "olivetti", so only one chip.
4057    assert_eq!(
4058        visible_entries_as_strings(&sidebar, cx),
4059        vec![
4060            //
4061            "v [project_a, project_b]",
4062            "  Same Branch Thread {olivetti}",
4063        ]
4064    );
4065}
4066
4067#[gpui::test]
4068async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
4069    // When a worktree workspace is absorbed under the main repo, a
4070    // running thread in the worktree's agent panel should still show
4071    // live status (spinner + "(running)") in the sidebar.
4072    agent_ui::test_support::init_test(cx);
4073    cx.update(|cx| {
4074        ThreadStore::init_global(cx);
4075        ThreadMetadataStore::init_global(cx);
4076        language_model::LanguageModelRegistry::test(cx);
4077        prompt_store::init(cx);
4078    });
4079
4080    let fs = FakeFs::new(cx.executor());
4081
4082    // Main repo with a linked worktree.
4083    fs.insert_tree(
4084        "/project",
4085        serde_json::json!({
4086            ".git": {},
4087            "src": {},
4088        }),
4089    )
4090    .await;
4091
4092    // Worktree checkout pointing back to the main repo.
4093    fs.add_linked_worktree_for_repo(
4094        Path::new("/project/.git"),
4095        false,
4096        git::repository::Worktree {
4097            path: std::path::PathBuf::from("/wt-feature-a"),
4098            ref_name: Some("refs/heads/feature-a".into()),
4099            sha: "aaa".into(),
4100            is_main: false,
4101        },
4102    )
4103    .await;
4104
4105    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4106
4107    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4108    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4109
4110    main_project
4111        .update(cx, |p, cx| p.git_scans_complete(cx))
4112        .await;
4113    worktree_project
4114        .update(cx, |p, cx| p.git_scans_complete(cx))
4115        .await;
4116
4117    // Create the MultiWorkspace with both projects.
4118    let (multi_workspace, cx) =
4119        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4120
4121    let sidebar = setup_sidebar(&multi_workspace, cx);
4122
4123    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4124        mw.test_add_workspace(worktree_project.clone(), window, cx)
4125    });
4126
4127    // Add an agent panel to the worktree workspace so we can run a
4128    // thread inside it.
4129    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
4130
4131    // Switch back to the main workspace before setting up the sidebar.
4132    multi_workspace.update_in(cx, |mw, window, cx| {
4133        let workspace = mw.workspaces().next().unwrap().clone();
4134        mw.activate(workspace, window, cx);
4135    });
4136
4137    // Start a thread in the worktree workspace's panel and keep it
4138    // generating (don't resolve it).
4139    let connection = StubAgentConnection::new();
4140    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
4141    send_message(&worktree_panel, cx);
4142
4143    let session_id = active_session_id(&worktree_panel, cx);
4144
4145    // Save metadata so the sidebar knows about this thread.
4146    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
4147
4148    // Keep the thread generating by sending a chunk without ending
4149    // the turn.
4150    cx.update(|_, cx| {
4151        connection.send_update(
4152            session_id.clone(),
4153            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4154            cx,
4155        );
4156    });
4157    cx.run_until_parked();
4158
4159    // The worktree thread should be absorbed under the main project
4160    // and show live running status.
4161    let entries = visible_entries_as_strings(&sidebar, cx);
4162    assert_eq!(
4163        entries,
4164        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
4165    );
4166}
4167
4168#[gpui::test]
4169async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
4170    agent_ui::test_support::init_test(cx);
4171    cx.update(|cx| {
4172        ThreadStore::init_global(cx);
4173        ThreadMetadataStore::init_global(cx);
4174        language_model::LanguageModelRegistry::test(cx);
4175        prompt_store::init(cx);
4176    });
4177
4178    let fs = FakeFs::new(cx.executor());
4179
4180    fs.insert_tree(
4181        "/project",
4182        serde_json::json!({
4183            ".git": {},
4184            "src": {},
4185        }),
4186    )
4187    .await;
4188
4189    fs.add_linked_worktree_for_repo(
4190        Path::new("/project/.git"),
4191        false,
4192        git::repository::Worktree {
4193            path: std::path::PathBuf::from("/wt-feature-a"),
4194            ref_name: Some("refs/heads/feature-a".into()),
4195            sha: "aaa".into(),
4196            is_main: false,
4197        },
4198    )
4199    .await;
4200
4201    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4202
4203    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4204    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4205
4206    main_project
4207        .update(cx, |p, cx| p.git_scans_complete(cx))
4208        .await;
4209    worktree_project
4210        .update(cx, |p, cx| p.git_scans_complete(cx))
4211        .await;
4212
4213    let (multi_workspace, cx) =
4214        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4215
4216    let sidebar = setup_sidebar(&multi_workspace, cx);
4217
4218    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4219        mw.test_add_workspace(worktree_project.clone(), window, cx)
4220    });
4221
4222    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
4223
4224    multi_workspace.update_in(cx, |mw, window, cx| {
4225        let workspace = mw.workspaces().next().unwrap().clone();
4226        mw.activate(workspace, window, cx);
4227    });
4228
4229    let connection = StubAgentConnection::new();
4230    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
4231    send_message(&worktree_panel, cx);
4232
4233    let session_id = active_session_id(&worktree_panel, cx);
4234    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
4235
4236    cx.update(|_, cx| {
4237        connection.send_update(
4238            session_id.clone(),
4239            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4240            cx,
4241        );
4242    });
4243    cx.run_until_parked();
4244
4245    assert_eq!(
4246        visible_entries_as_strings(&sidebar, cx),
4247        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
4248    );
4249
4250    connection.end_turn(session_id, acp::StopReason::EndTurn);
4251    cx.run_until_parked();
4252
4253    assert_eq!(
4254        visible_entries_as_strings(&sidebar, cx),
4255        vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
4256    );
4257}
4258
4259#[gpui::test]
4260async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
4261    init_test(cx);
4262    let fs = FakeFs::new(cx.executor());
4263
4264    fs.insert_tree(
4265        "/project",
4266        serde_json::json!({
4267            ".git": {},
4268            "src": {},
4269        }),
4270    )
4271    .await;
4272
4273    fs.add_linked_worktree_for_repo(
4274        Path::new("/project/.git"),
4275        false,
4276        git::repository::Worktree {
4277            path: std::path::PathBuf::from("/wt-feature-a"),
4278            ref_name: Some("refs/heads/feature-a".into()),
4279            sha: "aaa".into(),
4280            is_main: false,
4281        },
4282    )
4283    .await;
4284
4285    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4286
4287    // Only open the main repo — no workspace for the worktree.
4288    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4289    main_project
4290        .update(cx, |p, cx| p.git_scans_complete(cx))
4291        .await;
4292
4293    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4294    worktree_project
4295        .update(cx, |p, cx| p.git_scans_complete(cx))
4296        .await;
4297
4298    let (multi_workspace, cx) =
4299        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4300    let sidebar = setup_sidebar(&multi_workspace, cx);
4301
4302    // Save a thread for the worktree path (no workspace for it).
4303    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
4304
4305    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4306    cx.run_until_parked();
4307
4308    // Thread should appear under the main repo with a worktree chip.
4309    assert_eq!(
4310        visible_entries_as_strings(&sidebar, cx),
4311        vec![
4312            //
4313            "v [project]",
4314            "  WT Thread {wt-feature-a}",
4315        ],
4316    );
4317
4318    // Only 1 workspace should exist.
4319    assert_eq!(
4320        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4321        1,
4322    );
4323
4324    // Focus the sidebar and select the worktree thread.
4325    focus_sidebar(&sidebar, cx);
4326    sidebar.update_in(cx, |sidebar, _window, _cx| {
4327        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
4328    });
4329
4330    // Confirm to open the worktree thread.
4331    cx.dispatch_action(Confirm);
4332    cx.run_until_parked();
4333
4334    // A new workspace should have been created for the worktree path.
4335    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
4336        assert_eq!(
4337            mw.workspaces().count(),
4338            2,
4339            "confirming a worktree thread without a workspace should open one",
4340        );
4341        mw.workspaces().nth(1).unwrap().clone()
4342    });
4343
4344    let new_path_list =
4345        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
4346    assert_eq!(
4347        new_path_list,
4348        PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
4349        "the new workspace should have been opened for the worktree path",
4350    );
4351}
4352
4353#[gpui::test]
4354async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
4355    cx: &mut TestAppContext,
4356) {
4357    init_test(cx);
4358    let fs = FakeFs::new(cx.executor());
4359
4360    fs.insert_tree(
4361        "/project",
4362        serde_json::json!({
4363            ".git": {},
4364            "src": {},
4365        }),
4366    )
4367    .await;
4368
4369    fs.add_linked_worktree_for_repo(
4370        Path::new("/project/.git"),
4371        false,
4372        git::repository::Worktree {
4373            path: std::path::PathBuf::from("/wt-feature-a"),
4374            ref_name: Some("refs/heads/feature-a".into()),
4375            sha: "aaa".into(),
4376            is_main: false,
4377        },
4378    )
4379    .await;
4380
4381    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4382
4383    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4384    main_project
4385        .update(cx, |p, cx| p.git_scans_complete(cx))
4386        .await;
4387
4388    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4389    worktree_project
4390        .update(cx, |p, cx| p.git_scans_complete(cx))
4391        .await;
4392
4393    let (multi_workspace, cx) =
4394        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4395    let sidebar = setup_sidebar(&multi_workspace, cx);
4396
4397    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
4398
4399    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4400    cx.run_until_parked();
4401
4402    assert_eq!(
4403        visible_entries_as_strings(&sidebar, cx),
4404        vec![
4405            //
4406            "v [project]",
4407            "  WT Thread {wt-feature-a}",
4408        ],
4409    );
4410
4411    focus_sidebar(&sidebar, cx);
4412    sidebar.update_in(cx, |sidebar, _window, _cx| {
4413        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
4414    });
4415
4416    let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
4417        let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
4418            if let ListEntry::ProjectHeader { label, .. } = entry {
4419                Some(label.as_ref())
4420            } else {
4421                None
4422            }
4423        });
4424
4425        let Some(project_header) = project_headers.next() else {
4426            panic!("expected exactly one sidebar project header named `project`, found none");
4427        };
4428        assert_eq!(
4429            project_header, "project",
4430            "expected the only sidebar project header to be `project`"
4431        );
4432        if let Some(unexpected_header) = project_headers.next() {
4433            panic!(
4434                "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
4435            );
4436        }
4437
4438        let mut saw_expected_thread = false;
4439        for entry in &sidebar.contents.entries {
4440            match entry {
4441                ListEntry::ProjectHeader { label, .. } => {
4442                    assert_eq!(
4443                        label.as_ref(),
4444                        "project",
4445                        "expected the only sidebar project header to be `project`"
4446                    );
4447                }
4448                ListEntry::Thread(thread)
4449                    if thread.metadata.title.as_ref() == "WT Thread"
4450                        && thread.worktrees.first().map(|wt| wt.name.as_ref())
4451                            == Some("wt-feature-a") =>
4452                {
4453                    saw_expected_thread = true;
4454                }
4455                ListEntry::Thread(thread) => {
4456                    let title = thread.metadata.title.as_ref();
4457                    let worktree_name = thread
4458                        .worktrees
4459                        .first()
4460                        .map(|wt| wt.name.as_ref())
4461                        .unwrap_or("<none>");
4462                    panic!(
4463                        "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`"
4464                    );
4465                }
4466                ListEntry::ViewMore { .. } => {
4467                    panic!("unexpected `View More` entry while opening linked worktree thread");
4468                }
4469                ListEntry::DraftThread { .. } => {}
4470            }
4471        }
4472
4473        assert!(
4474            saw_expected_thread,
4475            "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
4476        );
4477    };
4478
4479    sidebar
4480        .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
4481        .detach();
4482
4483    let window = cx.windows()[0];
4484    cx.update_window(window, |_, window, cx| {
4485        window.dispatch_action(Confirm.boxed_clone(), cx);
4486    })
4487    .unwrap();
4488
4489    cx.run_until_parked();
4490
4491    sidebar.update(cx, assert_sidebar_state);
4492}
4493
4494#[gpui::test]
4495async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
4496    cx: &mut TestAppContext,
4497) {
4498    init_test(cx);
4499    let fs = FakeFs::new(cx.executor());
4500
4501    fs.insert_tree(
4502        "/project",
4503        serde_json::json!({
4504            ".git": {},
4505            "src": {},
4506        }),
4507    )
4508    .await;
4509
4510    fs.add_linked_worktree_for_repo(
4511        Path::new("/project/.git"),
4512        false,
4513        git::repository::Worktree {
4514            path: std::path::PathBuf::from("/wt-feature-a"),
4515            ref_name: Some("refs/heads/feature-a".into()),
4516            sha: "aaa".into(),
4517            is_main: false,
4518        },
4519    )
4520    .await;
4521
4522    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4523
4524    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4525    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4526
4527    main_project
4528        .update(cx, |p, cx| p.git_scans_complete(cx))
4529        .await;
4530    worktree_project
4531        .update(cx, |p, cx| p.git_scans_complete(cx))
4532        .await;
4533
4534    let (multi_workspace, cx) =
4535        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4536
4537    let sidebar = setup_sidebar(&multi_workspace, cx);
4538
4539    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4540        mw.test_add_workspace(worktree_project.clone(), window, cx)
4541    });
4542
4543    // Activate the main workspace before setting up the sidebar.
4544    let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4545        let workspace = mw.workspaces().next().unwrap().clone();
4546        mw.activate(workspace.clone(), window, cx);
4547        workspace
4548    });
4549
4550    save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
4551    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
4552
4553    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4554    cx.run_until_parked();
4555
4556    // The worktree workspace should be absorbed under the main repo.
4557    let entries = visible_entries_as_strings(&sidebar, cx);
4558    assert_eq!(entries.len(), 3);
4559    assert_eq!(entries[0], "v [project]");
4560    assert!(entries.contains(&"  Main Thread".to_string()));
4561    assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
4562
4563    let wt_thread_index = entries
4564        .iter()
4565        .position(|e| e.contains("WT Thread"))
4566        .expect("should find the worktree thread entry");
4567
4568    assert_eq!(
4569        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4570        main_workspace,
4571        "main workspace should be active initially"
4572    );
4573
4574    // Focus the sidebar and select the absorbed worktree thread.
4575    focus_sidebar(&sidebar, cx);
4576    sidebar.update_in(cx, |sidebar, _window, _cx| {
4577        sidebar.selection = Some(wt_thread_index);
4578    });
4579
4580    // Confirm to activate the worktree thread.
4581    cx.dispatch_action(Confirm);
4582    cx.run_until_parked();
4583
4584    // The worktree workspace should now be active, not the main one.
4585    let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4586    assert_eq!(
4587        active_workspace, worktree_workspace,
4588        "clicking an absorbed worktree thread should activate the worktree workspace"
4589    );
4590}
4591
4592#[gpui::test]
4593async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
4594    cx: &mut TestAppContext,
4595) {
4596    // Thread has saved metadata in ThreadStore. A matching workspace is
4597    // already open. Expected: activates the matching workspace.
4598    init_test(cx);
4599    let fs = FakeFs::new(cx.executor());
4600    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4601        .await;
4602    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4603        .await;
4604    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4605
4606    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4607    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4608
4609    let (multi_workspace, cx) =
4610        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4611
4612    let sidebar = setup_sidebar(&multi_workspace, cx);
4613
4614    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4615        mw.test_add_workspace(project_b.clone(), window, cx)
4616    });
4617    let workspace_a =
4618        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4619
4620    // Save a thread with path_list pointing to project-b.
4621    let session_id = acp::SessionId::new(Arc::from("archived-1"));
4622    save_test_thread_metadata(&session_id, &project_b, cx).await;
4623
4624    // Ensure workspace A is active.
4625    multi_workspace.update_in(cx, |mw, window, cx| {
4626        let workspace = mw.workspaces().next().unwrap().clone();
4627        mw.activate(workspace, window, cx);
4628    });
4629    cx.run_until_parked();
4630    assert_eq!(
4631        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4632        workspace_a
4633    );
4634
4635    // Call activate_archived_thread – should resolve saved paths and
4636    // switch to the workspace for project-b.
4637    sidebar.update_in(cx, |sidebar, window, cx| {
4638        sidebar.activate_archived_thread(
4639            ThreadMetadata {
4640                session_id: session_id.clone(),
4641                agent_id: agent::ZED_AGENT_ID.clone(),
4642                title: "Archived Thread".into(),
4643                updated_at: Utc::now(),
4644                created_at: None,
4645                worktree_paths: ThreadWorktreePaths::from_folder_paths(&PathList::new(&[
4646                    PathBuf::from("/project-b"),
4647                ])),
4648                archived: false,
4649                remote_connection: None,
4650            },
4651            window,
4652            cx,
4653        );
4654    });
4655    cx.run_until_parked();
4656
4657    assert_eq!(
4658        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4659        workspace_b,
4660        "should have switched to the workspace matching the saved paths"
4661    );
4662}
4663
4664#[gpui::test]
4665async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
4666    cx: &mut TestAppContext,
4667) {
4668    // Thread has no saved metadata but session_info has cwd. A matching
4669    // workspace is open. Expected: uses cwd to find and activate it.
4670    init_test(cx);
4671    let fs = FakeFs::new(cx.executor());
4672    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4673        .await;
4674    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4675        .await;
4676    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4677
4678    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4679    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4680
4681    let (multi_workspace, cx) =
4682        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4683
4684    let sidebar = setup_sidebar(&multi_workspace, cx);
4685
4686    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4687        mw.test_add_workspace(project_b, window, cx)
4688    });
4689    let workspace_a =
4690        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4691
4692    // Start with workspace A active.
4693    multi_workspace.update_in(cx, |mw, window, cx| {
4694        let workspace = mw.workspaces().next().unwrap().clone();
4695        mw.activate(workspace, window, cx);
4696    });
4697    cx.run_until_parked();
4698    assert_eq!(
4699        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4700        workspace_a
4701    );
4702
4703    // No thread saved to the store – cwd is the only path hint.
4704    sidebar.update_in(cx, |sidebar, window, cx| {
4705        sidebar.activate_archived_thread(
4706            ThreadMetadata {
4707                session_id: acp::SessionId::new(Arc::from("unknown-session")),
4708                agent_id: agent::ZED_AGENT_ID.clone(),
4709                title: "CWD Thread".into(),
4710                updated_at: Utc::now(),
4711                created_at: None,
4712                worktree_paths: ThreadWorktreePaths::from_folder_paths(&PathList::new(&[
4713                    std::path::PathBuf::from("/project-b"),
4714                ])),
4715                archived: false,
4716                remote_connection: None,
4717            },
4718            window,
4719            cx,
4720        );
4721    });
4722    cx.run_until_parked();
4723
4724    assert_eq!(
4725        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4726        workspace_b,
4727        "should have activated the workspace matching the cwd"
4728    );
4729}
4730
4731#[gpui::test]
4732async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4733    cx: &mut TestAppContext,
4734) {
4735    // Thread has no saved metadata and no cwd. Expected: falls back to
4736    // the currently active workspace.
4737    init_test(cx);
4738    let fs = FakeFs::new(cx.executor());
4739    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4740        .await;
4741    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4742        .await;
4743    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4744
4745    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4746    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4747
4748    let (multi_workspace, cx) =
4749        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4750
4751    let sidebar = setup_sidebar(&multi_workspace, cx);
4752
4753    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4754        mw.test_add_workspace(project_b, window, cx)
4755    });
4756
4757    // Activate workspace B (index 1) to make it the active one.
4758    multi_workspace.update_in(cx, |mw, window, cx| {
4759        let workspace = mw.workspaces().nth(1).unwrap().clone();
4760        mw.activate(workspace, window, cx);
4761    });
4762    cx.run_until_parked();
4763    assert_eq!(
4764        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4765        workspace_b
4766    );
4767
4768    // No saved thread, no cwd – should fall back to the active workspace.
4769    sidebar.update_in(cx, |sidebar, window, cx| {
4770        sidebar.activate_archived_thread(
4771            ThreadMetadata {
4772                session_id: acp::SessionId::new(Arc::from("no-context-session")),
4773                agent_id: agent::ZED_AGENT_ID.clone(),
4774                title: "Contextless Thread".into(),
4775                updated_at: Utc::now(),
4776                created_at: None,
4777                worktree_paths: ThreadWorktreePaths::default(),
4778                archived: false,
4779                remote_connection: None,
4780            },
4781            window,
4782            cx,
4783        );
4784    });
4785    cx.run_until_parked();
4786
4787    assert_eq!(
4788        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
4789        workspace_b,
4790        "should have stayed on the active workspace when no path info is available"
4791    );
4792}
4793
4794#[gpui::test]
4795async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
4796    // Thread has saved metadata pointing to a path with no open workspace.
4797    // Expected: opens a new workspace for that path.
4798    init_test(cx);
4799    let fs = FakeFs::new(cx.executor());
4800    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4801        .await;
4802    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4803        .await;
4804    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4805
4806    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4807
4808    let (multi_workspace, cx) =
4809        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4810
4811    let sidebar = setup_sidebar(&multi_workspace, cx);
4812
4813    // Save a thread with path_list pointing to project-b – which has no
4814    // open workspace.
4815    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4816    let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4817
4818    assert_eq!(
4819        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4820        1,
4821        "should start with one workspace"
4822    );
4823
4824    sidebar.update_in(cx, |sidebar, window, cx| {
4825        sidebar.activate_archived_thread(
4826            ThreadMetadata {
4827                session_id: session_id.clone(),
4828                agent_id: agent::ZED_AGENT_ID.clone(),
4829                title: "New WS Thread".into(),
4830                updated_at: Utc::now(),
4831                created_at: None,
4832                worktree_paths: ThreadWorktreePaths::from_folder_paths(&path_list_b),
4833                archived: false,
4834                remote_connection: None,
4835            },
4836            window,
4837            cx,
4838        );
4839    });
4840    cx.run_until_parked();
4841
4842    assert_eq!(
4843        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4844        2,
4845        "should have opened a second workspace for the archived thread's saved paths"
4846    );
4847}
4848
4849#[gpui::test]
4850async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
4851    init_test(cx);
4852    let fs = FakeFs::new(cx.executor());
4853    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4854        .await;
4855    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4856        .await;
4857    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4858
4859    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4860    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4861
4862    let multi_workspace_a =
4863        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4864    let multi_workspace_b =
4865        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
4866
4867    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4868    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4869
4870    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4871    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4872
4873    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4874    let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
4875
4876    let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
4877
4878    sidebar.update_in(cx_a, |sidebar, window, cx| {
4879        sidebar.activate_archived_thread(
4880            ThreadMetadata {
4881                session_id: session_id.clone(),
4882                agent_id: agent::ZED_AGENT_ID.clone(),
4883                title: "Cross Window Thread".into(),
4884                updated_at: Utc::now(),
4885                created_at: None,
4886                worktree_paths: ThreadWorktreePaths::from_folder_paths(&PathList::new(&[
4887                    PathBuf::from("/project-b"),
4888                ])),
4889                archived: false,
4890                remote_connection: None,
4891            },
4892            window,
4893            cx,
4894        );
4895    });
4896    cx_a.run_until_parked();
4897
4898    assert_eq!(
4899        multi_workspace_a
4900            .read_with(cx_a, |mw, _| mw.workspaces().count())
4901            .unwrap(),
4902        1,
4903        "should not add the other window's workspace into the current window"
4904    );
4905    assert_eq!(
4906        multi_workspace_b
4907            .read_with(cx_a, |mw, _| mw.workspaces().count())
4908            .unwrap(),
4909        1,
4910        "should reuse the existing workspace in the other window"
4911    );
4912    assert!(
4913        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4914        "should activate the window that already owns the matching workspace"
4915    );
4916    sidebar.read_with(cx_a, |sidebar, _| {
4917            assert!(
4918                !matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id: id, .. }) if id == &session_id),
4919                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4920            );
4921        });
4922}
4923
4924#[gpui::test]
4925async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
4926    cx: &mut TestAppContext,
4927) {
4928    init_test(cx);
4929    let fs = FakeFs::new(cx.executor());
4930    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4931        .await;
4932    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4933        .await;
4934    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4935
4936    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4937    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4938
4939    let multi_workspace_a =
4940        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4941    let multi_workspace_b =
4942        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
4943
4944    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
4945    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
4946
4947    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4948    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4949
4950    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
4951    let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
4952    let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
4953    let _panel_b = add_agent_panel(&workspace_b, cx_b);
4954
4955    let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
4956
4957    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4958        sidebar.activate_archived_thread(
4959            ThreadMetadata {
4960                session_id: session_id.clone(),
4961                agent_id: agent::ZED_AGENT_ID.clone(),
4962                title: "Cross Window Thread".into(),
4963                updated_at: Utc::now(),
4964                created_at: None,
4965                worktree_paths: ThreadWorktreePaths::from_folder_paths(&PathList::new(&[
4966                    PathBuf::from("/project-b"),
4967                ])),
4968                archived: false,
4969                remote_connection: None,
4970            },
4971            window,
4972            cx,
4973        );
4974    });
4975    cx_a.run_until_parked();
4976
4977    assert_eq!(
4978        multi_workspace_a
4979            .read_with(cx_a, |mw, _| mw.workspaces().count())
4980            .unwrap(),
4981        1,
4982        "should not add the other window's workspace into the current window"
4983    );
4984    assert_eq!(
4985        multi_workspace_b
4986            .read_with(cx_a, |mw, _| mw.workspaces().count())
4987            .unwrap(),
4988        1,
4989        "should reuse the existing workspace in the other window"
4990    );
4991    assert!(
4992        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
4993        "should activate the window that already owns the matching workspace"
4994    );
4995    sidebar_a.read_with(cx_a, |sidebar, _| {
4996            assert!(
4997                !matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id: id, .. }) if id == &session_id),
4998                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
4999            );
5000        });
5001    sidebar_b.read_with(cx_b, |sidebar, _| {
5002        assert_active_thread(
5003            sidebar,
5004            &session_id,
5005            "target window's sidebar should eagerly focus the activated archived thread",
5006        );
5007    });
5008}
5009
5010#[gpui::test]
5011async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
5012    cx: &mut TestAppContext,
5013) {
5014    init_test(cx);
5015    let fs = FakeFs::new(cx.executor());
5016    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
5017        .await;
5018    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5019
5020    let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
5021    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
5022
5023    let multi_workspace_b =
5024        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
5025    let multi_workspace_a =
5026        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
5027
5028    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
5029    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
5030
5031    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
5032    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
5033
5034    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
5035    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
5036
5037    let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
5038
5039    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
5040        sidebar.activate_archived_thread(
5041            ThreadMetadata {
5042                session_id: session_id.clone(),
5043                agent_id: agent::ZED_AGENT_ID.clone(),
5044                title: "Current Window Thread".into(),
5045                updated_at: Utc::now(),
5046                created_at: None,
5047                worktree_paths: ThreadWorktreePaths::from_folder_paths(&PathList::new(&[
5048                    PathBuf::from("/project-a"),
5049                ])),
5050                archived: false,
5051                remote_connection: None,
5052            },
5053            window,
5054            cx,
5055        );
5056    });
5057    cx_a.run_until_parked();
5058
5059    assert!(
5060        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
5061        "should keep activation in the current window when it already has a matching workspace"
5062    );
5063    sidebar_a.read_with(cx_a, |sidebar, _| {
5064        assert_active_thread(
5065            sidebar,
5066            &session_id,
5067            "current window's sidebar should eagerly focus the activated archived thread",
5068        );
5069    });
5070    assert_eq!(
5071        multi_workspace_a
5072            .read_with(cx_a, |mw, _| mw.workspaces().count())
5073            .unwrap(),
5074        1,
5075        "current window should continue reusing its existing workspace"
5076    );
5077    assert_eq!(
5078        multi_workspace_b
5079            .read_with(cx_a, |mw, _| mw.workspaces().count())
5080            .unwrap(),
5081        1,
5082        "other windows should not be activated just because they also match the saved paths"
5083    );
5084}
5085
5086#[gpui::test]
5087async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
5088    // Regression test: archive_thread previously always loaded the next thread
5089    // through group_workspace (the main workspace's ProjectHeader), even when
5090    // the next thread belonged to an absorbed linked-worktree workspace. That
5091    // caused the worktree thread to be loaded in the main panel, which bound it
5092    // to the main project and corrupted its stored folder_paths.
5093    //
5094    // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
5095    // falling back to group_workspace only for Closed workspaces.
5096    agent_ui::test_support::init_test(cx);
5097    cx.update(|cx| {
5098        ThreadStore::init_global(cx);
5099        ThreadMetadataStore::init_global(cx);
5100        language_model::LanguageModelRegistry::test(cx);
5101        prompt_store::init(cx);
5102    });
5103
5104    let fs = FakeFs::new(cx.executor());
5105
5106    fs.insert_tree(
5107        "/project",
5108        serde_json::json!({
5109            ".git": {},
5110            "src": {},
5111        }),
5112    )
5113    .await;
5114
5115    fs.add_linked_worktree_for_repo(
5116        Path::new("/project/.git"),
5117        false,
5118        git::repository::Worktree {
5119            path: std::path::PathBuf::from("/wt-feature-a"),
5120            ref_name: Some("refs/heads/feature-a".into()),
5121            sha: "aaa".into(),
5122            is_main: false,
5123        },
5124    )
5125    .await;
5126
5127    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5128
5129    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5130    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5131
5132    main_project
5133        .update(cx, |p, cx| p.git_scans_complete(cx))
5134        .await;
5135    worktree_project
5136        .update(cx, |p, cx| p.git_scans_complete(cx))
5137        .await;
5138
5139    let (multi_workspace, cx) =
5140        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5141
5142    let sidebar = setup_sidebar(&multi_workspace, cx);
5143
5144    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5145        mw.test_add_workspace(worktree_project.clone(), window, cx)
5146    });
5147
5148    // Activate main workspace so the sidebar tracks the main panel.
5149    multi_workspace.update_in(cx, |mw, window, cx| {
5150        let workspace = mw.workspaces().next().unwrap().clone();
5151        mw.activate(workspace, window, cx);
5152    });
5153
5154    let main_workspace =
5155        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5156    let main_panel = add_agent_panel(&main_workspace, cx);
5157    let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
5158
5159    // Open Thread 2 in the main panel and keep it running.
5160    let connection = StubAgentConnection::new();
5161    open_thread_with_connection(&main_panel, connection.clone(), cx);
5162    send_message(&main_panel, cx);
5163
5164    let thread2_session_id = active_session_id(&main_panel, cx);
5165
5166    cx.update(|_, cx| {
5167        connection.send_update(
5168            thread2_session_id.clone(),
5169            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
5170            cx,
5171        );
5172    });
5173
5174    // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
5175    save_thread_metadata(
5176        thread2_session_id.clone(),
5177        "Thread 2".into(),
5178        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5179        None,
5180        &main_project,
5181        cx,
5182    );
5183
5184    // Save thread 1's metadata with the worktree path and an older timestamp so
5185    // it sorts below thread 2. archive_thread will find it as the "next" candidate.
5186    let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
5187    save_thread_metadata(
5188        thread1_session_id,
5189        "Thread 1".into(),
5190        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5191        None,
5192        &worktree_project,
5193        cx,
5194    );
5195
5196    cx.run_until_parked();
5197
5198    // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
5199    let entries_before = visible_entries_as_strings(&sidebar, cx);
5200    assert!(
5201        entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
5202        "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
5203        entries_before
5204    );
5205
5206    // The sidebar should track T2 as the focused thread (derived from the
5207    // main panel's active view).
5208    sidebar.read_with(cx, |s, _| {
5209        assert_active_thread(
5210            s,
5211            &thread2_session_id,
5212            "focused thread should be Thread 2 before archiving",
5213        );
5214    });
5215
5216    // Archive thread 2.
5217    sidebar.update_in(cx, |sidebar, window, cx| {
5218        sidebar.archive_thread(&thread2_session_id, window, cx);
5219    });
5220
5221    cx.run_until_parked();
5222
5223    // The main panel's active thread must still be thread 2.
5224    let main_active = main_panel.read_with(cx, |panel, cx| {
5225        panel
5226            .active_agent_thread(cx)
5227            .map(|t| t.read(cx).session_id().clone())
5228    });
5229    assert_eq!(
5230        main_active,
5231        Some(thread2_session_id.clone()),
5232        "main panel should not have been taken over by loading the linked-worktree thread T1; \
5233             before the fix, archive_thread used group_workspace instead of next.workspace, \
5234             causing T1 to be loaded in the wrong panel"
5235    );
5236
5237    // Thread 1 should still appear in the sidebar with its worktree chip
5238    // (Thread 2 was archived so it is gone from the list).
5239    let entries_after = visible_entries_as_strings(&sidebar, cx);
5240    assert!(
5241        entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
5242        "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
5243        entries_after
5244    );
5245}
5246
5247#[gpui::test]
5248async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
5249    // When the last non-archived thread for a linked worktree is archived,
5250    // the linked worktree workspace should be removed from the multi-workspace.
5251    // The main worktree workspace should remain (it's always reachable via
5252    // the project header).
5253    init_test(cx);
5254    let fs = FakeFs::new(cx.executor());
5255
5256    fs.insert_tree(
5257        "/project",
5258        serde_json::json!({
5259            ".git": {
5260                "worktrees": {
5261                    "feature-a": {
5262                        "commondir": "../../",
5263                        "HEAD": "ref: refs/heads/feature-a",
5264                    },
5265                },
5266            },
5267            "src": {},
5268        }),
5269    )
5270    .await;
5271
5272    fs.insert_tree(
5273        "/wt-feature-a",
5274        serde_json::json!({
5275            ".git": "gitdir: /project/.git/worktrees/feature-a",
5276            "src": {},
5277        }),
5278    )
5279    .await;
5280
5281    fs.add_linked_worktree_for_repo(
5282        Path::new("/project/.git"),
5283        false,
5284        git::repository::Worktree {
5285            path: PathBuf::from("/wt-feature-a"),
5286            ref_name: Some("refs/heads/feature-a".into()),
5287            sha: "abc".into(),
5288            is_main: false,
5289        },
5290    )
5291    .await;
5292
5293    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5294
5295    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5296    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5297
5298    main_project
5299        .update(cx, |p, cx| p.git_scans_complete(cx))
5300        .await;
5301    worktree_project
5302        .update(cx, |p, cx| p.git_scans_complete(cx))
5303        .await;
5304
5305    let (multi_workspace, cx) =
5306        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5307    let sidebar = setup_sidebar(&multi_workspace, cx);
5308
5309    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5310        mw.test_add_workspace(worktree_project.clone(), window, cx)
5311    });
5312
5313    // Save a thread for the main project.
5314    save_thread_metadata(
5315        acp::SessionId::new(Arc::from("main-thread")),
5316        "Main Thread".into(),
5317        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5318        None,
5319        &main_project,
5320        cx,
5321    );
5322
5323    // Save a thread for the linked worktree.
5324    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
5325    save_thread_metadata(
5326        wt_thread_id.clone(),
5327        "Worktree Thread".into(),
5328        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5329        None,
5330        &worktree_project,
5331        cx,
5332    );
5333    cx.run_until_parked();
5334
5335    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5336    cx.run_until_parked();
5337
5338    // Should have 2 workspaces.
5339    assert_eq!(
5340        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5341        2,
5342        "should start with 2 workspaces (main + linked worktree)"
5343    );
5344
5345    // Archive the worktree thread (the only thread for /wt-feature-a).
5346    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
5347        sidebar.archive_thread(&wt_thread_id, window, cx);
5348    });
5349
5350    // archive_thread spawns a multi-layered chain of tasks (workspace
5351    // removal → git persist → disk removal), each of which may spawn
5352    // further background work. Each run_until_parked() call drives one
5353    // layer of pending work.
5354    cx.run_until_parked();
5355    cx.run_until_parked();
5356    cx.run_until_parked();
5357
5358    // The linked worktree workspace should have been removed.
5359    assert_eq!(
5360        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5361        1,
5362        "linked worktree workspace should be removed after archiving its last thread"
5363    );
5364
5365    // The linked worktree checkout directory should also be removed from disk.
5366    assert!(
5367        !fs.is_dir(Path::new("/wt-feature-a")).await,
5368        "linked worktree directory should be removed from disk after archiving its last thread"
5369    );
5370
5371    // The main thread should still be visible.
5372    let entries = visible_entries_as_strings(&sidebar, cx);
5373    assert!(
5374        entries.iter().any(|e| e.contains("Main Thread")),
5375        "main thread should still be visible: {entries:?}"
5376    );
5377    assert!(
5378        !entries.iter().any(|e| e.contains("Worktree Thread")),
5379        "archived worktree thread should not be visible: {entries:?}"
5380    );
5381}
5382
5383#[gpui::test]
5384async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
5385    // When a multi-root workspace (e.g. [/other, /project]) shares a
5386    // repo with a single-root workspace (e.g. [/project]), linked
5387    // worktree threads from the shared repo should only appear under
5388    // the dedicated group [project], not under [other, project].
5389    init_test(cx);
5390    let fs = FakeFs::new(cx.executor());
5391
5392    // Two independent repos, each with their own git history.
5393    fs.insert_tree(
5394        "/project",
5395        serde_json::json!({
5396            ".git": {},
5397            "src": {},
5398        }),
5399    )
5400    .await;
5401    fs.insert_tree(
5402        "/other",
5403        serde_json::json!({
5404            ".git": {},
5405            "src": {},
5406        }),
5407    )
5408    .await;
5409
5410    // Register the linked worktree in the main repo.
5411    fs.add_linked_worktree_for_repo(
5412        Path::new("/project/.git"),
5413        false,
5414        git::repository::Worktree {
5415            path: std::path::PathBuf::from("/wt-feature-a"),
5416            ref_name: Some("refs/heads/feature-a".into()),
5417            sha: "aaa".into(),
5418            is_main: false,
5419        },
5420    )
5421    .await;
5422
5423    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5424
5425    // Workspace 1: just /project.
5426    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5427    project_only
5428        .update(cx, |p, cx| p.git_scans_complete(cx))
5429        .await;
5430
5431    // Workspace 2: /other and /project together (multi-root).
5432    let multi_root =
5433        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
5434    multi_root
5435        .update(cx, |p, cx| p.git_scans_complete(cx))
5436        .await;
5437
5438    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5439    worktree_project
5440        .update(cx, |p, cx| p.git_scans_complete(cx))
5441        .await;
5442
5443    let (multi_workspace, cx) =
5444        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
5445    let sidebar = setup_sidebar(&multi_workspace, cx);
5446    multi_workspace.update_in(cx, |mw, window, cx| {
5447        mw.test_add_workspace(multi_root.clone(), window, cx);
5448    });
5449
5450    // Save a thread under the linked worktree path.
5451    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
5452
5453    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5454    cx.run_until_parked();
5455
5456    // The thread should appear only under [project] (the dedicated
5457    // group for the /project repo), not under [other, project].
5458    assert_eq!(
5459        visible_entries_as_strings(&sidebar, cx),
5460        vec![
5461            //
5462            "v [other, project]",
5463            "  [~ Draft]",
5464            "v [project]",
5465            "  Worktree Thread {wt-feature-a}",
5466        ]
5467    );
5468}
5469
5470#[gpui::test]
5471async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
5472    let project = init_test_project_with_agent_panel("/my-project", cx).await;
5473    let (multi_workspace, cx) =
5474        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5475    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5476
5477    let switcher_ids =
5478        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
5479            sidebar.read_with(cx, |sidebar, cx| {
5480                let switcher = sidebar
5481                    .thread_switcher
5482                    .as_ref()
5483                    .expect("switcher should be open");
5484                switcher
5485                    .read(cx)
5486                    .entries()
5487                    .iter()
5488                    .map(|e| e.session_id.clone())
5489                    .collect()
5490            })
5491        };
5492
5493    let switcher_selected_id =
5494        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
5495            sidebar.read_with(cx, |sidebar, cx| {
5496                let switcher = sidebar
5497                    .thread_switcher
5498                    .as_ref()
5499                    .expect("switcher should be open");
5500                let s = switcher.read(cx);
5501                s.selected_entry()
5502                    .expect("should have selection")
5503                    .session_id
5504                    .clone()
5505            })
5506        };
5507
5508    // ── Setup: create three threads with distinct created_at times ──────
5509    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
5510    // We send messages in each so they also get last_message_sent_or_queued timestamps.
5511    let connection_c = StubAgentConnection::new();
5512    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5513        acp::ContentChunk::new("Done C".into()),
5514    )]);
5515    open_thread_with_connection(&panel, connection_c, cx);
5516    send_message(&panel, cx);
5517    let session_id_c = active_session_id(&panel, cx);
5518    save_thread_metadata(
5519        session_id_c.clone(),
5520        "Thread C".into(),
5521        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5522        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
5523        &project,
5524        cx,
5525    );
5526
5527    let connection_b = StubAgentConnection::new();
5528    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5529        acp::ContentChunk::new("Done B".into()),
5530    )]);
5531    open_thread_with_connection(&panel, connection_b, cx);
5532    send_message(&panel, cx);
5533    let session_id_b = active_session_id(&panel, cx);
5534    save_thread_metadata(
5535        session_id_b.clone(),
5536        "Thread B".into(),
5537        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5538        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
5539        &project,
5540        cx,
5541    );
5542
5543    let connection_a = StubAgentConnection::new();
5544    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5545        acp::ContentChunk::new("Done A".into()),
5546    )]);
5547    open_thread_with_connection(&panel, connection_a, cx);
5548    send_message(&panel, cx);
5549    let session_id_a = active_session_id(&panel, cx);
5550    save_thread_metadata(
5551        session_id_a.clone(),
5552        "Thread A".into(),
5553        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
5554        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
5555        &project,
5556        cx,
5557    );
5558
5559    // All three threads are now live. Thread A was opened last, so it's
5560    // the one being viewed. Opening each thread called record_thread_access,
5561    // so all three have last_accessed_at set.
5562    // Access order is: A (most recent), B, C (oldest).
5563
5564    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
5565    focus_sidebar(&sidebar, cx);
5566    sidebar.update_in(cx, |sidebar, window, cx| {
5567        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5568    });
5569    cx.run_until_parked();
5570
5571    // All three have last_accessed_at, so they sort by access time.
5572    // A was accessed most recently (it's the currently viewed thread),
5573    // then B, then C.
5574    assert_eq!(
5575        switcher_ids(&sidebar, cx),
5576        vec![
5577            session_id_a.clone(),
5578            session_id_b.clone(),
5579            session_id_c.clone()
5580        ],
5581    );
5582    // First ctrl-tab selects the second entry (B).
5583    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
5584
5585    // Dismiss the switcher without confirming.
5586    sidebar.update_in(cx, |sidebar, _window, cx| {
5587        sidebar.dismiss_thread_switcher(cx);
5588    });
5589    cx.run_until_parked();
5590
5591    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
5592    sidebar.update_in(cx, |sidebar, window, cx| {
5593        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5594    });
5595    cx.run_until_parked();
5596
5597    // Cycle twice to land on Thread C (index 2).
5598    sidebar.read_with(cx, |sidebar, cx| {
5599        let switcher = sidebar.thread_switcher.as_ref().unwrap();
5600        assert_eq!(switcher.read(cx).selected_index(), 1);
5601    });
5602    sidebar.update_in(cx, |sidebar, _window, cx| {
5603        sidebar
5604            .thread_switcher
5605            .as_ref()
5606            .unwrap()
5607            .update(cx, |s, cx| s.cycle_selection(cx));
5608    });
5609    cx.run_until_parked();
5610    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
5611
5612    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
5613
5614    // Confirm on Thread C.
5615    sidebar.update_in(cx, |sidebar, window, cx| {
5616        let switcher = sidebar.thread_switcher.as_ref().unwrap();
5617        let focus = switcher.focus_handle(cx);
5618        focus.dispatch_action(&menu::Confirm, window, cx);
5619    });
5620    cx.run_until_parked();
5621
5622    // Switcher should be dismissed after confirm.
5623    sidebar.read_with(cx, |sidebar, _cx| {
5624        assert!(
5625            sidebar.thread_switcher.is_none(),
5626            "switcher should be dismissed"
5627        );
5628    });
5629
5630    sidebar.update(cx, |sidebar, _cx| {
5631        let last_accessed = sidebar
5632            .thread_last_accessed
5633            .keys()
5634            .cloned()
5635            .collect::<Vec<_>>();
5636        assert_eq!(last_accessed.len(), 1);
5637        assert!(last_accessed.contains(&session_id_c));
5638        assert!(
5639            sidebar
5640                .active_entry
5641                .as_ref()
5642                .expect("active_entry should be set")
5643                .is_active_thread(&session_id_c)
5644        );
5645    });
5646
5647    sidebar.update_in(cx, |sidebar, window, cx| {
5648        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5649    });
5650    cx.run_until_parked();
5651
5652    assert_eq!(
5653        switcher_ids(&sidebar, cx),
5654        vec![
5655            session_id_c.clone(),
5656            session_id_a.clone(),
5657            session_id_b.clone()
5658        ],
5659    );
5660
5661    // Confirm on Thread A.
5662    sidebar.update_in(cx, |sidebar, window, cx| {
5663        let switcher = sidebar.thread_switcher.as_ref().unwrap();
5664        let focus = switcher.focus_handle(cx);
5665        focus.dispatch_action(&menu::Confirm, window, cx);
5666    });
5667    cx.run_until_parked();
5668
5669    sidebar.update(cx, |sidebar, _cx| {
5670        let last_accessed = sidebar
5671            .thread_last_accessed
5672            .keys()
5673            .cloned()
5674            .collect::<Vec<_>>();
5675        assert_eq!(last_accessed.len(), 2);
5676        assert!(last_accessed.contains(&session_id_c));
5677        assert!(last_accessed.contains(&session_id_a));
5678        assert!(
5679            sidebar
5680                .active_entry
5681                .as_ref()
5682                .expect("active_entry should be set")
5683                .is_active_thread(&session_id_a)
5684        );
5685    });
5686
5687    sidebar.update_in(cx, |sidebar, window, cx| {
5688        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5689    });
5690    cx.run_until_parked();
5691
5692    assert_eq!(
5693        switcher_ids(&sidebar, cx),
5694        vec![
5695            session_id_a.clone(),
5696            session_id_c.clone(),
5697            session_id_b.clone(),
5698        ],
5699    );
5700
5701    sidebar.update_in(cx, |sidebar, _window, cx| {
5702        let switcher = sidebar.thread_switcher.as_ref().unwrap();
5703        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
5704    });
5705    cx.run_until_parked();
5706
5707    // Confirm on Thread B.
5708    sidebar.update_in(cx, |sidebar, window, cx| {
5709        let switcher = sidebar.thread_switcher.as_ref().unwrap();
5710        let focus = switcher.focus_handle(cx);
5711        focus.dispatch_action(&menu::Confirm, window, cx);
5712    });
5713    cx.run_until_parked();
5714
5715    sidebar.update(cx, |sidebar, _cx| {
5716        let last_accessed = sidebar
5717            .thread_last_accessed
5718            .keys()
5719            .cloned()
5720            .collect::<Vec<_>>();
5721        assert_eq!(last_accessed.len(), 3);
5722        assert!(last_accessed.contains(&session_id_c));
5723        assert!(last_accessed.contains(&session_id_a));
5724        assert!(last_accessed.contains(&session_id_b));
5725        assert!(
5726            sidebar
5727                .active_entry
5728                .as_ref()
5729                .expect("active_entry should be set")
5730                .is_active_thread(&session_id_b)
5731        );
5732    });
5733
5734    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
5735    // This thread was never opened in a panel — it only exists in metadata.
5736    save_thread_metadata(
5737        acp::SessionId::new(Arc::from("thread-historical")),
5738        "Historical Thread".into(),
5739        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
5740        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
5741        &project,
5742        cx,
5743    );
5744
5745    sidebar.update_in(cx, |sidebar, window, cx| {
5746        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5747    });
5748    cx.run_until_parked();
5749
5750    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
5751    // so it falls to tier 3 (sorted by created_at). It should appear after all
5752    // accessed threads, even though its created_at (June 2024) is much later
5753    // than the others.
5754    //
5755    // But the live threads (A, B, C) each had send_message called which sets
5756    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
5757    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
5758    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
5759
5760    let ids = switcher_ids(&sidebar, cx);
5761    assert_eq!(
5762        ids,
5763        vec![
5764            session_id_b.clone(),
5765            session_id_a.clone(),
5766            session_id_c.clone(),
5767            session_id_hist.clone()
5768        ],
5769    );
5770
5771    sidebar.update_in(cx, |sidebar, _window, cx| {
5772        sidebar.dismiss_thread_switcher(cx);
5773    });
5774    cx.run_until_parked();
5775
5776    // ── 4. Add another historical thread with older created_at ─────────
5777    save_thread_metadata(
5778        acp::SessionId::new(Arc::from("thread-old-historical")),
5779        "Old Historical Thread".into(),
5780        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
5781        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
5782        &project,
5783        cx,
5784    );
5785
5786    sidebar.update_in(cx, |sidebar, window, cx| {
5787        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
5788    });
5789    cx.run_until_parked();
5790
5791    // Both historical threads have no access or message times. They should
5792    // appear after accessed threads, sorted by created_at (newest first).
5793    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
5794    let ids = switcher_ids(&sidebar, cx);
5795    assert_eq!(
5796        ids,
5797        vec![
5798            session_id_b,
5799            session_id_a,
5800            session_id_c,
5801            session_id_hist,
5802            session_id_old_hist,
5803        ],
5804    );
5805
5806    sidebar.update_in(cx, |sidebar, _window, cx| {
5807        sidebar.dismiss_thread_switcher(cx);
5808    });
5809    cx.run_until_parked();
5810}
5811
5812#[gpui::test]
5813async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
5814    let project = init_test_project("/my-project", cx).await;
5815    let (multi_workspace, cx) =
5816        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5817    let sidebar = setup_sidebar(&multi_workspace, cx);
5818
5819    save_thread_metadata(
5820        acp::SessionId::new(Arc::from("thread-to-archive")),
5821        "Thread To Archive".into(),
5822        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5823        None,
5824        &project,
5825        cx,
5826    );
5827    cx.run_until_parked();
5828
5829    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5830    cx.run_until_parked();
5831
5832    let entries = visible_entries_as_strings(&sidebar, cx);
5833    assert!(
5834        entries.iter().any(|e| e.contains("Thread To Archive")),
5835        "expected thread to be visible before archiving, got: {entries:?}"
5836    );
5837
5838    sidebar.update_in(cx, |sidebar, window, cx| {
5839        sidebar.archive_thread(
5840            &acp::SessionId::new(Arc::from("thread-to-archive")),
5841            window,
5842            cx,
5843        );
5844    });
5845    cx.run_until_parked();
5846
5847    let entries = visible_entries_as_strings(&sidebar, cx);
5848    assert!(
5849        !entries.iter().any(|e| e.contains("Thread To Archive")),
5850        "expected thread to be hidden after archiving, got: {entries:?}"
5851    );
5852
5853    cx.update(|_, cx| {
5854        let store = ThreadMetadataStore::global(cx);
5855        let archived: Vec<_> = store.read(cx).archived_entries().collect();
5856        assert_eq!(archived.len(), 1);
5857        assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive");
5858        assert!(archived[0].archived);
5859    });
5860}
5861
5862#[gpui::test]
5863async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
5864    // Tests two archive scenarios:
5865    // 1. Archiving a thread in a non-active workspace leaves active_entry
5866    //    as the current draft.
5867    // 2. Archiving the thread the user is looking at falls back to a draft
5868    //    on the same workspace.
5869    agent_ui::test_support::init_test(cx);
5870    cx.update(|cx| {
5871        ThreadStore::init_global(cx);
5872        ThreadMetadataStore::init_global(cx);
5873        language_model::LanguageModelRegistry::test(cx);
5874        prompt_store::init(cx);
5875    });
5876
5877    let fs = FakeFs::new(cx.executor());
5878    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
5879        .await;
5880    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
5881        .await;
5882    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5883
5884    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
5885    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
5886
5887    let (multi_workspace, cx) =
5888        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5889    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5890
5891    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5892        mw.test_add_workspace(project_b.clone(), window, cx)
5893    });
5894    let panel_b = add_agent_panel(&workspace_b, cx);
5895    cx.run_until_parked();
5896
5897    // --- Scenario 1: archive a thread in the non-active workspace ---
5898
5899    // Create a thread in project-a (non-active — project-b is active).
5900    let connection = acp_thread::StubAgentConnection::new();
5901    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5902        acp::ContentChunk::new("Done".into()),
5903    )]);
5904    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
5905    agent_ui::test_support::send_message(&panel_a, cx);
5906    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
5907    cx.run_until_parked();
5908
5909    sidebar.update_in(cx, |sidebar, window, cx| {
5910        sidebar.archive_thread(&thread_a, window, cx);
5911    });
5912    cx.run_until_parked();
5913
5914    // active_entry should still be a draft on workspace_b (the active one).
5915    sidebar.read_with(cx, |sidebar, _| {
5916        assert!(
5917            matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_b),
5918            "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
5919            sidebar.active_entry,
5920        );
5921    });
5922
5923    // --- Scenario 2: archive the thread the user is looking at ---
5924
5925    // Create a thread in project-b (the active workspace) and verify it
5926    // becomes the active entry.
5927    let connection = acp_thread::StubAgentConnection::new();
5928    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5929        acp::ContentChunk::new("Done".into()),
5930    )]);
5931    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
5932    agent_ui::test_support::send_message(&panel_b, cx);
5933    let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
5934    cx.run_until_parked();
5935
5936    sidebar.read_with(cx, |sidebar, _| {
5937        assert!(
5938            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id, .. }) if *session_id == thread_b),
5939            "expected active_entry to be Thread({thread_b}), got: {:?}",
5940            sidebar.active_entry,
5941        );
5942    });
5943
5944    sidebar.update_in(cx, |sidebar, window, cx| {
5945        sidebar.archive_thread(&thread_b, window, cx);
5946    });
5947    cx.run_until_parked();
5948
5949    // Should fall back to a draft on the same workspace.
5950    sidebar.read_with(cx, |sidebar, _| {
5951        assert!(
5952            matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_b),
5953            "expected Draft(workspace_b) after archiving active thread, got: {:?}",
5954            sidebar.active_entry,
5955        );
5956    });
5957}
5958
5959#[gpui::test]
5960async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) {
5961    // When a thread is archived while the user is in a different workspace,
5962    // the archiving code clears the thread from its panel (via
5963    // `clear_active_thread`). Switching back to that workspace should show
5964    // a draft, not the archived thread.
5965    agent_ui::test_support::init_test(cx);
5966    cx.update(|cx| {
5967        ThreadStore::init_global(cx);
5968        ThreadMetadataStore::init_global(cx);
5969        language_model::LanguageModelRegistry::test(cx);
5970        prompt_store::init(cx);
5971    });
5972
5973    let fs = FakeFs::new(cx.executor());
5974    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
5975        .await;
5976    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
5977        .await;
5978    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5979
5980    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
5981    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
5982
5983    let (multi_workspace, cx) =
5984        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5985    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5986
5987    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5988        mw.test_add_workspace(project_b.clone(), window, cx)
5989    });
5990    let _panel_b = add_agent_panel(&workspace_b, cx);
5991    cx.run_until_parked();
5992
5993    // Create a thread in project-a's panel (currently non-active).
5994    let connection = acp_thread::StubAgentConnection::new();
5995    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5996        acp::ContentChunk::new("Done".into()),
5997    )]);
5998    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
5999    agent_ui::test_support::send_message(&panel_a, cx);
6000    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
6001    cx.run_until_parked();
6002
6003    // Archive it while project-b is active.
6004    sidebar.update_in(cx, |sidebar, window, cx| {
6005        sidebar.archive_thread(&thread_a, window, cx);
6006    });
6007    cx.run_until_parked();
6008
6009    // Switch back to project-a. Its panel was cleared during archiving,
6010    // so active_entry should be Draft.
6011    let workspace_a =
6012        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
6013    multi_workspace.update_in(cx, |mw, window, cx| {
6014        mw.activate(workspace_a.clone(), window, cx);
6015    });
6016    cx.run_until_parked();
6017
6018    sidebar.update_in(cx, |sidebar, _window, cx| {
6019        sidebar.update_entries(cx);
6020    });
6021    cx.run_until_parked();
6022
6023    sidebar.read_with(cx, |sidebar, _| {
6024        assert!(
6025            matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_a),
6026            "expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}",
6027            sidebar.active_entry,
6028        );
6029    });
6030}
6031
6032#[gpui::test]
6033async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
6034    let project = init_test_project("/my-project", cx).await;
6035    let (multi_workspace, cx) =
6036        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6037    let sidebar = setup_sidebar(&multi_workspace, cx);
6038
6039    save_thread_metadata(
6040        acp::SessionId::new(Arc::from("visible-thread")),
6041        "Visible Thread".into(),
6042        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6043        None,
6044        &project,
6045        cx,
6046    );
6047
6048    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
6049    save_thread_metadata(
6050        archived_thread_session_id.clone(),
6051        "Archived Thread".into(),
6052        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6053        None,
6054        &project,
6055        cx,
6056    );
6057
6058    cx.update(|_, cx| {
6059        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
6060            store.archive(&archived_thread_session_id, None, cx)
6061        })
6062    });
6063    cx.run_until_parked();
6064
6065    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6066    cx.run_until_parked();
6067
6068    let entries = visible_entries_as_strings(&sidebar, cx);
6069    assert!(
6070        entries.iter().any(|e| e.contains("Visible Thread")),
6071        "expected visible thread in sidebar, got: {entries:?}"
6072    );
6073    assert!(
6074        !entries.iter().any(|e| e.contains("Archived Thread")),
6075        "expected archived thread to be hidden from sidebar, got: {entries:?}"
6076    );
6077
6078    cx.update(|_, cx| {
6079        let store = ThreadMetadataStore::global(cx);
6080        let all: Vec<_> = store.read(cx).entries().collect();
6081        assert_eq!(
6082            all.len(),
6083            2,
6084            "expected 2 total entries in the store, got: {}",
6085            all.len()
6086        );
6087
6088        let archived: Vec<_> = store.read(cx).archived_entries().collect();
6089        assert_eq!(archived.len(), 1);
6090        assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread");
6091    });
6092}
6093
6094#[gpui::test]
6095async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
6096    cx: &mut TestAppContext,
6097) {
6098    // When a linked worktree has a single thread and that thread is archived,
6099    // the sidebar must NOT create a new thread on the same worktree (which
6100    // would prevent the worktree from being cleaned up on disk). Instead,
6101    // archive_thread switches to a sibling thread on the main workspace (or
6102    // creates a draft there) before archiving the metadata.
6103    agent_ui::test_support::init_test(cx);
6104    cx.update(|cx| {
6105        ThreadStore::init_global(cx);
6106        ThreadMetadataStore::init_global(cx);
6107        language_model::LanguageModelRegistry::test(cx);
6108        prompt_store::init(cx);
6109    });
6110
6111    let fs = FakeFs::new(cx.executor());
6112
6113    fs.insert_tree(
6114        "/project",
6115        serde_json::json!({
6116            ".git": {},
6117            "src": {},
6118        }),
6119    )
6120    .await;
6121
6122    fs.add_linked_worktree_for_repo(
6123        Path::new("/project/.git"),
6124        false,
6125        git::repository::Worktree {
6126            path: std::path::PathBuf::from("/wt-ochre-drift"),
6127            ref_name: Some("refs/heads/ochre-drift".into()),
6128            sha: "aaa".into(),
6129            is_main: false,
6130        },
6131    )
6132    .await;
6133
6134    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6135
6136    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6137    let worktree_project =
6138        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
6139
6140    main_project
6141        .update(cx, |p, cx| p.git_scans_complete(cx))
6142        .await;
6143    worktree_project
6144        .update(cx, |p, cx| p.git_scans_complete(cx))
6145        .await;
6146
6147    let (multi_workspace, cx) =
6148        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
6149
6150    let sidebar = setup_sidebar(&multi_workspace, cx);
6151
6152    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6153        mw.test_add_workspace(worktree_project.clone(), window, cx)
6154    });
6155
6156    // Set up both workspaces with agent panels.
6157    let main_workspace =
6158        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
6159    let _main_panel = add_agent_panel(&main_workspace, cx);
6160    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
6161
6162    // Activate the linked worktree workspace so the sidebar tracks it.
6163    multi_workspace.update_in(cx, |mw, window, cx| {
6164        mw.activate(worktree_workspace.clone(), window, cx);
6165    });
6166
6167    // Open a thread in the linked worktree panel and send a message
6168    // so it becomes the active thread.
6169    let connection = StubAgentConnection::new();
6170    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
6171    send_message(&worktree_panel, cx);
6172
6173    let worktree_thread_id = active_session_id(&worktree_panel, cx);
6174
6175    // Give the thread a response chunk so it has content.
6176    cx.update(|_, cx| {
6177        connection.send_update(
6178            worktree_thread_id.clone(),
6179            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
6180            cx,
6181        );
6182    });
6183
6184    // Save the worktree thread's metadata.
6185    save_thread_metadata(
6186        worktree_thread_id.clone(),
6187        "Ochre Drift Thread".into(),
6188        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6189        None,
6190        &worktree_project,
6191        cx,
6192    );
6193
6194    // Also save a thread on the main project so there's a sibling in the
6195    // group that can be selected after archiving.
6196    save_thread_metadata(
6197        acp::SessionId::new(Arc::from("main-project-thread")),
6198        "Main Project Thread".into(),
6199        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6200        None,
6201        &main_project,
6202        cx,
6203    );
6204
6205    cx.run_until_parked();
6206
6207    // Verify the linked worktree thread appears with its chip.
6208    // The live thread title comes from the message text ("Hello"), not
6209    // the metadata title we saved.
6210    let entries_before = visible_entries_as_strings(&sidebar, cx);
6211    assert!(
6212        entries_before
6213            .iter()
6214            .any(|s| s.contains("{wt-ochre-drift}")),
6215        "expected worktree thread with chip before archiving, got: {entries_before:?}"
6216    );
6217    assert!(
6218        entries_before
6219            .iter()
6220            .any(|s| s.contains("Main Project Thread")),
6221        "expected main project thread before archiving, got: {entries_before:?}"
6222    );
6223
6224    // Confirm the worktree thread is the active entry.
6225    sidebar.read_with(cx, |s, _| {
6226        assert_active_thread(
6227            s,
6228            &worktree_thread_id,
6229            "worktree thread should be active before archiving",
6230        );
6231    });
6232
6233    // Archive the worktree thread — it's the only thread using ochre-drift.
6234    sidebar.update_in(cx, |sidebar, window, cx| {
6235        sidebar.archive_thread(&worktree_thread_id, window, cx);
6236    });
6237
6238    cx.run_until_parked();
6239
6240    // The archived thread should no longer appear in the sidebar.
6241    let entries_after = visible_entries_as_strings(&sidebar, cx);
6242    assert!(
6243        !entries_after
6244            .iter()
6245            .any(|s| s.contains("Ochre Drift Thread")),
6246        "archived thread should be hidden, got: {entries_after:?}"
6247    );
6248
6249    // No "+ New Thread" entry should appear with the ochre-drift worktree
6250    // chip — that would keep the worktree alive and prevent cleanup.
6251    assert!(
6252        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
6253        "no entry should reference the archived worktree, got: {entries_after:?}"
6254    );
6255
6256    // The main project thread should still be visible.
6257    assert!(
6258        entries_after
6259            .iter()
6260            .any(|s| s.contains("Main Project Thread")),
6261        "main project thread should still be visible, got: {entries_after:?}"
6262    );
6263}
6264
6265#[gpui::test]
6266async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_creates_draft_on_main(
6267    cx: &mut TestAppContext,
6268) {
6269    // When a linked worktree thread is the ONLY thread in the project group
6270    // (no threads on the main repo either), archiving it should create a
6271    // draft on the main workspace, not the linked worktree workspace.
6272    agent_ui::test_support::init_test(cx);
6273    cx.update(|cx| {
6274        ThreadStore::init_global(cx);
6275        ThreadMetadataStore::init_global(cx);
6276        language_model::LanguageModelRegistry::test(cx);
6277        prompt_store::init(cx);
6278    });
6279
6280    let fs = FakeFs::new(cx.executor());
6281
6282    fs.insert_tree(
6283        "/project",
6284        serde_json::json!({
6285            ".git": {},
6286            "src": {},
6287        }),
6288    )
6289    .await;
6290
6291    fs.add_linked_worktree_for_repo(
6292        Path::new("/project/.git"),
6293        false,
6294        git::repository::Worktree {
6295            path: std::path::PathBuf::from("/wt-ochre-drift"),
6296            ref_name: Some("refs/heads/ochre-drift".into()),
6297            sha: "aaa".into(),
6298            is_main: false,
6299        },
6300    )
6301    .await;
6302
6303    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6304
6305    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6306    let worktree_project =
6307        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
6308
6309    main_project
6310        .update(cx, |p, cx| p.git_scans_complete(cx))
6311        .await;
6312    worktree_project
6313        .update(cx, |p, cx| p.git_scans_complete(cx))
6314        .await;
6315
6316    let (multi_workspace, cx) =
6317        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
6318
6319    let sidebar = setup_sidebar(&multi_workspace, cx);
6320
6321    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6322        mw.test_add_workspace(worktree_project.clone(), window, cx)
6323    });
6324
6325    let main_workspace =
6326        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
6327    let _main_panel = add_agent_panel(&main_workspace, cx);
6328    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
6329
6330    // Activate the linked worktree workspace.
6331    multi_workspace.update_in(cx, |mw, window, cx| {
6332        mw.activate(worktree_workspace.clone(), window, cx);
6333    });
6334
6335    // Open a thread on the linked worktree — this is the ONLY thread.
6336    let connection = StubAgentConnection::new();
6337    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
6338    send_message(&worktree_panel, cx);
6339
6340    let worktree_thread_id = active_session_id(&worktree_panel, cx);
6341
6342    cx.update(|_, cx| {
6343        connection.send_update(
6344            worktree_thread_id.clone(),
6345            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
6346            cx,
6347        );
6348    });
6349
6350    save_thread_metadata(
6351        worktree_thread_id.clone(),
6352        "Ochre Drift Thread".into(),
6353        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6354        None,
6355        &worktree_project,
6356        cx,
6357    );
6358
6359    cx.run_until_parked();
6360
6361    // Archive it — there are no other threads in the group.
6362    sidebar.update_in(cx, |sidebar, window, cx| {
6363        sidebar.archive_thread(&worktree_thread_id, window, cx);
6364    });
6365
6366    cx.run_until_parked();
6367
6368    let entries_after = visible_entries_as_strings(&sidebar, cx);
6369
6370    // No entry should reference the linked worktree.
6371    assert!(
6372        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
6373        "no entry should reference the archived worktree, got: {entries_after:?}"
6374    );
6375
6376    // The active entry should be a draft on the main workspace.
6377    sidebar.read_with(cx, |s, _| {
6378        assert_active_draft(
6379            s,
6380            &main_workspace,
6381            "active entry should be a draft on the main workspace",
6382        );
6383    });
6384}
6385
6386#[gpui::test]
6387async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
6388    // When a linked worktree thread is archived but the group has other
6389    // threads (e.g. on the main project), archive_thread should select
6390    // the nearest sibling.
6391    agent_ui::test_support::init_test(cx);
6392    cx.update(|cx| {
6393        ThreadStore::init_global(cx);
6394        ThreadMetadataStore::init_global(cx);
6395        language_model::LanguageModelRegistry::test(cx);
6396        prompt_store::init(cx);
6397    });
6398
6399    let fs = FakeFs::new(cx.executor());
6400
6401    fs.insert_tree(
6402        "/project",
6403        serde_json::json!({
6404            ".git": {},
6405            "src": {},
6406        }),
6407    )
6408    .await;
6409
6410    fs.add_linked_worktree_for_repo(
6411        Path::new("/project/.git"),
6412        false,
6413        git::repository::Worktree {
6414            path: std::path::PathBuf::from("/wt-ochre-drift"),
6415            ref_name: Some("refs/heads/ochre-drift".into()),
6416            sha: "aaa".into(),
6417            is_main: false,
6418        },
6419    )
6420    .await;
6421
6422    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6423
6424    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6425    let worktree_project =
6426        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
6427
6428    main_project
6429        .update(cx, |p, cx| p.git_scans_complete(cx))
6430        .await;
6431    worktree_project
6432        .update(cx, |p, cx| p.git_scans_complete(cx))
6433        .await;
6434
6435    let (multi_workspace, cx) =
6436        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
6437
6438    let sidebar = setup_sidebar(&multi_workspace, cx);
6439
6440    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6441        mw.test_add_workspace(worktree_project.clone(), window, cx)
6442    });
6443
6444    let main_workspace =
6445        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
6446    let _main_panel = add_agent_panel(&main_workspace, cx);
6447    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
6448
6449    // Activate the linked worktree workspace.
6450    multi_workspace.update_in(cx, |mw, window, cx| {
6451        mw.activate(worktree_workspace.clone(), window, cx);
6452    });
6453
6454    // Open a thread on the linked worktree.
6455    let connection = StubAgentConnection::new();
6456    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
6457    send_message(&worktree_panel, cx);
6458
6459    let worktree_thread_id = active_session_id(&worktree_panel, cx);
6460
6461    cx.update(|_, cx| {
6462        connection.send_update(
6463            worktree_thread_id.clone(),
6464            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
6465            cx,
6466        );
6467    });
6468
6469    save_thread_metadata(
6470        worktree_thread_id.clone(),
6471        "Ochre Drift Thread".into(),
6472        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6473        None,
6474        &worktree_project,
6475        cx,
6476    );
6477
6478    // Save a sibling thread on the main project.
6479    let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
6480    save_thread_metadata(
6481        main_thread_id,
6482        "Main Project Thread".into(),
6483        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6484        None,
6485        &main_project,
6486        cx,
6487    );
6488
6489    cx.run_until_parked();
6490
6491    // Confirm the worktree thread is active.
6492    sidebar.read_with(cx, |s, _| {
6493        assert_active_thread(
6494            s,
6495            &worktree_thread_id,
6496            "worktree thread should be active before archiving",
6497        );
6498    });
6499
6500    // Archive the worktree thread.
6501    sidebar.update_in(cx, |sidebar, window, cx| {
6502        sidebar.archive_thread(&worktree_thread_id, window, cx);
6503    });
6504
6505    cx.run_until_parked();
6506
6507    // The worktree workspace was removed and a draft was created on the
6508    // main workspace. No entry should reference the linked worktree.
6509    let entries_after = visible_entries_as_strings(&sidebar, cx);
6510    assert!(
6511        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
6512        "no entry should reference the archived worktree, got: {entries_after:?}"
6513    );
6514
6515    // The main project thread should still be visible.
6516    assert!(
6517        entries_after
6518            .iter()
6519            .any(|s| s.contains("Main Project Thread")),
6520        "main project thread should still be visible, got: {entries_after:?}"
6521    );
6522}
6523
6524#[gpui::test]
6525async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
6526    // When a linked worktree is opened as its own workspace and the user
6527    // creates a draft thread from it, then switches away, the workspace must
6528    // still be reachable from that DraftThread sidebar entry. Pressing
6529    // RemoveSelectedThread (shift-backspace) on that entry should remove the
6530    // workspace.
6531    init_test(cx);
6532    let fs = FakeFs::new(cx.executor());
6533
6534    fs.insert_tree(
6535        "/project",
6536        serde_json::json!({
6537            ".git": {
6538                "worktrees": {
6539                    "feature-a": {
6540                        "commondir": "../../",
6541                        "HEAD": "ref: refs/heads/feature-a",
6542                    },
6543                },
6544            },
6545            "src": {},
6546        }),
6547    )
6548    .await;
6549
6550    fs.insert_tree(
6551        "/wt-feature-a",
6552        serde_json::json!({
6553            ".git": "gitdir: /project/.git/worktrees/feature-a",
6554            "src": {},
6555        }),
6556    )
6557    .await;
6558
6559    fs.add_linked_worktree_for_repo(
6560        Path::new("/project/.git"),
6561        false,
6562        git::repository::Worktree {
6563            path: PathBuf::from("/wt-feature-a"),
6564            ref_name: Some("refs/heads/feature-a".into()),
6565            sha: "aaa".into(),
6566            is_main: false,
6567        },
6568    )
6569    .await;
6570
6571    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6572
6573    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6574    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6575
6576    main_project
6577        .update(cx, |p, cx| p.git_scans_complete(cx))
6578        .await;
6579    worktree_project
6580        .update(cx, |p, cx| p.git_scans_complete(cx))
6581        .await;
6582
6583    let (multi_workspace, cx) =
6584        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
6585    let sidebar = setup_sidebar(&multi_workspace, cx);
6586
6587    // Open the linked worktree as a separate workspace (simulates cmd-o).
6588    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6589        mw.test_add_workspace(worktree_project.clone(), window, cx)
6590    });
6591    add_agent_panel(&worktree_workspace, cx);
6592    cx.run_until_parked();
6593
6594    // Explicitly create a draft thread from the linked worktree workspace.
6595    // Auto-created drafts use the group's first workspace (the main one),
6596    // so a user-created draft is needed to make the linked worktree reachable.
6597    sidebar.update_in(cx, |sidebar, window, cx| {
6598        sidebar.create_new_thread(&worktree_workspace, window, cx);
6599    });
6600    cx.run_until_parked();
6601
6602    // Switch back to the main workspace.
6603    multi_workspace.update_in(cx, |mw, window, cx| {
6604        let main_ws = mw.workspaces().next().unwrap().clone();
6605        mw.activate(main_ws, window, cx);
6606    });
6607    cx.run_until_parked();
6608
6609    sidebar.update_in(cx, |sidebar, _window, cx| {
6610        sidebar.update_entries(cx);
6611    });
6612    cx.run_until_parked();
6613
6614    // The linked worktree workspace must be reachable from some sidebar entry.
6615    let worktree_ws_id = worktree_workspace.entity_id();
6616    let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
6617        let mw = multi_workspace.read(cx);
6618        sidebar
6619            .contents
6620            .entries
6621            .iter()
6622            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
6623            .map(|ws| ws.entity_id())
6624            .collect()
6625    });
6626    assert!(
6627        reachable.contains(&worktree_ws_id),
6628        "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
6629    );
6630
6631    // Find the DraftThread entry whose workspace is the linked worktree.
6632    let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
6633        sidebar
6634            .contents
6635            .entries
6636            .iter()
6637            .position(|entry| {
6638                matches!(
6639                    entry,
6640                    ListEntry::DraftThread {
6641                        workspace: Some(ws),
6642                        ..
6643                    } if ws.entity_id() == worktree_ws_id
6644                )
6645            })
6646            .expect("expected a DraftThread entry for the linked worktree")
6647    });
6648
6649    assert_eq!(
6650        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6651        2
6652    );
6653
6654    sidebar.update_in(cx, |sidebar, window, cx| {
6655        sidebar.selection = Some(new_thread_ix);
6656        sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
6657    });
6658    cx.run_until_parked();
6659
6660    assert_eq!(
6661        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6662        2,
6663        "dismissing a draft no longer removes the linked worktree workspace"
6664    );
6665
6666    let has_draft_for_worktree = sidebar.read_with(cx, |sidebar, _| {
6667        sidebar.contents.entries.iter().any(|entry| {
6668            matches!(
6669                entry,
6670                ListEntry::DraftThread {
6671                    draft_id: Some(_),
6672                    workspace: Some(ws),
6673                    ..
6674                } if ws.entity_id() == worktree_ws_id
6675            )
6676        })
6677    });
6678    assert!(
6679        !has_draft_for_worktree,
6680        "DraftThread entry for the linked worktree should be removed after dismiss"
6681    );
6682}
6683
6684#[gpui::test]
6685async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
6686    // When only a linked worktree workspace is open (not the main repo),
6687    // threads saved against the main repo should still appear in the sidebar.
6688    init_test(cx);
6689    let fs = FakeFs::new(cx.executor());
6690
6691    // Create the main repo with a linked worktree.
6692    fs.insert_tree(
6693        "/project",
6694        serde_json::json!({
6695            ".git": {
6696                "worktrees": {
6697                    "feature-a": {
6698                        "commondir": "../../",
6699                        "HEAD": "ref: refs/heads/feature-a",
6700                    },
6701                },
6702            },
6703            "src": {},
6704        }),
6705    )
6706    .await;
6707
6708    fs.insert_tree(
6709        "/wt-feature-a",
6710        serde_json::json!({
6711            ".git": "gitdir: /project/.git/worktrees/feature-a",
6712            "src": {},
6713        }),
6714    )
6715    .await;
6716
6717    fs.add_linked_worktree_for_repo(
6718        std::path::Path::new("/project/.git"),
6719        false,
6720        git::repository::Worktree {
6721            path: std::path::PathBuf::from("/wt-feature-a"),
6722            ref_name: Some("refs/heads/feature-a".into()),
6723            sha: "abc".into(),
6724            is_main: false,
6725        },
6726    )
6727    .await;
6728
6729    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6730
6731    // Only open the linked worktree as a workspace — NOT the main repo.
6732    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6733    worktree_project
6734        .update(cx, |p, cx| p.git_scans_complete(cx))
6735        .await;
6736
6737    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6738    main_project
6739        .update(cx, |p, cx| p.git_scans_complete(cx))
6740        .await;
6741
6742    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6743        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
6744    });
6745    let sidebar = setup_sidebar(&multi_workspace, cx);
6746
6747    // Save a thread against the MAIN repo path.
6748    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
6749
6750    // Save a thread against the linked worktree path.
6751    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
6752
6753    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6754    cx.run_until_parked();
6755
6756    // Both threads should be visible: the worktree thread by direct lookup,
6757    // and the main repo thread because the workspace is a linked worktree
6758    // and we also query the main repo path.
6759    let entries = visible_entries_as_strings(&sidebar, cx);
6760    assert!(
6761        entries.iter().any(|e| e.contains("Main Repo Thread")),
6762        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
6763    );
6764    assert!(
6765        entries.iter().any(|e| e.contains("Worktree Thread")),
6766        "expected worktree thread to be visible, got: {entries:?}"
6767    );
6768}
6769
6770async fn init_multi_project_test(
6771    paths: &[&str],
6772    cx: &mut TestAppContext,
6773) -> (Arc<FakeFs>, Entity<project::Project>) {
6774    agent_ui::test_support::init_test(cx);
6775    cx.update(|cx| {
6776        ThreadStore::init_global(cx);
6777        ThreadMetadataStore::init_global(cx);
6778        language_model::LanguageModelRegistry::test(cx);
6779        prompt_store::init(cx);
6780    });
6781    let fs = FakeFs::new(cx.executor());
6782    for path in paths {
6783        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
6784            .await;
6785    }
6786    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6787    let project =
6788        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
6789    (fs, project)
6790}
6791
6792async fn add_test_project(
6793    path: &str,
6794    fs: &Arc<FakeFs>,
6795    multi_workspace: &Entity<MultiWorkspace>,
6796    cx: &mut gpui::VisualTestContext,
6797) -> Entity<Workspace> {
6798    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
6799    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6800        mw.test_add_workspace(project, window, cx)
6801    });
6802    cx.run_until_parked();
6803    workspace
6804}
6805
6806#[gpui::test]
6807async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
6808    let (fs, project_a) =
6809        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
6810    let (multi_workspace, cx) =
6811        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6812    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
6813
6814    // Sidebar starts closed. Initial workspace A is transient.
6815    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6816    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
6817    assert_eq!(
6818        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6819        1
6820    );
6821    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
6822
6823    // Add B — replaces A as the transient workspace.
6824    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
6825    assert_eq!(
6826        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6827        1
6828    );
6829    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
6830
6831    // Add C — replaces B as the transient workspace.
6832    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
6833    assert_eq!(
6834        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6835        1
6836    );
6837    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
6838}
6839
6840#[gpui::test]
6841async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
6842    let (fs, project_a) = init_multi_project_test(
6843        &["/project-a", "/project-b", "/project-c", "/project-d"],
6844        cx,
6845    )
6846    .await;
6847    let (multi_workspace, cx) =
6848        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6849    let _sidebar = setup_sidebar(&multi_workspace, cx);
6850    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
6851
6852    // Add B — retained since sidebar is open.
6853    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
6854    assert_eq!(
6855        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6856        2
6857    );
6858
6859    // Switch to A — B survives. (Switching from one internal workspace, to another)
6860    multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
6861    cx.run_until_parked();
6862    assert_eq!(
6863        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6864        2
6865    );
6866
6867    // Close sidebar — both A and B remain retained.
6868    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
6869    cx.run_until_parked();
6870    assert_eq!(
6871        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6872        2
6873    );
6874
6875    // Add C — added as new transient workspace. (switching from retained, to transient)
6876    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
6877    assert_eq!(
6878        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6879        3
6880    );
6881    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
6882
6883    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
6884    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
6885    assert_eq!(
6886        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6887        3
6888    );
6889    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
6890}
6891
6892#[gpui::test]
6893async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
6894    let (fs, project_a) =
6895        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
6896    let (multi_workspace, cx) =
6897        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6898    setup_sidebar_closed(&multi_workspace, cx);
6899
6900    // Add B — replaces A as the transient workspace (A is discarded).
6901    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
6902    assert_eq!(
6903        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6904        1
6905    );
6906    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
6907
6908    // Open sidebar — promotes the transient B to retained.
6909    multi_workspace.update_in(cx, |mw, window, cx| {
6910        mw.toggle_sidebar(window, cx);
6911    });
6912    cx.run_until_parked();
6913    assert_eq!(
6914        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6915        1
6916    );
6917    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
6918
6919    // Close sidebar — the retained B remains.
6920    multi_workspace.update_in(cx, |mw, window, cx| {
6921        mw.toggle_sidebar(window, cx);
6922    });
6923
6924    // Add C — added as new transient workspace.
6925    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
6926    assert_eq!(
6927        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
6928        2
6929    );
6930    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
6931}
6932
6933#[gpui::test]
6934async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
6935    init_test(cx);
6936    let fs = FakeFs::new(cx.executor());
6937
6938    fs.insert_tree(
6939        "/project",
6940        serde_json::json!({
6941            ".git": {
6942                "worktrees": {
6943                    "feature-a": {
6944                        "commondir": "../../",
6945                        "HEAD": "ref: refs/heads/feature-a",
6946                    },
6947                },
6948            },
6949            "src": {},
6950        }),
6951    )
6952    .await;
6953
6954    fs.insert_tree(
6955        "/wt-feature-a",
6956        serde_json::json!({
6957            ".git": "gitdir: /project/.git/worktrees/feature-a",
6958            "src": {},
6959        }),
6960    )
6961    .await;
6962
6963    fs.add_linked_worktree_for_repo(
6964        Path::new("/project/.git"),
6965        false,
6966        git::repository::Worktree {
6967            path: PathBuf::from("/wt-feature-a"),
6968            ref_name: Some("refs/heads/feature-a".into()),
6969            sha: "abc".into(),
6970            is_main: false,
6971        },
6972    )
6973    .await;
6974
6975    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6976
6977    // Only a linked worktree workspace is open — no workspace for /project.
6978    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6979    worktree_project
6980        .update(cx, |p, cx| p.git_scans_complete(cx))
6981        .await;
6982
6983    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6984        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
6985    });
6986    let sidebar = setup_sidebar(&multi_workspace, cx);
6987
6988    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
6989    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
6990    cx.update(|_, cx| {
6991        let metadata = ThreadMetadata {
6992            session_id: legacy_session.clone(),
6993            agent_id: agent::ZED_AGENT_ID.clone(),
6994            title: "Legacy Main Thread".into(),
6995            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
6996            created_at: None,
6997            worktree_paths: ThreadWorktreePaths::from_folder_paths(&PathList::new(&[
6998                PathBuf::from("/project"),
6999            ])),
7000            archived: false,
7001            remote_connection: None,
7002        };
7003        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
7004    });
7005    cx.run_until_parked();
7006
7007    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7008    cx.run_until_parked();
7009
7010    // The legacy thread should appear in the sidebar under the project group.
7011    let entries = visible_entries_as_strings(&sidebar, cx);
7012    assert!(
7013        entries.iter().any(|e| e.contains("Legacy Main Thread")),
7014        "legacy thread should be visible: {entries:?}",
7015    );
7016
7017    // Verify only 1 workspace before clicking.
7018    assert_eq!(
7019        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
7020        1,
7021    );
7022
7023    // Focus and select the legacy thread, then confirm.
7024    focus_sidebar(&sidebar, cx);
7025    let thread_index = sidebar.read_with(cx, |sidebar, _| {
7026        sidebar
7027            .contents
7028            .entries
7029            .iter()
7030            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
7031            .expect("legacy thread should be in entries")
7032    });
7033    sidebar.update_in(cx, |sidebar, _window, _cx| {
7034        sidebar.selection = Some(thread_index);
7035    });
7036    cx.dispatch_action(Confirm);
7037    cx.run_until_parked();
7038
7039    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7040    let new_path_list =
7041        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
7042    assert_eq!(
7043        new_path_list,
7044        PathList::new(&[PathBuf::from("/project")]),
7045        "the new workspace should be for the main repo, not the linked worktree",
7046    );
7047}
7048
7049#[gpui::test]
7050async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
7051    cx: &mut TestAppContext,
7052) {
7053    // Regression test for a property-test finding:
7054    //   AddLinkedWorktree { project_group_index: 0 }
7055    //   AddProject { use_worktree: true }
7056    //   AddProject { use_worktree: false }
7057    // After these three steps, the linked-worktree workspace was not
7058    // reachable from any sidebar entry.
7059    agent_ui::test_support::init_test(cx);
7060    cx.update(|cx| {
7061        ThreadStore::init_global(cx);
7062        ThreadMetadataStore::init_global(cx);
7063        language_model::LanguageModelRegistry::test(cx);
7064        prompt_store::init(cx);
7065
7066        cx.observe_new(
7067            |workspace: &mut Workspace,
7068             window: Option<&mut Window>,
7069             cx: &mut gpui::Context<Workspace>| {
7070                if let Some(window) = window {
7071                    let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
7072                    workspace.add_panel(panel, window, cx);
7073                }
7074            },
7075        )
7076        .detach();
7077    });
7078
7079    let fs = FakeFs::new(cx.executor());
7080    fs.insert_tree(
7081        "/my-project",
7082        serde_json::json!({
7083            ".git": {},
7084            "src": {},
7085        }),
7086    )
7087    .await;
7088    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7089    let project =
7090        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
7091    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
7092
7093    let (multi_workspace, cx) =
7094        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7095    let sidebar = setup_sidebar(&multi_workspace, cx);
7096
7097    // Step 1: Create a linked worktree for the main project.
7098    let worktree_name = "wt-0";
7099    let worktree_path = "/worktrees/wt-0";
7100
7101    fs.insert_tree(
7102        worktree_path,
7103        serde_json::json!({
7104            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
7105            "src": {},
7106        }),
7107    )
7108    .await;
7109    fs.insert_tree(
7110        "/my-project/.git/worktrees/wt-0",
7111        serde_json::json!({
7112            "commondir": "../../",
7113            "HEAD": "ref: refs/heads/wt-0",
7114        }),
7115    )
7116    .await;
7117    fs.add_linked_worktree_for_repo(
7118        Path::new("/my-project/.git"),
7119        false,
7120        git::repository::Worktree {
7121            path: PathBuf::from(worktree_path),
7122            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
7123            sha: "aaa".into(),
7124            is_main: false,
7125        },
7126    )
7127    .await;
7128
7129    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7130    let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
7131    main_project
7132        .update(cx, |p, cx| p.git_scans_complete(cx))
7133        .await;
7134    cx.run_until_parked();
7135
7136    // Step 2: Open the linked worktree as its own workspace.
7137    let worktree_project =
7138        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
7139    worktree_project
7140        .update(cx, |p, cx| p.git_scans_complete(cx))
7141        .await;
7142    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7143        mw.test_add_workspace(worktree_project.clone(), window, cx)
7144    });
7145    cx.run_until_parked();
7146
7147    // Step 3: Add an unrelated project.
7148    fs.insert_tree(
7149        "/other-project",
7150        serde_json::json!({
7151            ".git": {},
7152            "src": {},
7153        }),
7154    )
7155    .await;
7156    let other_project = project::Project::test(
7157        fs.clone() as Arc<dyn fs::Fs>,
7158        ["/other-project".as_ref()],
7159        cx,
7160    )
7161    .await;
7162    other_project
7163        .update(cx, |p, cx| p.git_scans_complete(cx))
7164        .await;
7165    multi_workspace.update_in(cx, |mw, window, cx| {
7166        mw.test_add_workspace(other_project.clone(), window, cx);
7167    });
7168    cx.run_until_parked();
7169
7170    // Force a full sidebar rebuild with all groups expanded.
7171    sidebar.update_in(cx, |sidebar, _window, cx| {
7172        sidebar.collapsed_groups.clear();
7173        let group_keys: Vec<project::ProjectGroupKey> = sidebar
7174            .contents
7175            .entries
7176            .iter()
7177            .filter_map(|entry| match entry {
7178                ListEntry::ProjectHeader { key, .. } => Some(key.clone()),
7179                _ => None,
7180            })
7181            .collect();
7182        for group_key in group_keys {
7183            sidebar.expanded_groups.insert(group_key, 10_000);
7184        }
7185        sidebar.update_entries(cx);
7186    });
7187    cx.run_until_parked();
7188
7189    // The linked-worktree workspace must be reachable from at least one
7190    // sidebar entry — otherwise the user has no way to navigate to it.
7191    let worktree_ws_id = worktree_workspace.entity_id();
7192    let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
7193        let mw = multi_workspace.read(cx);
7194
7195        let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
7196        let reachable: HashSet<gpui::EntityId> = sidebar
7197            .contents
7198            .entries
7199            .iter()
7200            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
7201            .map(|ws| ws.entity_id())
7202            .collect();
7203        (all, reachable)
7204    });
7205
7206    let unreachable = &all_ids - &reachable_ids;
7207    eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
7208
7209    assert!(
7210        unreachable.is_empty(),
7211        "workspaces not reachable from any sidebar entry: {:?}\n\
7212         (linked-worktree workspace id: {:?})",
7213        unreachable,
7214        worktree_ws_id,
7215    );
7216}
7217
7218mod property_test {
7219    use super::*;
7220    use gpui::proptest::prelude::*;
7221
7222    struct UnopenedWorktree {
7223        path: String,
7224        main_workspace_path: String,
7225    }
7226
7227    struct TestState {
7228        fs: Arc<FakeFs>,
7229        thread_counter: u32,
7230        workspace_counter: u32,
7231        worktree_counter: u32,
7232        saved_thread_ids: Vec<acp::SessionId>,
7233        unopened_worktrees: Vec<UnopenedWorktree>,
7234    }
7235
7236    impl TestState {
7237        fn new(fs: Arc<FakeFs>) -> Self {
7238            Self {
7239                fs,
7240                thread_counter: 0,
7241                workspace_counter: 1,
7242                worktree_counter: 0,
7243                saved_thread_ids: Vec::new(),
7244                unopened_worktrees: Vec::new(),
7245            }
7246        }
7247
7248        fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
7249            let id = self.thread_counter;
7250            self.thread_counter += 1;
7251            acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
7252        }
7253
7254        fn next_workspace_path(&mut self) -> String {
7255            let id = self.workspace_counter;
7256            self.workspace_counter += 1;
7257            format!("/prop-project-{id}")
7258        }
7259
7260        fn next_worktree_name(&mut self) -> String {
7261            let id = self.worktree_counter;
7262            self.worktree_counter += 1;
7263            format!("wt-{id}")
7264        }
7265    }
7266
7267    #[derive(Debug)]
7268    enum Operation {
7269        SaveThread { project_group_index: usize },
7270        SaveWorktreeThread { worktree_index: usize },
7271        ToggleAgentPanel,
7272        CreateDraftThread,
7273        AddProject { use_worktree: bool },
7274        ArchiveThread { index: usize },
7275        SwitchToThread { index: usize },
7276        SwitchToProjectGroup { index: usize },
7277        AddLinkedWorktree { project_group_index: usize },
7278        AddWorktreeToProject { project_group_index: usize },
7279        RemoveWorktreeFromProject { project_group_index: usize },
7280    }
7281
7282    // Distribution (out of 24 slots):
7283    //   SaveThread:                5 slots (~21%)
7284    //   SaveWorktreeThread:        2 slots (~8%)
7285    //   ToggleAgentPanel:          1 slot  (~4%)
7286    //   CreateDraftThread:         1 slot  (~4%)
7287    //   AddProject:                1 slot  (~4%)
7288    //   ArchiveThread:             2 slots (~8%)
7289    //   SwitchToThread:            2 slots (~8%)
7290    //   SwitchToProjectGroup:      2 slots (~8%)
7291    //   AddLinkedWorktree:         4 slots (~17%)
7292    //   AddWorktreeToProject:      2 slots (~8%)
7293    //   RemoveWorktreeFromProject: 2 slots (~8%)
7294    const DISTRIBUTION_SLOTS: u32 = 24;
7295
7296    impl TestState {
7297        fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
7298            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
7299
7300            match raw % DISTRIBUTION_SLOTS {
7301                0..=4 => Operation::SaveThread {
7302                    project_group_index: extra % project_group_count,
7303                },
7304                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
7305                    worktree_index: extra % self.unopened_worktrees.len(),
7306                },
7307                5..=6 => Operation::SaveThread {
7308                    project_group_index: extra % project_group_count,
7309                },
7310                7 => Operation::ToggleAgentPanel,
7311                8 => Operation::CreateDraftThread,
7312                9 => Operation::AddProject {
7313                    use_worktree: !self.unopened_worktrees.is_empty(),
7314                },
7315                10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
7316                    index: extra % self.saved_thread_ids.len(),
7317                },
7318                10..=11 => Operation::AddProject {
7319                    use_worktree: !self.unopened_worktrees.is_empty(),
7320                },
7321                12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
7322                    index: extra % self.saved_thread_ids.len(),
7323                },
7324                12..=13 => Operation::SwitchToProjectGroup {
7325                    index: extra % project_group_count,
7326                },
7327                14..=15 => Operation::SwitchToProjectGroup {
7328                    index: extra % project_group_count,
7329                },
7330                16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
7331                    project_group_index: extra % project_group_count,
7332                },
7333                16..=19 => Operation::SaveThread {
7334                    project_group_index: extra % project_group_count,
7335                },
7336                20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
7337                    project_group_index: extra % project_group_count,
7338                },
7339                20..=21 => Operation::SaveThread {
7340                    project_group_index: extra % project_group_count,
7341                },
7342                22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
7343                    project_group_index: extra % project_group_count,
7344                },
7345                22..=23 => Operation::SaveThread {
7346                    project_group_index: extra % project_group_count,
7347                },
7348                _ => unreachable!(),
7349            }
7350        }
7351    }
7352
7353    fn save_thread_to_path_with_main(
7354        state: &mut TestState,
7355        path_list: PathList,
7356        main_worktree_paths: PathList,
7357        cx: &mut gpui::VisualTestContext,
7358    ) {
7359        let session_id = state.next_metadata_only_thread_id();
7360        let title: SharedString = format!("Thread {}", session_id).into();
7361        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
7362            .unwrap()
7363            + chrono::Duration::seconds(state.thread_counter as i64);
7364        let metadata = ThreadMetadata {
7365            session_id,
7366            agent_id: agent::ZED_AGENT_ID.clone(),
7367            title,
7368            updated_at,
7369            created_at: None,
7370            worktree_paths: ThreadWorktreePaths::from_path_lists(main_worktree_paths, path_list)
7371                .unwrap(),
7372            archived: false,
7373            remote_connection: None,
7374        };
7375        cx.update(|_, cx| {
7376            ThreadMetadataStore::global(cx)
7377                .update(cx, |store, cx| store.save_manually(metadata, cx))
7378        });
7379        cx.run_until_parked();
7380    }
7381
7382    async fn perform_operation(
7383        operation: Operation,
7384        state: &mut TestState,
7385        multi_workspace: &Entity<MultiWorkspace>,
7386        sidebar: &Entity<Sidebar>,
7387        cx: &mut gpui::VisualTestContext,
7388    ) {
7389        match operation {
7390            Operation::SaveThread {
7391                project_group_index,
7392            } => {
7393                // Find a workspace for this project group and create a real
7394                // thread via its agent panel.
7395                let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
7396                    let key = mw.project_group_keys().nth(project_group_index).unwrap();
7397                    let ws = mw
7398                        .workspaces_for_project_group(key, cx)
7399                        .next()
7400                        .unwrap_or(mw.workspace())
7401                        .clone();
7402                    let project = ws.read(cx).project().clone();
7403                    (ws, project)
7404                });
7405
7406                let panel =
7407                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
7408                if let Some(panel) = panel {
7409                    let connection = StubAgentConnection::new();
7410                    connection.set_next_prompt_updates(vec![
7411                        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
7412                            "Done".into(),
7413                        )),
7414                    ]);
7415                    open_thread_with_connection(&panel, connection, cx);
7416                    send_message(&panel, cx);
7417                    let session_id = active_session_id(&panel, cx);
7418                    state.saved_thread_ids.push(session_id.clone());
7419
7420                    let title: SharedString = format!("Thread {}", state.thread_counter).into();
7421                    state.thread_counter += 1;
7422                    let updated_at =
7423                        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
7424                            .unwrap()
7425                            + chrono::Duration::seconds(state.thread_counter as i64);
7426                    save_thread_metadata(session_id, title, updated_at, None, &project, cx);
7427                }
7428            }
7429            Operation::SaveWorktreeThread { worktree_index } => {
7430                let worktree = &state.unopened_worktrees[worktree_index];
7431                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
7432                let main_worktree_paths =
7433                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
7434                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
7435            }
7436
7437            Operation::ToggleAgentPanel => {
7438                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7439                let panel_open =
7440                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
7441                workspace.update_in(cx, |workspace, window, cx| {
7442                    if panel_open {
7443                        workspace.close_panel::<AgentPanel>(window, cx);
7444                    } else {
7445                        workspace.open_panel::<AgentPanel>(window, cx);
7446                    }
7447                });
7448            }
7449            Operation::CreateDraftThread => {
7450                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
7451                let panel =
7452                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
7453                if let Some(panel) = panel {
7454                    let connection = StubAgentConnection::new();
7455                    open_thread_with_connection(&panel, connection, cx);
7456                    cx.run_until_parked();
7457                }
7458                workspace.update_in(cx, |workspace, window, cx| {
7459                    workspace.focus_panel::<AgentPanel>(window, cx);
7460                });
7461            }
7462            Operation::AddProject { use_worktree } => {
7463                let path = if use_worktree {
7464                    // Open an existing linked worktree as a project (simulates Cmd+O
7465                    // on a worktree directory).
7466                    state.unopened_worktrees.remove(0).path
7467                } else {
7468                    // Create a brand new project.
7469                    let path = state.next_workspace_path();
7470                    state
7471                        .fs
7472                        .insert_tree(
7473                            &path,
7474                            serde_json::json!({
7475                                ".git": {},
7476                                "src": {},
7477                            }),
7478                        )
7479                        .await;
7480                    path
7481                };
7482                let project = project::Project::test(
7483                    state.fs.clone() as Arc<dyn fs::Fs>,
7484                    [path.as_ref()],
7485                    cx,
7486                )
7487                .await;
7488                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
7489                multi_workspace.update_in(cx, |mw, window, cx| {
7490                    mw.test_add_workspace(project.clone(), window, cx)
7491                });
7492            }
7493
7494            Operation::ArchiveThread { index } => {
7495                let session_id = state.saved_thread_ids[index].clone();
7496                sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
7497                    sidebar.archive_thread(&session_id, window, cx);
7498                });
7499                cx.run_until_parked();
7500                state.saved_thread_ids.remove(index);
7501            }
7502            Operation::SwitchToThread { index } => {
7503                let session_id = state.saved_thread_ids[index].clone();
7504                // Find the thread's position in the sidebar entries and select it.
7505                let thread_index = sidebar.read_with(cx, |sidebar, _| {
7506                    sidebar.contents.entries.iter().position(|entry| {
7507                        matches!(
7508                            entry,
7509                            ListEntry::Thread(t) if t.metadata.session_id == session_id
7510                        )
7511                    })
7512                });
7513                if let Some(ix) = thread_index {
7514                    sidebar.update_in(cx, |sidebar, window, cx| {
7515                        sidebar.selection = Some(ix);
7516                        sidebar.confirm(&Confirm, window, cx);
7517                    });
7518                    cx.run_until_parked();
7519                }
7520            }
7521            Operation::SwitchToProjectGroup { index } => {
7522                let workspace = multi_workspace.read_with(cx, |mw, cx| {
7523                    let key = mw.project_group_keys().nth(index).unwrap();
7524                    mw.workspaces_for_project_group(key, cx)
7525                        .next()
7526                        .unwrap_or(mw.workspace())
7527                        .clone()
7528                });
7529                multi_workspace.update_in(cx, |mw, window, cx| {
7530                    mw.activate(workspace, window, cx);
7531                });
7532            }
7533            Operation::AddLinkedWorktree {
7534                project_group_index,
7535            } => {
7536                // Get the main worktree path from the project group key.
7537                let main_path = multi_workspace.read_with(cx, |mw, _| {
7538                    let key = mw.project_group_keys().nth(project_group_index).unwrap();
7539                    key.path_list()
7540                        .paths()
7541                        .first()
7542                        .unwrap()
7543                        .to_string_lossy()
7544                        .to_string()
7545                });
7546                let dot_git = format!("{}/.git", main_path);
7547                let worktree_name = state.next_worktree_name();
7548                let worktree_path = format!("/worktrees/{}", worktree_name);
7549
7550                state.fs
7551                    .insert_tree(
7552                        &worktree_path,
7553                        serde_json::json!({
7554                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
7555                            "src": {},
7556                        }),
7557                    )
7558                    .await;
7559
7560                // Also create the worktree metadata dir inside the main repo's .git
7561                state
7562                    .fs
7563                    .insert_tree(
7564                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
7565                        serde_json::json!({
7566                            "commondir": "../../",
7567                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
7568                        }),
7569                    )
7570                    .await;
7571
7572                let dot_git_path = std::path::Path::new(&dot_git);
7573                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
7574                state
7575                    .fs
7576                    .add_linked_worktree_for_repo(
7577                        dot_git_path,
7578                        false,
7579                        git::repository::Worktree {
7580                            path: worktree_pathbuf,
7581                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
7582                            sha: "aaa".into(),
7583                            is_main: false,
7584                        },
7585                    )
7586                    .await;
7587
7588                // Re-scan the main workspace's project so it discovers the new worktree.
7589                let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
7590                    let key = mw.project_group_keys().nth(project_group_index).unwrap();
7591                    mw.workspaces_for_project_group(key, cx)
7592                        .next()
7593                        .unwrap()
7594                        .clone()
7595                });
7596                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
7597                main_project
7598                    .update(cx, |p, cx| p.git_scans_complete(cx))
7599                    .await;
7600
7601                state.unopened_worktrees.push(UnopenedWorktree {
7602                    path: worktree_path,
7603                    main_workspace_path: main_path.clone(),
7604                });
7605            }
7606            Operation::AddWorktreeToProject {
7607                project_group_index,
7608            } => {
7609                let workspace = multi_workspace.read_with(cx, |mw, cx| {
7610                    let key = mw.project_group_keys().nth(project_group_index).unwrap();
7611                    mw.workspaces_for_project_group(key, cx).next().cloned()
7612                });
7613                let Some(workspace) = workspace else { return };
7614                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
7615
7616                let new_path = state.next_workspace_path();
7617                state
7618                    .fs
7619                    .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
7620                    .await;
7621
7622                let result = project
7623                    .update(cx, |project, cx| {
7624                        project.find_or_create_worktree(&new_path, true, cx)
7625                    })
7626                    .await;
7627                if result.is_err() {
7628                    return;
7629                }
7630                cx.run_until_parked();
7631            }
7632            Operation::RemoveWorktreeFromProject {
7633                project_group_index,
7634            } => {
7635                let workspace = multi_workspace.read_with(cx, |mw, cx| {
7636                    let key = mw.project_group_keys().nth(project_group_index).unwrap();
7637                    mw.workspaces_for_project_group(key, cx).next().cloned()
7638                });
7639                let Some(workspace) = workspace else { return };
7640                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
7641
7642                let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
7643                if worktree_count <= 1 {
7644                    return;
7645                }
7646
7647                let worktree_id = project.read_with(cx, |p, cx| {
7648                    p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
7649                });
7650                if let Some(worktree_id) = worktree_id {
7651                    project.update(cx, |project, cx| {
7652                        project.remove_worktree(worktree_id, cx);
7653                    });
7654                    cx.run_until_parked();
7655                }
7656            }
7657        }
7658    }
7659
7660    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
7661        sidebar.update_in(cx, |sidebar, _window, cx| {
7662            sidebar.collapsed_groups.clear();
7663            let group_keys: Vec<project::ProjectGroupKey> = sidebar
7664                .contents
7665                .entries
7666                .iter()
7667                .filter_map(|entry| match entry {
7668                    ListEntry::ProjectHeader { key, .. } => Some(key.clone()),
7669                    _ => None,
7670                })
7671                .collect();
7672            for group_key in group_keys {
7673                sidebar.expanded_groups.insert(group_key, 10_000);
7674            }
7675            sidebar.update_entries(cx);
7676        });
7677    }
7678
7679    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
7680        verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
7681        verify_no_duplicate_threads(sidebar)?;
7682        verify_all_threads_are_shown(sidebar, cx)?;
7683        verify_active_state_matches_current_workspace(sidebar, cx)?;
7684        verify_all_workspaces_are_reachable(sidebar, cx)?;
7685        verify_workspace_group_key_integrity(sidebar, cx)?;
7686        Ok(())
7687    }
7688
7689    fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
7690        let mut seen: HashSet<acp::SessionId> = HashSet::default();
7691        let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
7692
7693        for entry in &sidebar.contents.entries {
7694            if let Some(session_id) = entry.session_id() {
7695                if !seen.insert(session_id.clone()) {
7696                    let title = match entry {
7697                        ListEntry::Thread(thread) => thread.metadata.title.to_string(),
7698                        _ => "<unknown>".to_string(),
7699                    };
7700                    duplicates.push((session_id.clone(), title));
7701                }
7702            }
7703        }
7704
7705        anyhow::ensure!(
7706            duplicates.is_empty(),
7707            "threads appear more than once in sidebar: {:?}",
7708            duplicates,
7709        );
7710        Ok(())
7711    }
7712
7713    fn verify_every_group_in_multiworkspace_is_shown(
7714        sidebar: &Sidebar,
7715        cx: &App,
7716    ) -> anyhow::Result<()> {
7717        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
7718            anyhow::bail!("sidebar should still have an associated multi-workspace");
7719        };
7720
7721        let mw = multi_workspace.read(cx);
7722
7723        // Every project group key in the multi-workspace that has a
7724        // non-empty path list should appear as a ProjectHeader in the
7725        // sidebar.
7726        let expected_keys: HashSet<&project::ProjectGroupKey> = mw
7727            .project_group_keys()
7728            .filter(|k| !k.path_list().paths().is_empty())
7729            .collect();
7730
7731        let sidebar_keys: HashSet<&project::ProjectGroupKey> = sidebar
7732            .contents
7733            .entries
7734            .iter()
7735            .filter_map(|entry| match entry {
7736                ListEntry::ProjectHeader { key, .. } => Some(key),
7737                _ => None,
7738            })
7739            .collect();
7740
7741        let missing = &expected_keys - &sidebar_keys;
7742        let stray = &sidebar_keys - &expected_keys;
7743
7744        anyhow::ensure!(
7745            missing.is_empty() && stray.is_empty(),
7746            "sidebar project groups don't match multi-workspace.\n\
7747             Only in multi-workspace (missing): {:?}\n\
7748             Only in sidebar (stray): {:?}",
7749            missing,
7750            stray,
7751        );
7752
7753        Ok(())
7754    }
7755
7756    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
7757        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
7758            anyhow::bail!("sidebar should still have an associated multi-workspace");
7759        };
7760        let workspaces = multi_workspace
7761            .read(cx)
7762            .workspaces()
7763            .cloned()
7764            .collect::<Vec<_>>();
7765        let thread_store = ThreadMetadataStore::global(cx);
7766
7767        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
7768            .contents
7769            .entries
7770            .iter()
7771            .filter_map(|entry| entry.session_id().cloned())
7772            .collect();
7773
7774        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
7775
7776        // Query using the same approach as the sidebar: iterate project
7777        // group keys, then do main + legacy queries per group.
7778        let mw = multi_workspace.read(cx);
7779        let mut workspaces_by_group: HashMap<project::ProjectGroupKey, Vec<Entity<Workspace>>> =
7780            HashMap::default();
7781        for workspace in &workspaces {
7782            let key = workspace.read(cx).project_group_key(cx);
7783            workspaces_by_group
7784                .entry(key)
7785                .or_default()
7786                .push(workspace.clone());
7787        }
7788
7789        for group_key in mw.project_group_keys() {
7790            let path_list = group_key.path_list().clone();
7791            if path_list.paths().is_empty() {
7792                continue;
7793            }
7794
7795            let group_workspaces = workspaces_by_group
7796                .get(group_key)
7797                .map(|ws| ws.as_slice())
7798                .unwrap_or_default();
7799
7800            // Main code path queries (run for all groups, even without workspaces).
7801            for metadata in thread_store
7802                .read(cx)
7803                .entries_for_main_worktree_path(&path_list)
7804            {
7805                metadata_thread_ids.insert(metadata.session_id.clone());
7806            }
7807            for metadata in thread_store.read(cx).entries_for_path(&path_list) {
7808                metadata_thread_ids.insert(metadata.session_id.clone());
7809            }
7810
7811            // Legacy: per-workspace queries for different root paths.
7812            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
7813                .iter()
7814                .flat_map(|ws| {
7815                    ws.read(cx)
7816                        .root_paths(cx)
7817                        .into_iter()
7818                        .map(|p| p.to_path_buf())
7819                })
7820                .collect();
7821
7822            for workspace in group_workspaces {
7823                let ws_path_list = workspace_path_list(workspace, cx);
7824                if ws_path_list != path_list {
7825                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list) {
7826                        metadata_thread_ids.insert(metadata.session_id.clone());
7827                    }
7828                }
7829            }
7830
7831            for workspace in group_workspaces {
7832                for snapshot in root_repository_snapshots(workspace, cx) {
7833                    let repo_path_list =
7834                        PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
7835                    if repo_path_list != path_list {
7836                        continue;
7837                    }
7838                    for linked_worktree in snapshot.linked_worktrees() {
7839                        if covered_paths.contains(&*linked_worktree.path) {
7840                            continue;
7841                        }
7842                        let worktree_path_list =
7843                            PathList::new(std::slice::from_ref(&linked_worktree.path));
7844                        for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list)
7845                        {
7846                            metadata_thread_ids.insert(metadata.session_id.clone());
7847                        }
7848                    }
7849                }
7850            }
7851        }
7852
7853        anyhow::ensure!(
7854            sidebar_thread_ids == metadata_thread_ids,
7855            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
7856            sidebar_thread_ids,
7857            metadata_thread_ids,
7858        );
7859        Ok(())
7860    }
7861
7862    fn verify_active_state_matches_current_workspace(
7863        sidebar: &Sidebar,
7864        cx: &App,
7865    ) -> anyhow::Result<()> {
7866        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
7867            anyhow::bail!("sidebar should still have an associated multi-workspace");
7868        };
7869
7870        let active_workspace = multi_workspace.read(cx).workspace();
7871
7872        // 1. active_entry must always be Some after rebuild_contents.
7873        let entry = sidebar
7874            .active_entry
7875            .as_ref()
7876            .ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?;
7877
7878        // 2. The entry's workspace must agree with the multi-workspace's
7879        //    active workspace.
7880        anyhow::ensure!(
7881            entry.workspace().entity_id() == active_workspace.entity_id(),
7882            "active_entry workspace ({:?}) != active workspace ({:?})",
7883            entry.workspace().entity_id(),
7884            active_workspace.entity_id(),
7885        );
7886
7887        // 3. The entry must match the agent panel's current state.
7888        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
7889        if panel.read(cx).active_thread_is_draft(cx) {
7890            anyhow::ensure!(
7891                matches!(entry, ActiveEntry::Draft { .. }),
7892                "panel shows a draft but active_entry is {:?}",
7893                entry,
7894            );
7895        } else if let Some(session_id) = panel
7896            .read(cx)
7897            .active_conversation_view()
7898            .and_then(|cv| cv.read(cx).parent_id(cx))
7899        {
7900            anyhow::ensure!(
7901                matches!(entry, ActiveEntry::Thread { session_id: id, .. } if id == &session_id),
7902                "panel has session {:?} but active_entry is {:?}",
7903                session_id,
7904                entry,
7905            );
7906        }
7907
7908        // 4. Exactly one entry in sidebar contents must be uniquely
7909        //    identified by the active_entry.
7910        let matching_count = sidebar
7911            .contents
7912            .entries
7913            .iter()
7914            .filter(|e| entry.matches_entry(e))
7915            .count();
7916        anyhow::ensure!(
7917            matching_count == 1,
7918            "expected exactly 1 sidebar entry matching active_entry {:?}, found {}",
7919            entry,
7920            matching_count,
7921        );
7922
7923        Ok(())
7924    }
7925
7926    /// Every workspace in the multi-workspace should be "reachable" from
7927    /// the sidebar — meaning there is at least one entry (thread, draft,
7928    /// new-thread, or project header) that, when clicked, would activate
7929    /// that workspace.
7930    fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
7931        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
7932            anyhow::bail!("sidebar should still have an associated multi-workspace");
7933        };
7934
7935        let multi_workspace = multi_workspace.read(cx);
7936
7937        let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
7938            .contents
7939            .entries
7940            .iter()
7941            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
7942            .map(|ws| ws.entity_id())
7943            .collect();
7944
7945        let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
7946            .workspaces()
7947            .map(|ws| ws.entity_id())
7948            .collect();
7949
7950        let unreachable = &all_workspace_ids - &reachable_workspaces;
7951
7952        anyhow::ensure!(
7953            unreachable.is_empty(),
7954            "The following workspaces are not reachable from any sidebar entry: {:?}",
7955            unreachable,
7956        );
7957
7958        Ok(())
7959    }
7960
7961    fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
7962        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
7963            anyhow::bail!("sidebar should still have an associated multi-workspace");
7964        };
7965        multi_workspace
7966            .read(cx)
7967            .assert_project_group_key_integrity(cx)
7968    }
7969
7970    #[gpui::property_test(config = ProptestConfig {
7971        cases: 50,
7972        ..Default::default()
7973    })]
7974    async fn test_sidebar_invariants(
7975        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)]
7976        raw_operations: Vec<u32>,
7977        cx: &mut TestAppContext,
7978    ) {
7979        agent_ui::test_support::init_test(cx);
7980        cx.update(|cx| {
7981            ThreadStore::init_global(cx);
7982            ThreadMetadataStore::init_global(cx);
7983            language_model::LanguageModelRegistry::test(cx);
7984            prompt_store::init(cx);
7985
7986            // Auto-add an AgentPanel to every workspace so that implicitly
7987            // created workspaces (e.g. from thread activation) also have one.
7988            cx.observe_new(
7989                |workspace: &mut Workspace,
7990                 window: Option<&mut Window>,
7991                 cx: &mut gpui::Context<Workspace>| {
7992                    if let Some(window) = window {
7993                        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
7994                        workspace.add_panel(panel, window, cx);
7995                    }
7996                },
7997            )
7998            .detach();
7999        });
8000
8001        let fs = FakeFs::new(cx.executor());
8002        fs.insert_tree(
8003            "/my-project",
8004            serde_json::json!({
8005                ".git": {},
8006                "src": {},
8007            }),
8008        )
8009        .await;
8010        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
8011        let project =
8012            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
8013                .await;
8014        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
8015
8016        let (multi_workspace, cx) =
8017            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8018        let sidebar = setup_sidebar(&multi_workspace, cx);
8019
8020        let mut state = TestState::new(fs);
8021        let mut executed: Vec<String> = Vec::new();
8022
8023        for &raw_op in &raw_operations {
8024            let project_group_count =
8025                multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().count());
8026            let operation = state.generate_operation(raw_op, project_group_count);
8027            executed.push(format!("{:?}", operation));
8028            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
8029            cx.run_until_parked();
8030
8031            update_sidebar(&sidebar, cx);
8032            cx.run_until_parked();
8033
8034            let result =
8035                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
8036            if let Err(err) = result {
8037                let log = executed.join("\n  ");
8038                panic!(
8039                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
8040                    executed.len(),
8041                );
8042            }
8043        }
8044    }
8045}
8046
8047#[gpui::test]
8048async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
8049    cx: &mut TestAppContext,
8050    server_cx: &mut TestAppContext,
8051) {
8052    init_test(cx);
8053
8054    cx.update(|cx| {
8055        release_channel::init(semver::Version::new(0, 0, 0), cx);
8056    });
8057
8058    let app_state = cx.update(|cx| {
8059        let app_state = workspace::AppState::test(cx);
8060        workspace::init(app_state.clone(), cx);
8061        app_state
8062    });
8063
8064    // Set up the remote server side.
8065    let server_fs = FakeFs::new(server_cx.executor());
8066    server_fs
8067        .insert_tree(
8068            "/project",
8069            serde_json::json!({
8070                ".git": {},
8071                "src": { "main.rs": "fn main() {}" }
8072            }),
8073        )
8074        .await;
8075    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
8076
8077    // Create the linked worktree checkout path on the remote server,
8078    // but do not yet register it as a git-linked worktree. The real
8079    // regrouping update in this test should happen only after the
8080    // sidebar opens the closed remote thread.
8081    server_fs
8082        .insert_tree(
8083            "/project-wt-1",
8084            serde_json::json!({
8085                "src": { "main.rs": "fn main() {}" }
8086            }),
8087        )
8088        .await;
8089
8090    server_cx.update(|cx| {
8091        release_channel::init(semver::Version::new(0, 0, 0), cx);
8092    });
8093
8094    let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
8095
8096    server_cx.update(remote_server::HeadlessProject::init);
8097    let server_executor = server_cx.executor();
8098    let _headless = server_cx.new(|cx| {
8099        remote_server::HeadlessProject::new(
8100            remote_server::HeadlessAppState {
8101                session: server_session,
8102                fs: server_fs.clone(),
8103                http_client: Arc::new(http_client::BlockedHttpClient),
8104                node_runtime: node_runtime::NodeRuntime::unavailable(),
8105                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
8106                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
8107                startup_time: std::time::Instant::now(),
8108            },
8109            false,
8110            cx,
8111        )
8112    });
8113
8114    // Connect the client side and build a remote project.
8115    let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
8116    let project = cx.update(|cx| {
8117        let project_client = client::Client::new(
8118            Arc::new(clock::FakeSystemClock::new()),
8119            http_client::FakeHttpClient::with_404_response(),
8120            cx,
8121        );
8122        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
8123        project::Project::remote(
8124            remote_client,
8125            project_client,
8126            node_runtime::NodeRuntime::unavailable(),
8127            user_store,
8128            app_state.languages.clone(),
8129            app_state.fs.clone(),
8130            false,
8131            cx,
8132        )
8133    });
8134
8135    // Open the remote worktree.
8136    project
8137        .update(cx, |project, cx| {
8138            project.find_or_create_worktree(Path::new("/project"), true, cx)
8139        })
8140        .await
8141        .expect("should open remote worktree");
8142    cx.run_until_parked();
8143
8144    // Verify the project is remote.
8145    project.read_with(cx, |project, cx| {
8146        assert!(!project.is_local(), "project should be remote");
8147        assert!(
8148            project.remote_connection_options(cx).is_some(),
8149            "project should have remote connection options"
8150        );
8151    });
8152
8153    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
8154
8155    // Create MultiWorkspace with the remote project.
8156    let (multi_workspace, cx) =
8157        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8158    let sidebar = setup_sidebar(&multi_workspace, cx);
8159
8160    cx.run_until_parked();
8161
8162    // Save a thread for the main remote workspace (folder_paths match
8163    // the open workspace, so it will be classified as Open).
8164    let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
8165    save_thread_metadata(
8166        main_thread_id.clone(),
8167        "Main Thread".into(),
8168        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
8169        None,
8170        &project,
8171        cx,
8172    );
8173    cx.run_until_parked();
8174
8175    // Save a thread whose folder_paths point to a linked worktree path
8176    // that doesn't have an open workspace ("/project-wt-1"), but whose
8177    // main_worktree_paths match the project group key so it appears
8178    // in the sidebar under the same remote group. This simulates a
8179    // linked worktree workspace that was closed.
8180    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
8181    let main_worktree_paths =
8182        project.read_with(cx, |p, cx| p.project_group_key(cx).path_list().clone());
8183    cx.update(|_window, cx| {
8184        let metadata = ThreadMetadata {
8185            session_id: remote_thread_id.clone(),
8186            agent_id: agent::ZED_AGENT_ID.clone(),
8187            title: "Worktree Thread".into(),
8188            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
8189            created_at: None,
8190            worktree_paths: ThreadWorktreePaths::from_path_lists(
8191                main_worktree_paths,
8192                PathList::new(&[PathBuf::from("/project-wt-1")]),
8193            )
8194            .unwrap(),
8195            archived: false,
8196            remote_connection: None,
8197        };
8198        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
8199    });
8200    cx.run_until_parked();
8201
8202    focus_sidebar(&sidebar, cx);
8203    sidebar.update_in(cx, |sidebar, _window, _cx| {
8204        sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
8205            matches!(
8206                entry,
8207                ListEntry::Thread(thread) if thread.metadata.session_id == remote_thread_id
8208            )
8209        });
8210    });
8211
8212    let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
8213    let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
8214
8215    sidebar
8216        .update(cx, |_, cx| {
8217            cx.observe_self(move |sidebar, _cx| {
8218                let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
8219                    if let ListEntry::ProjectHeader { label, .. } = entry {
8220                        Some(label.as_ref())
8221                    } else {
8222                        None
8223                    }
8224                });
8225
8226                let Some(project_header) = project_headers.next() else {
8227                    saw_separate_project_header_for_observer
8228                        .store(true, std::sync::atomic::Ordering::SeqCst);
8229                    return;
8230                };
8231
8232                if project_header != "project" || project_headers.next().is_some() {
8233                    saw_separate_project_header_for_observer
8234                        .store(true, std::sync::atomic::Ordering::SeqCst);
8235                }
8236            })
8237        })
8238        .detach();
8239
8240    multi_workspace.update(cx, |multi_workspace, cx| {
8241        let workspace = multi_workspace.workspace().clone();
8242        workspace.update(cx, |workspace: &mut Workspace, cx| {
8243            let remote_client = workspace
8244                .project()
8245                .read(cx)
8246                .remote_client()
8247                .expect("main remote project should have a remote client");
8248            remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
8249                remote_client.force_server_not_running(cx);
8250            });
8251        });
8252    });
8253    cx.run_until_parked();
8254
8255    let (server_session_2, connect_guard_2) =
8256        remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
8257    let _headless_2 = server_cx.new(|cx| {
8258        remote_server::HeadlessProject::new(
8259            remote_server::HeadlessAppState {
8260                session: server_session_2,
8261                fs: server_fs.clone(),
8262                http_client: Arc::new(http_client::BlockedHttpClient),
8263                node_runtime: node_runtime::NodeRuntime::unavailable(),
8264                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
8265                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
8266                startup_time: std::time::Instant::now(),
8267            },
8268            false,
8269            cx,
8270        )
8271    });
8272    drop(connect_guard_2);
8273
8274    let window = cx.windows()[0];
8275    cx.update_window(window, |_, window, cx| {
8276        window.dispatch_action(Confirm.boxed_clone(), cx);
8277    })
8278    .unwrap();
8279
8280    cx.run_until_parked();
8281
8282    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
8283        assert_eq!(
8284            mw.workspaces().count(),
8285            2,
8286            "confirming a closed remote thread should open a second workspace"
8287        );
8288        mw.workspaces()
8289            .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
8290            .unwrap()
8291            .clone()
8292    });
8293
8294    server_fs
8295        .add_linked_worktree_for_repo(
8296            Path::new("/project/.git"),
8297            true,
8298            git::repository::Worktree {
8299                path: PathBuf::from("/project-wt-1"),
8300                ref_name: Some("refs/heads/feature-wt".into()),
8301                sha: "abc123".into(),
8302                is_main: false,
8303            },
8304        )
8305        .await;
8306
8307    server_cx.run_until_parked();
8308    cx.run_until_parked();
8309    server_cx.run_until_parked();
8310    cx.run_until_parked();
8311
8312    let entries_after_update = visible_entries_as_strings(&sidebar, cx);
8313    let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
8314        workspace.project().read(cx).project_group_key(cx)
8315    });
8316
8317    assert_eq!(
8318        group_after_update,
8319        project.read_with(cx, |project, cx| project.project_group_key(cx)),
8320        "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
8321         final sidebar entries: {:?}",
8322        entries_after_update,
8323    );
8324
8325    sidebar.update(cx, |sidebar, _cx| {
8326        assert_remote_project_integration_sidebar_state(
8327            sidebar,
8328            &main_thread_id,
8329            &remote_thread_id,
8330        );
8331    });
8332
8333    assert!(
8334        !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
8335        "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
8336         final group: {:?}; final sidebar entries: {:?}",
8337        group_after_update,
8338        entries_after_update,
8339    );
8340}