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