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