sidebar_tests.rs

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