sidebar_tests.rs

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