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