sidebar_tests.rs

   1use super::*;
   2use acp_thread::StubAgentConnection;
   3use agent::ThreadStore;
   4use agent_ui::{
   5    test_support::{active_session_id, open_thread_with_connection, send_message},
   6    thread_metadata_store::ThreadMetadata,
   7};
   8use chrono::DateTime;
   9use feature_flags::FeatureFlagAppExt as _;
  10use fs::FakeFs;
  11use gpui::TestAppContext;
  12use pretty_assertions::assert_eq;
  13use project::AgentId;
  14use settings::SettingsStore;
  15use std::{
  16    path::{Path, PathBuf},
  17    sync::Arc,
  18};
  19use util::path_list::PathList;
  20
  21fn init_test(cx: &mut TestAppContext) {
  22    cx.update(|cx| {
  23        let settings_store = SettingsStore::test(cx);
  24        cx.set_global(settings_store);
  25        theme_settings::init(theme::LoadThemes::JustBase, cx);
  26        editor::init(cx);
  27        cx.update_flags(false, vec!["agent-v2".into()]);
  28        ThreadStore::init_global(cx);
  29        ThreadMetadataStore::init_global(cx);
  30        language_model::LanguageModelRegistry::test(cx);
  31        prompt_store::init(cx);
  32    });
  33}
  34
  35#[track_caller]
  36fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
  37    assert!(
  38        sidebar
  39            .active_entry
  40            .as_ref()
  41            .is_some_and(|e| e.is_active_thread(session_id)),
  42        "{msg}: expected active_entry to be Thread({session_id:?}), got {:?}",
  43        sidebar.active_entry,
  44    );
  45}
  46
  47#[track_caller]
  48fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
  49    assert!(
  50        matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == workspace),
  51        "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
  52        workspace.entity_id(),
  53        sidebar.active_entry,
  54    );
  55}
  56
  57fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
  58    sidebar
  59        .contents
  60        .entries
  61        .iter()
  62        .any(|entry| matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id))
  63}
  64
  65#[track_caller]
  66fn assert_remote_project_integration_sidebar_state(
  67    sidebar: &mut Sidebar,
  68    main_thread_id: &acp::SessionId,
  69    remote_thread_id: &acp::SessionId,
  70) {
  71    let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
  72        if let ListEntry::ProjectHeader { label, .. } = entry {
  73            Some(label.as_ref())
  74        } else {
  75            None
  76        }
  77    });
  78
  79    let Some(project_header) = project_headers.next() else {
  80        panic!("expected exactly one sidebar project header named `project`, found none");
  81    };
  82    assert_eq!(
  83        project_header, "project",
  84        "expected the only sidebar project header to be `project`"
  85    );
  86    if let Some(unexpected_header) = project_headers.next() {
  87        panic!(
  88            "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
  89        );
  90    }
  91
  92    let mut saw_main_thread = false;
  93    let mut saw_remote_thread = false;
  94    for entry in &sidebar.contents.entries {
  95        match entry {
  96            ListEntry::ProjectHeader { label, .. } => {
  97                assert_eq!(
  98                    label.as_ref(),
  99                    "project",
 100                    "expected the only sidebar project header to be `project`"
 101                );
 102            }
 103            ListEntry::Thread(thread) if &thread.metadata.session_id == main_thread_id => {
 104                saw_main_thread = true;
 105            }
 106            ListEntry::Thread(thread) if &thread.metadata.session_id == remote_thread_id => {
 107                saw_remote_thread = true;
 108            }
 109            ListEntry::Thread(thread) => {
 110                let title = thread.metadata.title.as_ref();
 111                panic!(
 112                    "unexpected sidebar thread while simulating remote project integration flicker: title=`{title}`"
 113                );
 114            }
 115            ListEntry::ViewMore { .. } => {
 116                panic!(
 117                    "unexpected `View More` entry while simulating remote project integration flicker"
 118                );
 119            }
 120            ListEntry::DraftThread { .. } | ListEntry::NewThread { .. } => {}
 121        }
 122    }
 123
 124    assert!(
 125        saw_main_thread,
 126        "expected the sidebar to keep showing `Main Thread` under `project`"
 127    );
 128    assert!(
 129        saw_remote_thread,
 130        "expected the sidebar to keep showing `Worktree Thread` under `project`"
 131    );
 132}
 133
 134async fn init_test_project(
 135    worktree_path: &str,
 136    cx: &mut TestAppContext,
 137) -> Entity<project::Project> {
 138    init_test(cx);
 139    let fs = FakeFs::new(cx.executor());
 140    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
 141        .await;
 142    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 143    project::Project::test(fs, [worktree_path.as_ref()], cx).await
 144}
 145
 146fn setup_sidebar(
 147    multi_workspace: &Entity<MultiWorkspace>,
 148    cx: &mut gpui::VisualTestContext,
 149) -> Entity<Sidebar> {
 150    let sidebar = setup_sidebar_closed(multi_workspace, cx);
 151    multi_workspace.update_in(cx, |mw, window, cx| {
 152        mw.toggle_sidebar(window, cx);
 153    });
 154    cx.run_until_parked();
 155    sidebar
 156}
 157
 158fn setup_sidebar_closed(
 159    multi_workspace: &Entity<MultiWorkspace>,
 160    cx: &mut gpui::VisualTestContext,
 161) -> Entity<Sidebar> {
 162    let multi_workspace = multi_workspace.clone();
 163    let sidebar =
 164        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
 165    multi_workspace.update(cx, |mw, cx| {
 166        mw.register_sidebar(sidebar.clone(), cx);
 167    });
 168    cx.run_until_parked();
 169    sidebar
 170}
 171
 172async fn save_n_test_threads(
 173    count: u32,
 174    project: &Entity<project::Project>,
 175    cx: &mut gpui::VisualTestContext,
 176) {
 177    for i in 0..count {
 178        save_thread_metadata(
 179            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
 180            format!("Thread {}", i + 1).into(),
 181            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
 182            None,
 183            project,
 184            cx,
 185        )
 186    }
 187    cx.run_until_parked();
 188}
 189
 190async fn save_test_thread_metadata(
 191    session_id: &acp::SessionId,
 192    project: &Entity<project::Project>,
 193    cx: &mut TestAppContext,
 194) {
 195    save_thread_metadata(
 196        session_id.clone(),
 197        "Test".into(),
 198        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 199        None,
 200        project,
 201        cx,
 202    )
 203}
 204
 205async fn save_named_thread_metadata(
 206    session_id: &str,
 207    title: &str,
 208    project: &Entity<project::Project>,
 209    cx: &mut gpui::VisualTestContext,
 210) {
 211    save_thread_metadata(
 212        acp::SessionId::new(Arc::from(session_id)),
 213        SharedString::from(title.to_string()),
 214        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 215        None,
 216        project,
 217        cx,
 218    );
 219    cx.run_until_parked();
 220}
 221
 222fn save_thread_metadata(
 223    session_id: acp::SessionId,
 224    title: SharedString,
 225    updated_at: DateTime<Utc>,
 226    created_at: Option<DateTime<Utc>>,
 227    project: &Entity<project::Project>,
 228    cx: &mut TestAppContext,
 229) {
 230    cx.update(|cx| {
 231        let (folder_paths, main_worktree_paths) = {
 232            let project_ref = project.read(cx);
 233            let paths: Vec<Arc<Path>> = project_ref
 234                .visible_worktrees(cx)
 235                .map(|worktree| worktree.read(cx).abs_path())
 236                .collect();
 237            let folder_paths = PathList::new(&paths);
 238            let main_worktree_paths = project_ref.project_group_key(cx).path_list().clone();
 239            (folder_paths, main_worktree_paths)
 240        };
 241        let metadata = ThreadMetadata {
 242            session_id,
 243            agent_id: agent::ZED_AGENT_ID.clone(),
 244            title,
 245            updated_at,
 246            created_at,
 247            folder_paths,
 248            main_worktree_paths,
 249            archived: false,
 250        };
 251        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
 252    });
 253    cx.run_until_parked();
 254}
 255
 256fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
 257    sidebar.update_in(cx, |_, window, cx| {
 258        cx.focus_self(window);
 259    });
 260    cx.run_until_parked();
 261}
 262
 263fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
 264    let mut seen = Vec::new();
 265    let mut chips = Vec::new();
 266    for wt in worktrees {
 267        if wt.kind == ui::WorktreeKind::Main {
 268            continue;
 269        }
 270        if !seen.contains(&wt.name) {
 271            seen.push(wt.name.clone());
 272            chips.push(format!("{{{}}}", wt.name));
 273        }
 274    }
 275    if chips.is_empty() {
 276        String::new()
 277    } else {
 278        format!(" {}", chips.join(", "))
 279    }
 280}
 281
 282fn visible_entries_as_strings(
 283    sidebar: &Entity<Sidebar>,
 284    cx: &mut gpui::VisualTestContext,
 285) -> Vec<String> {
 286    sidebar.read_with(cx, |sidebar, _cx| {
 287        sidebar
 288            .contents
 289            .entries
 290            .iter()
 291            .enumerate()
 292            .map(|(ix, entry)| {
 293                let selected = if sidebar.selection == Some(ix) {
 294                    "  <== selected"
 295                } else {
 296                    ""
 297                };
 298                match entry {
 299                    ListEntry::ProjectHeader {
 300                        label,
 301                        key,
 302                        highlight_positions: _,
 303                        ..
 304                    } => {
 305                        let icon = if sidebar.collapsed_groups.contains(key.path_list()) {
 306                            ">"
 307                        } else {
 308                            "v"
 309                        };
 310                        format!("{} [{}]{}", icon, label, selected)
 311                    }
 312                    ListEntry::Thread(thread) => {
 313                        let title = thread.metadata.title.as_ref();
 314                        let active = if thread.is_live { " *" } else { "" };
 315                        let status_str = match thread.status {
 316                            AgentThreadStatus::Running => " (running)",
 317                            AgentThreadStatus::Error => " (error)",
 318                            AgentThreadStatus::WaitingForConfirmation => " (waiting)",
 319                            _ => "",
 320                        };
 321                        let notified = if sidebar
 322                            .contents
 323                            .is_thread_notified(&thread.metadata.session_id)
 324                        {
 325                            " (!)"
 326                        } else {
 327                            ""
 328                        };
 329                        let worktree = format_linked_worktree_chips(&thread.worktrees);
 330                        format!("  {title}{worktree}{active}{status_str}{notified}{selected}")
 331                    }
 332                    ListEntry::ViewMore {
 333                        is_fully_expanded, ..
 334                    } => {
 335                        if *is_fully_expanded {
 336                            format!("  - Collapse{}", selected)
 337                        } else {
 338                            format!("  + View More{}", selected)
 339                        }
 340                    }
 341                    ListEntry::DraftThread { worktrees, .. } => {
 342                        let worktree = format_linked_worktree_chips(worktrees);
 343                        format!("  [~ Draft{}]{}", worktree, selected)
 344                    }
 345                    ListEntry::NewThread { worktrees, .. } => {
 346                        let worktree = format_linked_worktree_chips(worktrees);
 347                        format!("  [+ New Thread{}]{}", worktree, selected)
 348                    }
 349                }
 350            })
 351            .collect()
 352    })
 353}
 354
 355#[gpui::test]
 356async fn test_serialization_round_trip(cx: &mut TestAppContext) {
 357    let project = init_test_project("/my-project", cx).await;
 358    let (multi_workspace, cx) =
 359        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 360    let sidebar = setup_sidebar(&multi_workspace, cx);
 361
 362    save_n_test_threads(3, &project, cx).await;
 363
 364    let path_list = project.read_with(cx, |project, cx| {
 365        project.project_group_key(cx).path_list().clone()
 366    });
 367
 368    // Set a custom width, collapse the group, and expand "View More".
 369    sidebar.update_in(cx, |sidebar, window, cx| {
 370        sidebar.set_width(Some(px(420.0)), cx);
 371        sidebar.toggle_collapse(&path_list, window, cx);
 372        sidebar.expanded_groups.insert(path_list.clone(), 2);
 373    });
 374    cx.run_until_parked();
 375
 376    // Capture the serialized state from the first sidebar.
 377    let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
 378    let serialized = serialized.expect("serialized_state should return Some");
 379
 380    // Create a fresh sidebar and restore into it.
 381    let sidebar2 =
 382        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
 383    cx.run_until_parked();
 384
 385    sidebar2.update_in(cx, |sidebar, window, cx| {
 386        sidebar.restore_serialized_state(&serialized, window, cx);
 387    });
 388    cx.run_until_parked();
 389
 390    // Assert all serialized fields match.
 391    let (width1, collapsed1, expanded1) = sidebar.read_with(cx, |s, _| {
 392        (
 393            s.width,
 394            s.collapsed_groups.clone(),
 395            s.expanded_groups.clone(),
 396        )
 397    });
 398    let (width2, collapsed2, expanded2) = sidebar2.read_with(cx, |s, _| {
 399        (
 400            s.width,
 401            s.collapsed_groups.clone(),
 402            s.expanded_groups.clone(),
 403        )
 404    });
 405
 406    assert_eq!(width1, width2);
 407    assert_eq!(collapsed1, collapsed2);
 408    assert_eq!(expanded1, expanded2);
 409    assert_eq!(width1, px(420.0));
 410    assert!(collapsed1.contains(&path_list));
 411    assert_eq!(expanded1.get(&path_list), Some(&2));
 412}
 413
 414#[gpui::test]
 415async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
 416    // A regression test to ensure that restoring a serialized archive view does not panic.
 417    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 418    let (multi_workspace, cx) =
 419        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 420    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 421    cx.update(|_window, cx| {
 422        AgentRegistryStore::init_test_global(cx, vec![]);
 423    });
 424
 425    let serialized = serde_json::to_string(&SerializedSidebar {
 426        width: Some(400.0),
 427        collapsed_groups: Vec::new(),
 428        expanded_groups: Vec::new(),
 429        active_view: SerializedSidebarView::Archive,
 430    })
 431    .expect("serialization should succeed");
 432
 433    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
 434        if let Some(sidebar) = multi_workspace.sidebar() {
 435            sidebar.restore_serialized_state(&serialized, window, cx);
 436        }
 437    });
 438    cx.run_until_parked();
 439
 440    // After the deferred `show_archive` runs, the view should be Archive.
 441    sidebar.read_with(cx, |sidebar, _cx| {
 442        assert!(
 443            matches!(sidebar.view, SidebarView::Archive(_)),
 444            "expected sidebar view to be Archive after restore, got ThreadList"
 445        );
 446    });
 447}
 448
 449#[test]
 450fn test_clean_mention_links() {
 451    // Simple mention link
 452    assert_eq!(
 453        Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
 454        "check @Button.tsx"
 455    );
 456
 457    // Multiple mention links
 458    assert_eq!(
 459        Sidebar::clean_mention_links(
 460            "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
 461        ),
 462        "look at @foo.rs and @bar.rs"
 463    );
 464
 465    // No mention links — passthrough
 466    assert_eq!(
 467        Sidebar::clean_mention_links("plain text with no mentions"),
 468        "plain text with no mentions"
 469    );
 470
 471    // Incomplete link syntax — preserved as-is
 472    assert_eq!(
 473        Sidebar::clean_mention_links("broken [@mention without closing"),
 474        "broken [@mention without closing"
 475    );
 476
 477    // Regular markdown link (no @) — not touched
 478    assert_eq!(
 479        Sidebar::clean_mention_links("see [docs](https://example.com)"),
 480        "see [docs](https://example.com)"
 481    );
 482
 483    // Empty input
 484    assert_eq!(Sidebar::clean_mention_links(""), "");
 485}
 486
 487#[gpui::test]
 488async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
 489    let project = init_test_project("/my-project", cx).await;
 490    let (multi_workspace, cx) =
 491        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 492    let sidebar = setup_sidebar(&multi_workspace, cx);
 493
 494    let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
 495    let weak_sidebar = sidebar.downgrade();
 496    let weak_multi_workspace = multi_workspace.downgrade();
 497
 498    drop(sidebar);
 499    drop(multi_workspace);
 500    cx.update(|window, _cx| window.remove_window());
 501    cx.run_until_parked();
 502
 503    weak_multi_workspace.assert_released();
 504    weak_sidebar.assert_released();
 505    weak_workspace.assert_released();
 506}
 507
 508#[gpui::test]
 509async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
 510    let project = init_test_project("/my-project", cx).await;
 511    let (multi_workspace, cx) =
 512        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 513    let sidebar = setup_sidebar(&multi_workspace, cx);
 514
 515    assert_eq!(
 516        visible_entries_as_strings(&sidebar, cx),
 517        vec!["v [my-project]", "  [+ New Thread]"]
 518    );
 519}
 520
 521#[gpui::test]
 522async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
 523    let project = init_test_project("/my-project", cx).await;
 524    let (multi_workspace, cx) =
 525        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 526    let sidebar = setup_sidebar(&multi_workspace, cx);
 527
 528    save_thread_metadata(
 529        acp::SessionId::new(Arc::from("thread-1")),
 530        "Fix crash in project panel".into(),
 531        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
 532        None,
 533        &project,
 534        cx,
 535    );
 536
 537    save_thread_metadata(
 538        acp::SessionId::new(Arc::from("thread-2")),
 539        "Add inline diff view".into(),
 540        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 541        None,
 542        &project,
 543        cx,
 544    );
 545    cx.run_until_parked();
 546
 547    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 548    cx.run_until_parked();
 549
 550    assert_eq!(
 551        visible_entries_as_strings(&sidebar, cx),
 552        vec![
 553            "v [my-project]",
 554            "  Fix crash in project panel",
 555            "  Add inline diff view",
 556        ]
 557    );
 558}
 559
 560#[gpui::test]
 561async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
 562    let project = init_test_project("/project-a", cx).await;
 563    let (multi_workspace, cx) =
 564        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 565    let sidebar = setup_sidebar(&multi_workspace, cx);
 566
 567    // Single workspace with a thread
 568    save_thread_metadata(
 569        acp::SessionId::new(Arc::from("thread-a1")),
 570        "Thread A1".into(),
 571        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 572        None,
 573        &project,
 574        cx,
 575    );
 576    cx.run_until_parked();
 577
 578    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 579    cx.run_until_parked();
 580
 581    assert_eq!(
 582        visible_entries_as_strings(&sidebar, cx),
 583        vec!["v [project-a]", "  Thread A1"]
 584    );
 585
 586    // Add a second workspace
 587    multi_workspace.update_in(cx, |mw, window, cx| {
 588        mw.create_test_workspace(window, cx).detach();
 589    });
 590    cx.run_until_parked();
 591
 592    assert_eq!(
 593        visible_entries_as_strings(&sidebar, cx),
 594        vec!["v [project-a]", "  Thread A1",]
 595    );
 596
 597    // Remove the second workspace
 598    multi_workspace.update_in(cx, |mw, window, cx| {
 599        let workspace = mw.workspaces().nth(1).cloned().unwrap();
 600        mw.remove(&workspace, window, cx);
 601    });
 602    cx.run_until_parked();
 603
 604    assert_eq!(
 605        visible_entries_as_strings(&sidebar, cx),
 606        vec!["v [project-a]", "  Thread A1"]
 607    );
 608}
 609
 610#[gpui::test]
 611async fn test_view_more_pagination(cx: &mut TestAppContext) {
 612    let project = init_test_project("/my-project", cx).await;
 613    let (multi_workspace, cx) =
 614        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 615    let sidebar = setup_sidebar(&multi_workspace, cx);
 616
 617    save_n_test_threads(12, &project, cx).await;
 618
 619    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 620    cx.run_until_parked();
 621
 622    assert_eq!(
 623        visible_entries_as_strings(&sidebar, cx),
 624        vec![
 625            "v [my-project]",
 626            "  Thread 12",
 627            "  Thread 11",
 628            "  Thread 10",
 629            "  Thread 9",
 630            "  Thread 8",
 631            "  + View More",
 632        ]
 633    );
 634}
 635
 636#[gpui::test]
 637async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
 638    let project = init_test_project("/my-project", cx).await;
 639    let (multi_workspace, cx) =
 640        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 641    let sidebar = setup_sidebar(&multi_workspace, cx);
 642
 643    // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
 644    save_n_test_threads(17, &project, cx).await;
 645
 646    let path_list = project.read_with(cx, |project, cx| {
 647        project.project_group_key(cx).path_list().clone()
 648    });
 649
 650    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 651    cx.run_until_parked();
 652
 653    // Initially shows 5 threads + View More
 654    let entries = visible_entries_as_strings(&sidebar, cx);
 655    assert_eq!(entries.len(), 7); // header + 5 threads + View More
 656    assert!(entries.iter().any(|e| e.contains("View More")));
 657
 658    // Focus and navigate to View More, then confirm to expand by one batch
 659    focus_sidebar(&sidebar, cx);
 660    for _ in 0..7 {
 661        cx.dispatch_action(SelectNext);
 662    }
 663    cx.dispatch_action(Confirm);
 664    cx.run_until_parked();
 665
 666    // Now shows 10 threads + View More
 667    let entries = visible_entries_as_strings(&sidebar, cx);
 668    assert_eq!(entries.len(), 12); // header + 10 threads + View More
 669    assert!(entries.iter().any(|e| e.contains("View More")));
 670
 671    // Expand again by one batch
 672    sidebar.update_in(cx, |s, _window, cx| {
 673        let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
 674        s.expanded_groups.insert(path_list.clone(), current + 1);
 675        s.update_entries(cx);
 676    });
 677    cx.run_until_parked();
 678
 679    // Now shows 15 threads + View More
 680    let entries = visible_entries_as_strings(&sidebar, cx);
 681    assert_eq!(entries.len(), 17); // header + 15 threads + View More
 682    assert!(entries.iter().any(|e| e.contains("View More")));
 683
 684    // Expand one more time - should show all 17 threads with Collapse button
 685    sidebar.update_in(cx, |s, _window, cx| {
 686        let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
 687        s.expanded_groups.insert(path_list.clone(), current + 1);
 688        s.update_entries(cx);
 689    });
 690    cx.run_until_parked();
 691
 692    // All 17 threads shown with Collapse button
 693    let entries = visible_entries_as_strings(&sidebar, cx);
 694    assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
 695    assert!(!entries.iter().any(|e| e.contains("View More")));
 696    assert!(entries.iter().any(|e| e.contains("Collapse")));
 697
 698    // Click collapse - should go back to showing 5 threads
 699    sidebar.update_in(cx, |s, _window, cx| {
 700        s.expanded_groups.remove(&path_list);
 701        s.update_entries(cx);
 702    });
 703    cx.run_until_parked();
 704
 705    // Back to initial state: 5 threads + View More
 706    let entries = visible_entries_as_strings(&sidebar, cx);
 707    assert_eq!(entries.len(), 7); // header + 5 threads + View More
 708    assert!(entries.iter().any(|e| e.contains("View More")));
 709}
 710
 711#[gpui::test]
 712async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
 713    let project = init_test_project("/my-project", cx).await;
 714    let (multi_workspace, cx) =
 715        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 716    let sidebar = setup_sidebar(&multi_workspace, cx);
 717
 718    save_n_test_threads(1, &project, cx).await;
 719
 720    let path_list = project.read_with(cx, |project, cx| {
 721        project.project_group_key(cx).path_list().clone()
 722    });
 723
 724    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 725    cx.run_until_parked();
 726
 727    assert_eq!(
 728        visible_entries_as_strings(&sidebar, cx),
 729        vec!["v [my-project]", "  Thread 1"]
 730    );
 731
 732    // Collapse
 733    sidebar.update_in(cx, |s, window, cx| {
 734        s.toggle_collapse(&path_list, window, cx);
 735    });
 736    cx.run_until_parked();
 737
 738    assert_eq!(
 739        visible_entries_as_strings(&sidebar, cx),
 740        vec!["> [my-project]"]
 741    );
 742
 743    // Expand
 744    sidebar.update_in(cx, |s, window, cx| {
 745        s.toggle_collapse(&path_list, window, cx);
 746    });
 747    cx.run_until_parked();
 748
 749    assert_eq!(
 750        visible_entries_as_strings(&sidebar, cx),
 751        vec!["v [my-project]", "  Thread 1"]
 752    );
 753}
 754
 755#[gpui::test]
 756async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
 757    let project = init_test_project("/my-project", cx).await;
 758    let (multi_workspace, cx) =
 759        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 760    let sidebar = setup_sidebar(&multi_workspace, cx);
 761
 762    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 763    let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
 764    let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
 765
 766    sidebar.update_in(cx, |s, _window, _cx| {
 767        s.collapsed_groups.insert(collapsed_path.clone());
 768        s.contents
 769            .notified_threads
 770            .insert(acp::SessionId::new(Arc::from("t-5")));
 771        s.contents.entries = vec![
 772            // Expanded project header
 773            ListEntry::ProjectHeader {
 774                key: project::ProjectGroupKey::new(None, expanded_path.clone()),
 775                label: "expanded-project".into(),
 776                highlight_positions: Vec::new(),
 777                has_running_threads: false,
 778                waiting_thread_count: 0,
 779                is_active: true,
 780            },
 781            ListEntry::Thread(ThreadEntry {
 782                metadata: ThreadMetadata {
 783                    session_id: acp::SessionId::new(Arc::from("t-1")),
 784                    agent_id: AgentId::new("zed-agent"),
 785                    folder_paths: PathList::default(),
 786                    main_worktree_paths: PathList::default(),
 787                    title: "Completed thread".into(),
 788                    updated_at: Utc::now(),
 789                    created_at: Some(Utc::now()),
 790                    archived: false,
 791                },
 792                icon: IconName::ZedAgent,
 793                icon_from_external_svg: None,
 794                status: AgentThreadStatus::Completed,
 795                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 796                is_live: false,
 797                is_background: false,
 798                is_title_generating: false,
 799                highlight_positions: Vec::new(),
 800                worktrees: Vec::new(),
 801                diff_stats: DiffStats::default(),
 802            }),
 803            // Active thread with Running status
 804            ListEntry::Thread(ThreadEntry {
 805                metadata: ThreadMetadata {
 806                    session_id: acp::SessionId::new(Arc::from("t-2")),
 807                    agent_id: AgentId::new("zed-agent"),
 808                    folder_paths: PathList::default(),
 809                    main_worktree_paths: PathList::default(),
 810                    title: "Running thread".into(),
 811                    updated_at: Utc::now(),
 812                    created_at: Some(Utc::now()),
 813                    archived: false,
 814                },
 815                icon: IconName::ZedAgent,
 816                icon_from_external_svg: None,
 817                status: AgentThreadStatus::Running,
 818                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 819                is_live: true,
 820                is_background: false,
 821                is_title_generating: false,
 822                highlight_positions: Vec::new(),
 823                worktrees: Vec::new(),
 824                diff_stats: DiffStats::default(),
 825            }),
 826            // Active thread with Error status
 827            ListEntry::Thread(ThreadEntry {
 828                metadata: ThreadMetadata {
 829                    session_id: acp::SessionId::new(Arc::from("t-3")),
 830                    agent_id: AgentId::new("zed-agent"),
 831                    folder_paths: PathList::default(),
 832                    main_worktree_paths: PathList::default(),
 833                    title: "Error thread".into(),
 834                    updated_at: Utc::now(),
 835                    created_at: Some(Utc::now()),
 836                    archived: false,
 837                },
 838                icon: IconName::ZedAgent,
 839                icon_from_external_svg: None,
 840                status: AgentThreadStatus::Error,
 841                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 842                is_live: true,
 843                is_background: false,
 844                is_title_generating: false,
 845                highlight_positions: Vec::new(),
 846                worktrees: Vec::new(),
 847                diff_stats: DiffStats::default(),
 848            }),
 849            // Thread with WaitingForConfirmation status, not active
 850            ListEntry::Thread(ThreadEntry {
 851                metadata: ThreadMetadata {
 852                    session_id: acp::SessionId::new(Arc::from("t-4")),
 853                    agent_id: AgentId::new("zed-agent"),
 854                    folder_paths: PathList::default(),
 855                    main_worktree_paths: PathList::default(),
 856                    title: "Waiting thread".into(),
 857                    updated_at: Utc::now(),
 858                    created_at: Some(Utc::now()),
 859                    archived: false,
 860                },
 861                icon: IconName::ZedAgent,
 862                icon_from_external_svg: None,
 863                status: AgentThreadStatus::WaitingForConfirmation,
 864                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 865                is_live: false,
 866                is_background: false,
 867                is_title_generating: false,
 868                highlight_positions: Vec::new(),
 869                worktrees: Vec::new(),
 870                diff_stats: DiffStats::default(),
 871            }),
 872            // Background thread that completed (should show notification)
 873            ListEntry::Thread(ThreadEntry {
 874                metadata: ThreadMetadata {
 875                    session_id: acp::SessionId::new(Arc::from("t-5")),
 876                    agent_id: AgentId::new("zed-agent"),
 877                    folder_paths: PathList::default(),
 878                    main_worktree_paths: PathList::default(),
 879                    title: "Notified thread".into(),
 880                    updated_at: Utc::now(),
 881                    created_at: Some(Utc::now()),
 882                    archived: false,
 883                },
 884                icon: IconName::ZedAgent,
 885                icon_from_external_svg: None,
 886                status: AgentThreadStatus::Completed,
 887                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 888                is_live: true,
 889                is_background: true,
 890                is_title_generating: false,
 891                highlight_positions: Vec::new(),
 892                worktrees: Vec::new(),
 893                diff_stats: DiffStats::default(),
 894            }),
 895            // View More entry
 896            ListEntry::ViewMore {
 897                key: project::ProjectGroupKey::new(None, expanded_path.clone()),
 898                is_fully_expanded: false,
 899            },
 900            // Collapsed project header
 901            ListEntry::ProjectHeader {
 902                key: project::ProjectGroupKey::new(None, collapsed_path.clone()),
 903                label: "collapsed-project".into(),
 904                highlight_positions: Vec::new(),
 905                has_running_threads: false,
 906                waiting_thread_count: 0,
 907                is_active: false,
 908            },
 909        ];
 910
 911        // Select the Running thread (index 2)
 912        s.selection = Some(2);
 913    });
 914
 915    assert_eq!(
 916        visible_entries_as_strings(&sidebar, cx),
 917        vec![
 918            "v [expanded-project]",
 919            "  Completed thread",
 920            "  Running thread * (running)  <== selected",
 921            "  Error thread * (error)",
 922            "  Waiting thread (waiting)",
 923            "  Notified thread * (!)",
 924            "  + View More",
 925            "> [collapsed-project]",
 926        ]
 927    );
 928
 929    // Move selection to the collapsed header
 930    sidebar.update_in(cx, |s, _window, _cx| {
 931        s.selection = Some(7);
 932    });
 933
 934    assert_eq!(
 935        visible_entries_as_strings(&sidebar, cx).last().cloned(),
 936        Some("> [collapsed-project]  <== selected".to_string()),
 937    );
 938
 939    // Clear selection
 940    sidebar.update_in(cx, |s, _window, _cx| {
 941        s.selection = None;
 942    });
 943
 944    // No entry should have the selected marker
 945    let entries = visible_entries_as_strings(&sidebar, cx);
 946    for entry in &entries {
 947        assert!(
 948            !entry.contains("<== selected"),
 949            "unexpected selection marker in: {}",
 950            entry
 951        );
 952    }
 953}
 954
 955#[gpui::test]
 956async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
 957    let project = init_test_project("/my-project", cx).await;
 958    let (multi_workspace, cx) =
 959        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 960    let sidebar = setup_sidebar(&multi_workspace, cx);
 961
 962    save_n_test_threads(3, &project, cx).await;
 963
 964    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 965    cx.run_until_parked();
 966
 967    // Entries: [header, thread3, thread2, thread1]
 968    // Focusing the sidebar does not set a selection; select_next/select_previous
 969    // handle None gracefully by starting from the first or last entry.
 970    focus_sidebar(&sidebar, cx);
 971    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 972
 973    // First SelectNext from None starts at index 0
 974    cx.dispatch_action(SelectNext);
 975    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 976
 977    // Move down through remaining entries
 978    cx.dispatch_action(SelectNext);
 979    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 980
 981    cx.dispatch_action(SelectNext);
 982    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 983
 984    cx.dispatch_action(SelectNext);
 985    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 986
 987    // At the end, wraps back to first entry
 988    cx.dispatch_action(SelectNext);
 989    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 990
 991    // Navigate back to the end
 992    cx.dispatch_action(SelectNext);
 993    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 994    cx.dispatch_action(SelectNext);
 995    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 996    cx.dispatch_action(SelectNext);
 997    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 998
 999    // Move back up
1000    cx.dispatch_action(SelectPrevious);
1001    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1002
1003    cx.dispatch_action(SelectPrevious);
1004    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1005
1006    cx.dispatch_action(SelectPrevious);
1007    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1008
1009    // At the top, selection clears (focus returns to editor)
1010    cx.dispatch_action(SelectPrevious);
1011    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1012}
1013
1014#[gpui::test]
1015async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
1016    let project = init_test_project("/my-project", cx).await;
1017    let (multi_workspace, cx) =
1018        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1019    let sidebar = setup_sidebar(&multi_workspace, cx);
1020
1021    save_n_test_threads(3, &project, cx).await;
1022    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1023    cx.run_until_parked();
1024
1025    focus_sidebar(&sidebar, cx);
1026
1027    // SelectLast jumps to the end
1028    cx.dispatch_action(SelectLast);
1029    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1030
1031    // SelectFirst jumps to the beginning
1032    cx.dispatch_action(SelectFirst);
1033    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1034}
1035
1036#[gpui::test]
1037async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
1038    let project = init_test_project("/my-project", cx).await;
1039    let (multi_workspace, cx) =
1040        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1041    let sidebar = setup_sidebar(&multi_workspace, cx);
1042
1043    // Initially no selection
1044    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1045
1046    // Open the sidebar so it's rendered, then focus it to trigger focus_in.
1047    // focus_in no longer sets a default selection.
1048    focus_sidebar(&sidebar, cx);
1049    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1050
1051    // Manually set a selection, blur, then refocus — selection should be preserved
1052    sidebar.update_in(cx, |sidebar, _window, _cx| {
1053        sidebar.selection = Some(0);
1054    });
1055
1056    cx.update(|window, _cx| {
1057        window.blur();
1058    });
1059    cx.run_until_parked();
1060
1061    sidebar.update_in(cx, |_, window, cx| {
1062        cx.focus_self(window);
1063    });
1064    cx.run_until_parked();
1065    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1066}
1067
1068#[gpui::test]
1069async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
1070    let project = init_test_project("/my-project", cx).await;
1071    let (multi_workspace, cx) =
1072        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1073    let sidebar = setup_sidebar(&multi_workspace, cx);
1074
1075    save_n_test_threads(1, &project, cx).await;
1076    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1077    cx.run_until_parked();
1078
1079    assert_eq!(
1080        visible_entries_as_strings(&sidebar, cx),
1081        vec!["v [my-project]", "  Thread 1"]
1082    );
1083
1084    // Focus the sidebar and select the header (index 0)
1085    focus_sidebar(&sidebar, cx);
1086    sidebar.update_in(cx, |sidebar, _window, _cx| {
1087        sidebar.selection = Some(0);
1088    });
1089
1090    // Confirm on project header collapses the group
1091    cx.dispatch_action(Confirm);
1092    cx.run_until_parked();
1093
1094    assert_eq!(
1095        visible_entries_as_strings(&sidebar, cx),
1096        vec!["> [my-project]  <== selected"]
1097    );
1098
1099    // Confirm again expands the group
1100    cx.dispatch_action(Confirm);
1101    cx.run_until_parked();
1102
1103    assert_eq!(
1104        visible_entries_as_strings(&sidebar, cx),
1105        vec!["v [my-project]  <== selected", "  Thread 1",]
1106    );
1107}
1108
1109#[gpui::test]
1110async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
1111    let project = init_test_project("/my-project", cx).await;
1112    let (multi_workspace, cx) =
1113        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1114    let sidebar = setup_sidebar(&multi_workspace, cx);
1115
1116    save_n_test_threads(8, &project, cx).await;
1117    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1118    cx.run_until_parked();
1119
1120    // Should show header + 5 threads + "View More"
1121    let entries = visible_entries_as_strings(&sidebar, cx);
1122    assert_eq!(entries.len(), 7);
1123    assert!(entries.iter().any(|e| e.contains("View More")));
1124
1125    // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
1126    focus_sidebar(&sidebar, cx);
1127    for _ in 0..7 {
1128        cx.dispatch_action(SelectNext);
1129    }
1130    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
1131
1132    // Confirm on "View More" to expand
1133    cx.dispatch_action(Confirm);
1134    cx.run_until_parked();
1135
1136    // All 8 threads should now be visible with a "Collapse" button
1137    let entries = visible_entries_as_strings(&sidebar, cx);
1138    assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
1139    assert!(!entries.iter().any(|e| e.contains("View More")));
1140    assert!(entries.iter().any(|e| e.contains("Collapse")));
1141}
1142
1143#[gpui::test]
1144async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
1145    let project = init_test_project("/my-project", cx).await;
1146    let (multi_workspace, cx) =
1147        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1148    let sidebar = setup_sidebar(&multi_workspace, cx);
1149
1150    save_n_test_threads(1, &project, cx).await;
1151    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1152    cx.run_until_parked();
1153
1154    assert_eq!(
1155        visible_entries_as_strings(&sidebar, cx),
1156        vec!["v [my-project]", "  Thread 1"]
1157    );
1158
1159    // Focus sidebar and manually select the header (index 0). Press left to collapse.
1160    focus_sidebar(&sidebar, cx);
1161    sidebar.update_in(cx, |sidebar, _window, _cx| {
1162        sidebar.selection = Some(0);
1163    });
1164
1165    cx.dispatch_action(SelectParent);
1166    cx.run_until_parked();
1167
1168    assert_eq!(
1169        visible_entries_as_strings(&sidebar, cx),
1170        vec!["> [my-project]  <== selected"]
1171    );
1172
1173    // Press right to expand
1174    cx.dispatch_action(SelectChild);
1175    cx.run_until_parked();
1176
1177    assert_eq!(
1178        visible_entries_as_strings(&sidebar, cx),
1179        vec!["v [my-project]  <== selected", "  Thread 1",]
1180    );
1181
1182    // Press right again on already-expanded header moves selection down
1183    cx.dispatch_action(SelectChild);
1184    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1185}
1186
1187#[gpui::test]
1188async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
1189    let project = init_test_project("/my-project", cx).await;
1190    let (multi_workspace, cx) =
1191        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1192    let sidebar = setup_sidebar(&multi_workspace, cx);
1193
1194    save_n_test_threads(1, &project, cx).await;
1195    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1196    cx.run_until_parked();
1197
1198    // Focus sidebar (selection starts at None), then navigate down to the thread (child)
1199    focus_sidebar(&sidebar, cx);
1200    cx.dispatch_action(SelectNext);
1201    cx.dispatch_action(SelectNext);
1202    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1203
1204    assert_eq!(
1205        visible_entries_as_strings(&sidebar, cx),
1206        vec!["v [my-project]", "  Thread 1  <== selected",]
1207    );
1208
1209    // Pressing left on a child collapses the parent group and selects it
1210    cx.dispatch_action(SelectParent);
1211    cx.run_until_parked();
1212
1213    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1214    assert_eq!(
1215        visible_entries_as_strings(&sidebar, cx),
1216        vec!["> [my-project]  <== selected"]
1217    );
1218}
1219
1220#[gpui::test]
1221async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
1222    let project = init_test_project("/empty-project", cx).await;
1223    let (multi_workspace, cx) =
1224        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1225    let sidebar = setup_sidebar(&multi_workspace, cx);
1226
1227    // An empty project has the header and a new thread button.
1228    assert_eq!(
1229        visible_entries_as_strings(&sidebar, cx),
1230        vec!["v [empty-project]", "  [+ New Thread]"]
1231    );
1232
1233    // Focus sidebar — focus_in does not set a selection
1234    focus_sidebar(&sidebar, cx);
1235    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1236
1237    // First SelectNext from None starts at index 0 (header)
1238    cx.dispatch_action(SelectNext);
1239    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1240
1241    // SelectNext moves to the new thread button
1242    cx.dispatch_action(SelectNext);
1243    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1244
1245    // At the end, wraps back to first entry
1246    cx.dispatch_action(SelectNext);
1247    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1248
1249    // SelectPrevious from first entry clears selection (returns to editor)
1250    cx.dispatch_action(SelectPrevious);
1251    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1252}
1253
1254#[gpui::test]
1255async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
1256    let project = init_test_project("/my-project", cx).await;
1257    let (multi_workspace, cx) =
1258        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1259    let sidebar = setup_sidebar(&multi_workspace, cx);
1260
1261    save_n_test_threads(1, &project, cx).await;
1262    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1263    cx.run_until_parked();
1264
1265    // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
1266    focus_sidebar(&sidebar, cx);
1267    cx.dispatch_action(SelectNext);
1268    cx.dispatch_action(SelectNext);
1269    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1270
1271    // Collapse the group, which removes the thread from the list
1272    cx.dispatch_action(SelectParent);
1273    cx.run_until_parked();
1274
1275    // Selection should be clamped to the last valid index (0 = header)
1276    let selection = sidebar.read_with(cx, |s, _| s.selection);
1277    let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
1278    assert!(
1279        selection.unwrap_or(0) < entry_count,
1280        "selection {} should be within bounds (entries: {})",
1281        selection.unwrap_or(0),
1282        entry_count,
1283    );
1284}
1285
1286async fn init_test_project_with_agent_panel(
1287    worktree_path: &str,
1288    cx: &mut TestAppContext,
1289) -> Entity<project::Project> {
1290    agent_ui::test_support::init_test(cx);
1291    cx.update(|cx| {
1292        cx.update_flags(false, vec!["agent-v2".into()]);
1293        ThreadStore::init_global(cx);
1294        ThreadMetadataStore::init_global(cx);
1295        language_model::LanguageModelRegistry::test(cx);
1296        prompt_store::init(cx);
1297    });
1298
1299    let fs = FakeFs::new(cx.executor());
1300    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1301        .await;
1302    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1303    project::Project::test(fs, [worktree_path.as_ref()], cx).await
1304}
1305
1306fn add_agent_panel(
1307    workspace: &Entity<Workspace>,
1308    cx: &mut gpui::VisualTestContext,
1309) -> Entity<AgentPanel> {
1310    workspace.update_in(cx, |workspace, window, cx| {
1311        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
1312        workspace.add_panel(panel.clone(), window, cx);
1313        panel
1314    })
1315}
1316
1317fn setup_sidebar_with_agent_panel(
1318    multi_workspace: &Entity<MultiWorkspace>,
1319    cx: &mut gpui::VisualTestContext,
1320) -> (Entity<Sidebar>, Entity<AgentPanel>) {
1321    let sidebar = setup_sidebar(multi_workspace, cx);
1322    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
1323    let panel = add_agent_panel(&workspace, cx);
1324    (sidebar, panel)
1325}
1326
1327#[gpui::test]
1328async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
1329    let project = init_test_project_with_agent_panel("/my-project", cx).await;
1330    let (multi_workspace, cx) =
1331        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1332    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1333
1334    // Open thread A and keep it generating.
1335    let connection = StubAgentConnection::new();
1336    open_thread_with_connection(&panel, connection.clone(), cx);
1337    send_message(&panel, cx);
1338
1339    let session_id_a = active_session_id(&panel, cx);
1340    save_test_thread_metadata(&session_id_a, &project, cx).await;
1341
1342    cx.update(|_, cx| {
1343        connection.send_update(
1344            session_id_a.clone(),
1345            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
1346            cx,
1347        );
1348    });
1349    cx.run_until_parked();
1350
1351    // Open thread B (idle, default response) — thread A goes to background.
1352    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
1353        acp::ContentChunk::new("Done".into()),
1354    )]);
1355    open_thread_with_connection(&panel, connection, cx);
1356    send_message(&panel, cx);
1357
1358    let session_id_b = active_session_id(&panel, cx);
1359    save_test_thread_metadata(&session_id_b, &project, cx).await;
1360
1361    cx.run_until_parked();
1362
1363    let mut entries = visible_entries_as_strings(&sidebar, cx);
1364    entries[1..].sort();
1365    assert_eq!(
1366        entries,
1367        vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
1368    );
1369}
1370
1371#[gpui::test]
1372async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
1373    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
1374    let (multi_workspace, cx) =
1375        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1376    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
1377
1378    // Open thread on workspace A and keep it generating.
1379    let connection_a = StubAgentConnection::new();
1380    open_thread_with_connection(&panel_a, connection_a.clone(), cx);
1381    send_message(&panel_a, cx);
1382
1383    let session_id_a = active_session_id(&panel_a, cx);
1384    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
1385
1386    cx.update(|_, cx| {
1387        connection_a.send_update(
1388            session_id_a.clone(),
1389            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
1390            cx,
1391        );
1392    });
1393    cx.run_until_parked();
1394
1395    // Add a second workspace and activate it (making workspace A the background).
1396    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
1397    let project_b = project::Project::test(fs, [], cx).await;
1398    multi_workspace.update_in(cx, |mw, window, cx| {
1399        mw.test_add_workspace(project_b, window, cx);
1400    });
1401    cx.run_until_parked();
1402
1403    // Thread A is still running; no notification yet.
1404    assert_eq!(
1405        visible_entries_as_strings(&sidebar, cx),
1406        vec!["v [project-a]", "  Hello * (running)",]
1407    );
1408
1409    // Complete thread A's turn (transition Running → Completed).
1410    connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
1411    cx.run_until_parked();
1412
1413    // The completed background thread shows a notification indicator.
1414    assert_eq!(
1415        visible_entries_as_strings(&sidebar, cx),
1416        vec!["v [project-a]", "  Hello * (!)",]
1417    );
1418}
1419
1420fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
1421    sidebar.update_in(cx, |sidebar, window, cx| {
1422        window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
1423        sidebar.filter_editor.update(cx, |editor, cx| {
1424            editor.set_text(query, window, cx);
1425        });
1426    });
1427    cx.run_until_parked();
1428}
1429
1430#[gpui::test]
1431async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
1432    let project = init_test_project("/my-project", cx).await;
1433    let (multi_workspace, cx) =
1434        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1435    let sidebar = setup_sidebar(&multi_workspace, cx);
1436
1437    for (id, title, hour) in [
1438        ("t-1", "Fix crash in project panel", 3),
1439        ("t-2", "Add inline diff view", 2),
1440        ("t-3", "Refactor settings module", 1),
1441    ] {
1442        save_thread_metadata(
1443            acp::SessionId::new(Arc::from(id)),
1444            title.into(),
1445            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1446            None,
1447            &project,
1448            cx,
1449        );
1450    }
1451    cx.run_until_parked();
1452
1453    assert_eq!(
1454        visible_entries_as_strings(&sidebar, cx),
1455        vec![
1456            "v [my-project]",
1457            "  Fix crash in project panel",
1458            "  Add inline diff view",
1459            "  Refactor settings module",
1460        ]
1461    );
1462
1463    // User types "diff" in the search box — only the matching thread remains,
1464    // with its workspace header preserved for context.
1465    type_in_search(&sidebar, "diff", cx);
1466    assert_eq!(
1467        visible_entries_as_strings(&sidebar, cx),
1468        vec!["v [my-project]", "  Add inline diff view  <== selected",]
1469    );
1470
1471    // User changes query to something with no matches — list is empty.
1472    type_in_search(&sidebar, "nonexistent", cx);
1473    assert_eq!(
1474        visible_entries_as_strings(&sidebar, cx),
1475        Vec::<String>::new()
1476    );
1477}
1478
1479#[gpui::test]
1480async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
1481    // Scenario: A user remembers a thread title but not the exact casing.
1482    // Search should match case-insensitively so they can still find it.
1483    let project = init_test_project("/my-project", cx).await;
1484    let (multi_workspace, cx) =
1485        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1486    let sidebar = setup_sidebar(&multi_workspace, cx);
1487
1488    save_thread_metadata(
1489        acp::SessionId::new(Arc::from("thread-1")),
1490        "Fix Crash In Project Panel".into(),
1491        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1492        None,
1493        &project,
1494        cx,
1495    );
1496    cx.run_until_parked();
1497
1498    // Lowercase query matches mixed-case title.
1499    type_in_search(&sidebar, "fix crash", cx);
1500    assert_eq!(
1501        visible_entries_as_strings(&sidebar, cx),
1502        vec![
1503            "v [my-project]",
1504            "  Fix Crash In Project Panel  <== selected",
1505        ]
1506    );
1507
1508    // Uppercase query also matches the same title.
1509    type_in_search(&sidebar, "FIX CRASH", cx);
1510    assert_eq!(
1511        visible_entries_as_strings(&sidebar, cx),
1512        vec![
1513            "v [my-project]",
1514            "  Fix Crash In Project Panel  <== selected",
1515        ]
1516    );
1517}
1518
1519#[gpui::test]
1520async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
1521    // Scenario: A user searches, finds what they need, then presses Escape
1522    // to dismiss the filter and see the full list again.
1523    let project = init_test_project("/my-project", cx).await;
1524    let (multi_workspace, cx) =
1525        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1526    let sidebar = setup_sidebar(&multi_workspace, cx);
1527
1528    for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
1529        save_thread_metadata(
1530            acp::SessionId::new(Arc::from(id)),
1531            title.into(),
1532            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1533            None,
1534            &project,
1535            cx,
1536        )
1537    }
1538    cx.run_until_parked();
1539
1540    // Confirm the full list is showing.
1541    assert_eq!(
1542        visible_entries_as_strings(&sidebar, cx),
1543        vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
1544    );
1545
1546    // User types a search query to filter down.
1547    focus_sidebar(&sidebar, cx);
1548    type_in_search(&sidebar, "alpha", cx);
1549    assert_eq!(
1550        visible_entries_as_strings(&sidebar, cx),
1551        vec!["v [my-project]", "  Alpha thread  <== selected",]
1552    );
1553
1554    // User presses Escape — filter clears, full list is restored.
1555    // The selection index (1) now points at the first thread entry.
1556    cx.dispatch_action(Cancel);
1557    cx.run_until_parked();
1558    assert_eq!(
1559        visible_entries_as_strings(&sidebar, cx),
1560        vec![
1561            "v [my-project]",
1562            "  Alpha thread  <== selected",
1563            "  Beta thread",
1564        ]
1565    );
1566}
1567
1568#[gpui::test]
1569async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
1570    let project_a = init_test_project("/project-a", cx).await;
1571    let (multi_workspace, cx) =
1572        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1573    let sidebar = setup_sidebar(&multi_workspace, cx);
1574
1575    for (id, title, hour) in [
1576        ("a1", "Fix bug in sidebar", 2),
1577        ("a2", "Add tests for editor", 1),
1578    ] {
1579        save_thread_metadata(
1580            acp::SessionId::new(Arc::from(id)),
1581            title.into(),
1582            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1583            None,
1584            &project_a,
1585            cx,
1586        )
1587    }
1588
1589    // Add a second workspace.
1590    multi_workspace.update_in(cx, |mw, window, cx| {
1591        mw.create_test_workspace(window, cx).detach();
1592    });
1593    cx.run_until_parked();
1594
1595    let project_b = multi_workspace.read_with(cx, |mw, cx| {
1596        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1597    });
1598
1599    for (id, title, hour) in [
1600        ("b1", "Refactor sidebar layout", 3),
1601        ("b2", "Fix typo in README", 1),
1602    ] {
1603        save_thread_metadata(
1604            acp::SessionId::new(Arc::from(id)),
1605            title.into(),
1606            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1607            None,
1608            &project_b,
1609            cx,
1610        )
1611    }
1612    cx.run_until_parked();
1613
1614    assert_eq!(
1615        visible_entries_as_strings(&sidebar, cx),
1616        vec![
1617            "v [project-a]",
1618            "  Fix bug in sidebar",
1619            "  Add tests for editor",
1620        ]
1621    );
1622
1623    // "sidebar" matches a thread in each workspace — both headers stay visible.
1624    type_in_search(&sidebar, "sidebar", cx);
1625    assert_eq!(
1626        visible_entries_as_strings(&sidebar, cx),
1627        vec!["v [project-a]", "  Fix bug in sidebar  <== selected",]
1628    );
1629
1630    // "typo" only matches in the second workspace — the first header disappears.
1631    type_in_search(&sidebar, "typo", cx);
1632    assert_eq!(
1633        visible_entries_as_strings(&sidebar, cx),
1634        Vec::<String>::new()
1635    );
1636
1637    // "project-a" matches the first workspace name — the header appears
1638    // with all child threads included.
1639    type_in_search(&sidebar, "project-a", cx);
1640    assert_eq!(
1641        visible_entries_as_strings(&sidebar, cx),
1642        vec![
1643            "v [project-a]",
1644            "  Fix bug in sidebar  <== selected",
1645            "  Add tests for editor",
1646        ]
1647    );
1648}
1649
1650#[gpui::test]
1651async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
1652    let project_a = init_test_project("/alpha-project", cx).await;
1653    let (multi_workspace, cx) =
1654        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1655    let sidebar = setup_sidebar(&multi_workspace, cx);
1656
1657    for (id, title, hour) in [
1658        ("a1", "Fix bug in sidebar", 2),
1659        ("a2", "Add tests for editor", 1),
1660    ] {
1661        save_thread_metadata(
1662            acp::SessionId::new(Arc::from(id)),
1663            title.into(),
1664            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1665            None,
1666            &project_a,
1667            cx,
1668        )
1669    }
1670
1671    // Add a second workspace.
1672    multi_workspace.update_in(cx, |mw, window, cx| {
1673        mw.create_test_workspace(window, cx).detach();
1674    });
1675    cx.run_until_parked();
1676
1677    let project_b = multi_workspace.read_with(cx, |mw, cx| {
1678        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
1679    });
1680
1681    for (id, title, hour) in [
1682        ("b1", "Refactor sidebar layout", 3),
1683        ("b2", "Fix typo in README", 1),
1684    ] {
1685        save_thread_metadata(
1686            acp::SessionId::new(Arc::from(id)),
1687            title.into(),
1688            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1689            None,
1690            &project_b,
1691            cx,
1692        )
1693    }
1694    cx.run_until_parked();
1695
1696    // "alpha" matches the workspace name "alpha-project" but no thread titles.
1697    // The workspace header should appear with all child threads included.
1698    type_in_search(&sidebar, "alpha", cx);
1699    assert_eq!(
1700        visible_entries_as_strings(&sidebar, cx),
1701        vec![
1702            "v [alpha-project]",
1703            "  Fix bug in sidebar  <== selected",
1704            "  Add tests for editor",
1705        ]
1706    );
1707
1708    // "sidebar" matches thread titles in both workspaces but not workspace names.
1709    // Both headers appear with their matching threads.
1710    type_in_search(&sidebar, "sidebar", cx);
1711    assert_eq!(
1712        visible_entries_as_strings(&sidebar, cx),
1713        vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
1714    );
1715
1716    // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
1717    // doesn't match) — but does not match either workspace name or any thread.
1718    // Actually let's test something simpler: a query that matches both a workspace
1719    // name AND some threads in that workspace. Matching threads should still appear.
1720    type_in_search(&sidebar, "fix", cx);
1721    assert_eq!(
1722        visible_entries_as_strings(&sidebar, cx),
1723        vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
1724    );
1725
1726    // A query that matches a workspace name AND a thread in that same workspace.
1727    // Both the header (highlighted) and all child threads should appear.
1728    type_in_search(&sidebar, "alpha", cx);
1729    assert_eq!(
1730        visible_entries_as_strings(&sidebar, cx),
1731        vec![
1732            "v [alpha-project]",
1733            "  Fix bug in sidebar  <== selected",
1734            "  Add tests for editor",
1735        ]
1736    );
1737
1738    // Now search for something that matches only a workspace name when there
1739    // are also threads with matching titles — the non-matching workspace's
1740    // threads should still appear if their titles match.
1741    type_in_search(&sidebar, "alp", cx);
1742    assert_eq!(
1743        visible_entries_as_strings(&sidebar, cx),
1744        vec![
1745            "v [alpha-project]",
1746            "  Fix bug in sidebar  <== selected",
1747            "  Add tests for editor",
1748        ]
1749    );
1750}
1751
1752#[gpui::test]
1753async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
1754    let project = init_test_project("/my-project", cx).await;
1755    let (multi_workspace, cx) =
1756        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1757    let sidebar = setup_sidebar(&multi_workspace, cx);
1758
1759    // Create 8 threads. The oldest one has a unique name and will be
1760    // behind View More (only 5 shown by default).
1761    for i in 0..8u32 {
1762        let title = if i == 0 {
1763            "Hidden gem thread".to_string()
1764        } else {
1765            format!("Thread {}", i + 1)
1766        };
1767        save_thread_metadata(
1768            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1769            title.into(),
1770            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1771            None,
1772            &project,
1773            cx,
1774        )
1775    }
1776    cx.run_until_parked();
1777
1778    // Confirm the thread is not visible and View More is shown.
1779    let entries = visible_entries_as_strings(&sidebar, cx);
1780    assert!(
1781        entries.iter().any(|e| e.contains("View More")),
1782        "should have View More button"
1783    );
1784    assert!(
1785        !entries.iter().any(|e| e.contains("Hidden gem")),
1786        "Hidden gem should be behind View More"
1787    );
1788
1789    // User searches for the hidden thread — it appears, and View More is gone.
1790    type_in_search(&sidebar, "hidden gem", cx);
1791    let filtered = visible_entries_as_strings(&sidebar, cx);
1792    assert_eq!(
1793        filtered,
1794        vec!["v [my-project]", "  Hidden gem thread  <== selected",]
1795    );
1796    assert!(
1797        !filtered.iter().any(|e| e.contains("View More")),
1798        "View More should not appear when filtering"
1799    );
1800}
1801
1802#[gpui::test]
1803async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
1804    let project = init_test_project("/my-project", cx).await;
1805    let (multi_workspace, cx) =
1806        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1807    let sidebar = setup_sidebar(&multi_workspace, cx);
1808
1809    save_thread_metadata(
1810        acp::SessionId::new(Arc::from("thread-1")),
1811        "Important thread".into(),
1812        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1813        None,
1814        &project,
1815        cx,
1816    );
1817    cx.run_until_parked();
1818
1819    // User focuses the sidebar and collapses the group using keyboard:
1820    // manually select the header, then press SelectParent to collapse.
1821    focus_sidebar(&sidebar, cx);
1822    sidebar.update_in(cx, |sidebar, _window, _cx| {
1823        sidebar.selection = Some(0);
1824    });
1825    cx.dispatch_action(SelectParent);
1826    cx.run_until_parked();
1827
1828    assert_eq!(
1829        visible_entries_as_strings(&sidebar, cx),
1830        vec!["> [my-project]  <== selected"]
1831    );
1832
1833    // User types a search — the thread appears even though its group is collapsed.
1834    type_in_search(&sidebar, "important", cx);
1835    assert_eq!(
1836        visible_entries_as_strings(&sidebar, cx),
1837        vec!["> [my-project]", "  Important thread  <== selected",]
1838    );
1839}
1840
1841#[gpui::test]
1842async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
1843    let project = init_test_project("/my-project", cx).await;
1844    let (multi_workspace, cx) =
1845        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1846    let sidebar = setup_sidebar(&multi_workspace, cx);
1847
1848    for (id, title, hour) in [
1849        ("t-1", "Fix crash in panel", 3),
1850        ("t-2", "Fix lint warnings", 2),
1851        ("t-3", "Add new feature", 1),
1852    ] {
1853        save_thread_metadata(
1854            acp::SessionId::new(Arc::from(id)),
1855            title.into(),
1856            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
1857            None,
1858            &project,
1859            cx,
1860        )
1861    }
1862    cx.run_until_parked();
1863
1864    focus_sidebar(&sidebar, cx);
1865
1866    // User types "fix" — two threads match.
1867    type_in_search(&sidebar, "fix", cx);
1868    assert_eq!(
1869        visible_entries_as_strings(&sidebar, cx),
1870        vec![
1871            "v [my-project]",
1872            "  Fix crash in panel  <== selected",
1873            "  Fix lint warnings",
1874        ]
1875    );
1876
1877    // Selection starts on the first matching thread. User presses
1878    // SelectNext to move to the second match.
1879    cx.dispatch_action(SelectNext);
1880    assert_eq!(
1881        visible_entries_as_strings(&sidebar, cx),
1882        vec![
1883            "v [my-project]",
1884            "  Fix crash in panel",
1885            "  Fix lint warnings  <== selected",
1886        ]
1887    );
1888
1889    // User can also jump back with SelectPrevious.
1890    cx.dispatch_action(SelectPrevious);
1891    assert_eq!(
1892        visible_entries_as_strings(&sidebar, cx),
1893        vec![
1894            "v [my-project]",
1895            "  Fix crash in panel  <== selected",
1896            "  Fix lint warnings",
1897        ]
1898    );
1899}
1900
1901#[gpui::test]
1902async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
1903    let project = init_test_project("/my-project", cx).await;
1904    let (multi_workspace, cx) =
1905        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1906    let sidebar = setup_sidebar(&multi_workspace, cx);
1907
1908    multi_workspace.update_in(cx, |mw, window, cx| {
1909        mw.create_test_workspace(window, cx).detach();
1910    });
1911    cx.run_until_parked();
1912
1913    let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
1914        (
1915            mw.workspaces().next().unwrap().clone(),
1916            mw.workspaces().nth(1).unwrap().clone(),
1917        )
1918    });
1919
1920    save_thread_metadata(
1921        acp::SessionId::new(Arc::from("hist-1")),
1922        "Historical Thread".into(),
1923        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
1924        None,
1925        &project,
1926        cx,
1927    );
1928    cx.run_until_parked();
1929    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1930    cx.run_until_parked();
1931
1932    assert_eq!(
1933        visible_entries_as_strings(&sidebar, cx),
1934        vec!["v [my-project]", "  Historical Thread",]
1935    );
1936
1937    // Switch to workspace 1 so we can verify the confirm switches back.
1938    multi_workspace.update_in(cx, |mw, window, cx| {
1939        let workspace = mw.workspaces().nth(1).unwrap().clone();
1940        mw.activate(workspace, window, cx);
1941    });
1942    cx.run_until_parked();
1943    assert_eq!(
1944        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
1945        workspace_1
1946    );
1947
1948    // Confirm on the historical (non-live) thread at index 1.
1949    // Before a previous fix, the workspace field was Option<usize> and
1950    // historical threads had None, so activate_thread early-returned
1951    // without switching the workspace.
1952    sidebar.update_in(cx, |sidebar, window, cx| {
1953        sidebar.selection = Some(1);
1954        sidebar.confirm(&Confirm, window, cx);
1955    });
1956    cx.run_until_parked();
1957
1958    assert_eq!(
1959        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
1960        workspace_0
1961    );
1962}
1963
1964#[gpui::test]
1965async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
1966    let project = init_test_project("/my-project", cx).await;
1967    let (multi_workspace, cx) =
1968        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1969    let sidebar = setup_sidebar(&multi_workspace, cx);
1970
1971    save_thread_metadata(
1972        acp::SessionId::new(Arc::from("t-1")),
1973        "Thread A".into(),
1974        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
1975        None,
1976        &project,
1977        cx,
1978    );
1979
1980    save_thread_metadata(
1981        acp::SessionId::new(Arc::from("t-2")),
1982        "Thread B".into(),
1983        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1984        None,
1985        &project,
1986        cx,
1987    );
1988
1989    cx.run_until_parked();
1990    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1991    cx.run_until_parked();
1992
1993    assert_eq!(
1994        visible_entries_as_strings(&sidebar, cx),
1995        vec!["v [my-project]", "  Thread A", "  Thread B",]
1996    );
1997
1998    // Keyboard confirm preserves selection.
1999    sidebar.update_in(cx, |sidebar, window, cx| {
2000        sidebar.selection = Some(1);
2001        sidebar.confirm(&Confirm, window, cx);
2002    });
2003    assert_eq!(
2004        sidebar.read_with(cx, |sidebar, _| sidebar.selection),
2005        Some(1)
2006    );
2007
2008    // Click handlers clear selection to None so no highlight lingers
2009    // after a click regardless of focus state. The hover style provides
2010    // visual feedback during mouse interaction instead.
2011    sidebar.update_in(cx, |sidebar, window, cx| {
2012        sidebar.selection = None;
2013        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2014        sidebar.toggle_collapse(&path_list, window, cx);
2015    });
2016    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2017
2018    // When the user tabs back into the sidebar, focus_in no longer
2019    // restores selection — it stays None.
2020    sidebar.update_in(cx, |sidebar, window, cx| {
2021        sidebar.focus_in(window, cx);
2022    });
2023    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
2024}
2025
2026#[gpui::test]
2027async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
2028    let project = init_test_project_with_agent_panel("/my-project", cx).await;
2029    let (multi_workspace, cx) =
2030        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2031    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2032
2033    let connection = StubAgentConnection::new();
2034    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2035        acp::ContentChunk::new("Hi there!".into()),
2036    )]);
2037    open_thread_with_connection(&panel, connection, cx);
2038    send_message(&panel, cx);
2039
2040    let session_id = active_session_id(&panel, cx);
2041    save_test_thread_metadata(&session_id, &project, cx).await;
2042    cx.run_until_parked();
2043
2044    assert_eq!(
2045        visible_entries_as_strings(&sidebar, cx),
2046        vec!["v [my-project]", "  Hello *"]
2047    );
2048
2049    // Simulate the agent generating a title. The notification chain is:
2050    // AcpThread::set_title emits TitleUpdated →
2051    // ConnectionView::handle_thread_event calls cx.notify() →
2052    // AgentPanel observer fires and emits AgentPanelEvent →
2053    // Sidebar subscription calls update_entries / rebuild_contents.
2054    //
2055    // Before the fix, handle_thread_event did NOT call cx.notify() for
2056    // TitleUpdated, so the AgentPanel observer never fired and the
2057    // sidebar kept showing the old title.
2058    let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
2059    thread.update(cx, |thread, cx| {
2060        thread
2061            .set_title("Friendly Greeting with AI".into(), cx)
2062            .detach();
2063    });
2064    cx.run_until_parked();
2065
2066    assert_eq!(
2067        visible_entries_as_strings(&sidebar, cx),
2068        vec!["v [my-project]", "  Friendly Greeting with AI *"]
2069    );
2070}
2071
2072#[gpui::test]
2073async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
2074    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2075    let (multi_workspace, cx) =
2076        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2077    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2078
2079    // Save a thread so it appears in the list.
2080    let connection_a = StubAgentConnection::new();
2081    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2082        acp::ContentChunk::new("Done".into()),
2083    )]);
2084    open_thread_with_connection(&panel_a, connection_a, cx);
2085    send_message(&panel_a, cx);
2086    let session_id_a = active_session_id(&panel_a, cx);
2087    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
2088
2089    // Add a second workspace with its own agent panel.
2090    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2091    fs.as_fake()
2092        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2093        .await;
2094    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
2095    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
2096        mw.test_add_workspace(project_b.clone(), window, cx)
2097    });
2098    let panel_b = add_agent_panel(&workspace_b, cx);
2099    cx.run_until_parked();
2100
2101    let workspace_a =
2102        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
2103
2104    // ── 1. Initial state: focused thread derived from active panel ─────
2105    sidebar.read_with(cx, |sidebar, _cx| {
2106        assert_active_thread(
2107            sidebar,
2108            &session_id_a,
2109            "The active panel's thread should be focused on startup",
2110        );
2111    });
2112
2113    sidebar.update_in(cx, |sidebar, window, cx| {
2114        sidebar.activate_thread(
2115            ThreadMetadata {
2116                session_id: session_id_a.clone(),
2117                agent_id: agent::ZED_AGENT_ID.clone(),
2118                title: "Test".into(),
2119                updated_at: Utc::now(),
2120                created_at: None,
2121                folder_paths: PathList::default(),
2122                main_worktree_paths: PathList::default(),
2123                archived: false,
2124            },
2125            &workspace_a,
2126            false,
2127            window,
2128            cx,
2129        );
2130    });
2131    cx.run_until_parked();
2132
2133    sidebar.read_with(cx, |sidebar, _cx| {
2134        assert_active_thread(
2135            sidebar,
2136            &session_id_a,
2137            "After clicking a thread, it should be the focused thread",
2138        );
2139        assert!(
2140            has_thread_entry(sidebar, &session_id_a),
2141            "The clicked thread should be present in the entries"
2142        );
2143    });
2144
2145    workspace_a.read_with(cx, |workspace, cx| {
2146        assert!(
2147            workspace.panel::<AgentPanel>(cx).is_some(),
2148            "Agent panel should exist"
2149        );
2150        let dock = workspace.right_dock().read(cx);
2151        assert!(
2152            dock.is_open(),
2153            "Clicking a thread should open the agent panel dock"
2154        );
2155    });
2156
2157    let connection_b = StubAgentConnection::new();
2158    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2159        acp::ContentChunk::new("Thread B".into()),
2160    )]);
2161    open_thread_with_connection(&panel_b, connection_b, cx);
2162    send_message(&panel_b, cx);
2163    let session_id_b = active_session_id(&panel_b, cx);
2164    save_test_thread_metadata(&session_id_b, &project_b, cx).await;
2165    cx.run_until_parked();
2166
2167    // Workspace A is currently active. Click a thread in workspace B,
2168    // which also triggers a workspace switch.
2169    sidebar.update_in(cx, |sidebar, window, cx| {
2170        sidebar.activate_thread(
2171            ThreadMetadata {
2172                session_id: session_id_b.clone(),
2173                agent_id: agent::ZED_AGENT_ID.clone(),
2174                title: "Thread B".into(),
2175                updated_at: Utc::now(),
2176                created_at: None,
2177                folder_paths: PathList::default(),
2178                main_worktree_paths: PathList::default(),
2179                archived: false,
2180            },
2181            &workspace_b,
2182            false,
2183            window,
2184            cx,
2185        );
2186    });
2187    cx.run_until_parked();
2188
2189    sidebar.read_with(cx, |sidebar, _cx| {
2190        assert_active_thread(
2191            sidebar,
2192            &session_id_b,
2193            "Clicking a thread in another workspace should focus that thread",
2194        );
2195        assert!(
2196            has_thread_entry(sidebar, &session_id_b),
2197            "The cross-workspace thread should be present in the entries"
2198        );
2199    });
2200
2201    multi_workspace.update_in(cx, |mw, window, cx| {
2202        let workspace = mw.workspaces().next().unwrap().clone();
2203        mw.activate(workspace, window, cx);
2204    });
2205    cx.run_until_parked();
2206
2207    sidebar.read_with(cx, |sidebar, _cx| {
2208        assert_active_thread(
2209            sidebar,
2210            &session_id_a,
2211            "Switching workspace should seed focused_thread from the new active panel",
2212        );
2213        assert!(
2214            has_thread_entry(sidebar, &session_id_a),
2215            "The seeded thread should be present in the entries"
2216        );
2217    });
2218
2219    let connection_b2 = StubAgentConnection::new();
2220    connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2221        acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
2222    )]);
2223    open_thread_with_connection(&panel_b, connection_b2, cx);
2224    send_message(&panel_b, cx);
2225    let session_id_b2 = active_session_id(&panel_b, cx);
2226    save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
2227    cx.run_until_parked();
2228
2229    // Panel B is not the active workspace's panel (workspace A is
2230    // active), so opening a thread there should not change focused_thread.
2231    // This prevents running threads in background workspaces from causing
2232    // the selection highlight to jump around.
2233    sidebar.read_with(cx, |sidebar, _cx| {
2234        assert_active_thread(
2235            sidebar,
2236            &session_id_a,
2237            "Opening a thread in a non-active panel should not change focused_thread",
2238        );
2239    });
2240
2241    workspace_b.update_in(cx, |workspace, window, cx| {
2242        workspace.focus_handle(cx).focus(window, cx);
2243    });
2244    cx.run_until_parked();
2245
2246    sidebar.read_with(cx, |sidebar, _cx| {
2247        assert_active_thread(
2248            sidebar,
2249            &session_id_a,
2250            "Defocusing the sidebar should not change focused_thread",
2251        );
2252    });
2253
2254    // Switching workspaces via the multi_workspace (simulates clicking
2255    // a workspace header) should clear focused_thread.
2256    multi_workspace.update_in(cx, |mw, window, cx| {
2257        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
2258        if let Some(workspace) = workspace {
2259            mw.activate(workspace, window, cx);
2260        }
2261    });
2262    cx.run_until_parked();
2263
2264    sidebar.read_with(cx, |sidebar, _cx| {
2265        assert_active_thread(
2266            sidebar,
2267            &session_id_b2,
2268            "Switching workspace should seed focused_thread from the new active panel",
2269        );
2270        assert!(
2271            has_thread_entry(sidebar, &session_id_b2),
2272            "The seeded thread should be present in the entries"
2273        );
2274    });
2275
2276    // ── 8. Focusing the agent panel thread keeps focused_thread ────
2277    // Workspace B still has session_id_b2 loaded in the agent panel.
2278    // Clicking into the thread (simulated by focusing its view) should
2279    // keep focused_thread since it was already seeded on workspace switch.
2280    panel_b.update_in(cx, |panel, window, cx| {
2281        if let Some(thread_view) = panel.active_conversation_view() {
2282            thread_view.read(cx).focus_handle(cx).focus(window, cx);
2283        }
2284    });
2285    cx.run_until_parked();
2286
2287    sidebar.read_with(cx, |sidebar, _cx| {
2288        assert_active_thread(
2289            sidebar,
2290            &session_id_b2,
2291            "Focusing the agent panel thread should set focused_thread",
2292        );
2293        assert!(
2294            has_thread_entry(sidebar, &session_id_b2),
2295            "The focused thread should be present in the entries"
2296        );
2297    });
2298}
2299
2300#[gpui::test]
2301async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
2302    let project = init_test_project_with_agent_panel("/project-a", cx).await;
2303    let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
2304    let (multi_workspace, cx) =
2305        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2306    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2307
2308    // Start a thread and send a message so it has history.
2309    let connection = StubAgentConnection::new();
2310    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2311        acp::ContentChunk::new("Done".into()),
2312    )]);
2313    open_thread_with_connection(&panel, connection, cx);
2314    send_message(&panel, cx);
2315    let session_id = active_session_id(&panel, cx);
2316    save_test_thread_metadata(&session_id, &project, cx).await;
2317    cx.run_until_parked();
2318
2319    // Verify the thread appears in the sidebar.
2320    assert_eq!(
2321        visible_entries_as_strings(&sidebar, cx),
2322        vec!["v [project-a]", "  Hello *",]
2323    );
2324
2325    // The "New Thread" button should NOT be in "active/draft" state
2326    // because the panel has a thread with messages.
2327    sidebar.read_with(cx, |sidebar, _cx| {
2328        assert!(
2329            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
2330            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
2331            sidebar.active_entry,
2332        );
2333    });
2334
2335    // Now add a second folder to the workspace, changing the path_list.
2336    fs.as_fake()
2337        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
2338        .await;
2339    project
2340        .update(cx, |project, cx| {
2341            project.find_or_create_worktree("/project-b", true, cx)
2342        })
2343        .await
2344        .expect("should add worktree");
2345    cx.run_until_parked();
2346
2347    // The workspace path_list is now [project-a, project-b]. The active
2348    // thread's metadata was re-saved with the new paths by the agent panel's
2349    // project subscription, so it stays visible under the updated group.
2350    // The old [project-a] group persists in the sidebar (empty) because
2351    // project_group_keys is append-only.
2352    assert_eq!(
2353        visible_entries_as_strings(&sidebar, cx),
2354        vec![
2355            "v [project-a, project-b]", //
2356            "  Hello *",
2357            "v [project-a]",
2358        ]
2359    );
2360
2361    // The "New Thread" button must still be clickable (not stuck in
2362    // "active/draft" state). Verify that `active_thread_is_draft` is
2363    // false — the panel still has the old thread with messages.
2364    sidebar.read_with(cx, |sidebar, _cx| {
2365        assert!(
2366            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
2367            "After adding a folder the panel still has a thread with messages, \
2368                 so active_entry should be Thread, got {:?}",
2369            sidebar.active_entry,
2370        );
2371    });
2372
2373    // Actually click "New Thread" by calling create_new_thread and
2374    // verify a new draft is created.
2375    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2376    sidebar.update_in(cx, |sidebar, window, cx| {
2377        sidebar.create_new_thread(&workspace, window, cx);
2378    });
2379    cx.run_until_parked();
2380
2381    // After creating a new thread, the panel should now be in draft
2382    // state (no messages on the new thread).
2383    sidebar.read_with(cx, |sidebar, _cx| {
2384        assert_active_draft(
2385            sidebar,
2386            &workspace,
2387            "After creating a new thread active_entry should be Draft",
2388        );
2389    });
2390}
2391
2392#[gpui::test]
2393async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
2394    // When the user presses Cmd-N (NewThread action) while viewing a
2395    // non-empty thread, the sidebar should show the "New Thread" entry.
2396    // This exercises the same code path as the workspace action handler
2397    // (which bypasses the sidebar's create_new_thread method).
2398    let project = init_test_project_with_agent_panel("/my-project", cx).await;
2399    let (multi_workspace, cx) =
2400        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2401    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2402
2403    // Create a non-empty thread (has messages).
2404    let connection = StubAgentConnection::new();
2405    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2406        acp::ContentChunk::new("Done".into()),
2407    )]);
2408    open_thread_with_connection(&panel, connection, cx);
2409    send_message(&panel, cx);
2410
2411    let session_id = active_session_id(&panel, cx);
2412    save_test_thread_metadata(&session_id, &project, cx).await;
2413    cx.run_until_parked();
2414
2415    assert_eq!(
2416        visible_entries_as_strings(&sidebar, cx),
2417        vec!["v [my-project]", "  Hello *"]
2418    );
2419
2420    // Simulate cmd-n
2421    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2422    panel.update_in(cx, |panel, window, cx| {
2423        panel.new_thread(&NewThread, window, cx);
2424    });
2425    workspace.update_in(cx, |workspace, window, cx| {
2426        workspace.focus_panel::<AgentPanel>(window, cx);
2427    });
2428    cx.run_until_parked();
2429
2430    assert_eq!(
2431        visible_entries_as_strings(&sidebar, cx),
2432        vec!["v [my-project]", "  [~ Draft]", "  Hello *"],
2433        "After Cmd-N the sidebar should show a highlighted Draft entry"
2434    );
2435
2436    sidebar.read_with(cx, |sidebar, _cx| {
2437        assert_active_draft(
2438            sidebar,
2439            &workspace,
2440            "active_entry should be Draft after Cmd-N",
2441        );
2442    });
2443}
2444
2445#[gpui::test]
2446async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
2447    let project = init_test_project_with_agent_panel("/my-project", cx).await;
2448    let (multi_workspace, cx) =
2449        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2450    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
2451
2452    // Create a saved thread so the workspace has history.
2453    let connection = StubAgentConnection::new();
2454    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2455        acp::ContentChunk::new("Done".into()),
2456    )]);
2457    open_thread_with_connection(&panel, connection, cx);
2458    send_message(&panel, cx);
2459    let saved_session_id = active_session_id(&panel, cx);
2460    save_test_thread_metadata(&saved_session_id, &project, cx).await;
2461    cx.run_until_parked();
2462
2463    assert_eq!(
2464        visible_entries_as_strings(&sidebar, cx),
2465        vec!["v [my-project]", "  Hello *"]
2466    );
2467
2468    // Open a new draft thread via a server connection. This gives the
2469    // conversation a parent_id (session assigned by the server) but
2470    // no messages have been sent, so active_thread_is_draft() is true.
2471    let draft_connection = StubAgentConnection::new();
2472    open_thread_with_connection(&panel, draft_connection, cx);
2473    cx.run_until_parked();
2474
2475    assert_eq!(
2476        visible_entries_as_strings(&sidebar, cx),
2477        vec!["v [my-project]", "  [~ Draft]", "  Hello *"],
2478    );
2479
2480    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2481    sidebar.read_with(cx, |sidebar, _cx| {
2482        assert_active_draft(
2483            sidebar,
2484            &workspace,
2485            "Draft with server session should be Draft, not Thread",
2486        );
2487    });
2488}
2489
2490#[gpui::test]
2491async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
2492    // When the active workspace is an absorbed git worktree, cmd-n
2493    // should still show the "New Thread" entry under the main repo's
2494    // header and highlight it as active.
2495    agent_ui::test_support::init_test(cx);
2496    cx.update(|cx| {
2497        cx.update_flags(false, vec!["agent-v2".into()]);
2498        ThreadStore::init_global(cx);
2499        ThreadMetadataStore::init_global(cx);
2500        language_model::LanguageModelRegistry::test(cx);
2501        prompt_store::init(cx);
2502    });
2503
2504    let fs = FakeFs::new(cx.executor());
2505
2506    // Main repo with a linked worktree.
2507    fs.insert_tree(
2508        "/project",
2509        serde_json::json!({
2510            ".git": {},
2511            "src": {},
2512        }),
2513    )
2514    .await;
2515
2516    // Worktree checkout pointing back to the main repo.
2517    fs.add_linked_worktree_for_repo(
2518        Path::new("/project/.git"),
2519        false,
2520        git::repository::Worktree {
2521            path: std::path::PathBuf::from("/wt-feature-a"),
2522            ref_name: Some("refs/heads/feature-a".into()),
2523            sha: "aaa".into(),
2524            is_main: false,
2525        },
2526    )
2527    .await;
2528
2529    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2530
2531    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2532    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2533
2534    main_project
2535        .update(cx, |p, cx| p.git_scans_complete(cx))
2536        .await;
2537    worktree_project
2538        .update(cx, |p, cx| p.git_scans_complete(cx))
2539        .await;
2540
2541    let (multi_workspace, cx) =
2542        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
2543
2544    let sidebar = setup_sidebar(&multi_workspace, cx);
2545
2546    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
2547        mw.test_add_workspace(worktree_project.clone(), window, cx)
2548    });
2549
2550    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
2551
2552    // Switch to the worktree workspace.
2553    multi_workspace.update_in(cx, |mw, window, cx| {
2554        let workspace = mw.workspaces().nth(1).unwrap().clone();
2555        mw.activate(workspace, window, cx);
2556    });
2557
2558    // Create a non-empty thread in the worktree workspace.
2559    let connection = StubAgentConnection::new();
2560    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2561        acp::ContentChunk::new("Done".into()),
2562    )]);
2563    open_thread_with_connection(&worktree_panel, connection, cx);
2564    send_message(&worktree_panel, cx);
2565
2566    let session_id = active_session_id(&worktree_panel, cx);
2567    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
2568    cx.run_until_parked();
2569
2570    assert_eq!(
2571        visible_entries_as_strings(&sidebar, cx),
2572        vec!["v [project]", "  Hello {wt-feature-a} *"]
2573    );
2574
2575    // Simulate Cmd-N in the worktree workspace.
2576    worktree_panel.update_in(cx, |panel, window, cx| {
2577        panel.new_thread(&NewThread, window, cx);
2578    });
2579    worktree_workspace.update_in(cx, |workspace, window, cx| {
2580        workspace.focus_panel::<AgentPanel>(window, cx);
2581    });
2582    cx.run_until_parked();
2583
2584    assert_eq!(
2585        visible_entries_as_strings(&sidebar, cx),
2586        vec![
2587            "v [project]",
2588            "  [~ Draft {wt-feature-a}]",
2589            "  Hello {wt-feature-a} *"
2590        ],
2591        "After Cmd-N in an absorbed worktree, the sidebar should show \
2592             a highlighted Draft entry under the main repo header"
2593    );
2594
2595    sidebar.read_with(cx, |sidebar, _cx| {
2596        assert_active_draft(
2597            sidebar,
2598            &worktree_workspace,
2599            "active_entry should be Draft after Cmd-N",
2600        );
2601    });
2602}
2603
2604async fn init_test_project_with_git(
2605    worktree_path: &str,
2606    cx: &mut TestAppContext,
2607) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
2608    init_test(cx);
2609    let fs = FakeFs::new(cx.executor());
2610    fs.insert_tree(
2611        worktree_path,
2612        serde_json::json!({
2613            ".git": {},
2614            "src": {},
2615        }),
2616    )
2617    .await;
2618    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2619    let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
2620    (project, fs)
2621}
2622
2623#[gpui::test]
2624async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
2625    let (project, fs) = init_test_project_with_git("/project", cx).await;
2626
2627    fs.as_fake()
2628        .add_linked_worktree_for_repo(
2629            Path::new("/project/.git"),
2630            false,
2631            git::repository::Worktree {
2632                path: std::path::PathBuf::from("/wt/rosewood"),
2633                ref_name: Some("refs/heads/rosewood".into()),
2634                sha: "abc".into(),
2635                is_main: false,
2636            },
2637        )
2638        .await;
2639
2640    project
2641        .update(cx, |project, cx| project.git_scans_complete(cx))
2642        .await;
2643
2644    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
2645    worktree_project
2646        .update(cx, |p, cx| p.git_scans_complete(cx))
2647        .await;
2648
2649    let (multi_workspace, cx) =
2650        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2651    let sidebar = setup_sidebar(&multi_workspace, cx);
2652
2653    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
2654    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
2655
2656    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2657    cx.run_until_parked();
2658
2659    // Search for "rosewood" — should match the worktree name, not the title.
2660    type_in_search(&sidebar, "rosewood", cx);
2661
2662    assert_eq!(
2663        visible_entries_as_strings(&sidebar, cx),
2664        vec!["v [project]", "  Fix Bug {rosewood}  <== selected"],
2665    );
2666}
2667
2668#[gpui::test]
2669async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
2670    let (project, fs) = init_test_project_with_git("/project", cx).await;
2671
2672    project
2673        .update(cx, |project, cx| project.git_scans_complete(cx))
2674        .await;
2675
2676    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
2677    worktree_project
2678        .update(cx, |p, cx| p.git_scans_complete(cx))
2679        .await;
2680
2681    let (multi_workspace, cx) =
2682        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2683    let sidebar = setup_sidebar(&multi_workspace, cx);
2684
2685    // Save a thread against a worktree path that doesn't exist yet.
2686    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
2687
2688    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2689    cx.run_until_parked();
2690
2691    // Thread is not visible yet — no worktree knows about this path.
2692    assert_eq!(
2693        visible_entries_as_strings(&sidebar, cx),
2694        vec!["v [project]", "  [+ New Thread]"]
2695    );
2696
2697    // Now add the worktree to the git state and trigger a rescan.
2698    fs.as_fake()
2699        .add_linked_worktree_for_repo(
2700            Path::new("/project/.git"),
2701            true,
2702            git::repository::Worktree {
2703                path: std::path::PathBuf::from("/wt/rosewood"),
2704                ref_name: Some("refs/heads/rosewood".into()),
2705                sha: "abc".into(),
2706                is_main: false,
2707            },
2708        )
2709        .await;
2710
2711    cx.run_until_parked();
2712
2713    assert_eq!(
2714        visible_entries_as_strings(&sidebar, cx),
2715        vec!["v [project]", "  Worktree Thread {rosewood}",]
2716    );
2717}
2718
2719#[gpui::test]
2720async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
2721    init_test(cx);
2722    let fs = FakeFs::new(cx.executor());
2723
2724    // Create the main repo directory (not opened as a workspace yet).
2725    fs.insert_tree(
2726        "/project",
2727        serde_json::json!({
2728            ".git": {
2729            },
2730            "src": {},
2731        }),
2732    )
2733    .await;
2734
2735    // Two worktree checkouts whose .git files point back to the main repo.
2736    fs.add_linked_worktree_for_repo(
2737        Path::new("/project/.git"),
2738        false,
2739        git::repository::Worktree {
2740            path: std::path::PathBuf::from("/wt-feature-a"),
2741            ref_name: Some("refs/heads/feature-a".into()),
2742            sha: "aaa".into(),
2743            is_main: false,
2744        },
2745    )
2746    .await;
2747    fs.add_linked_worktree_for_repo(
2748        Path::new("/project/.git"),
2749        false,
2750        git::repository::Worktree {
2751            path: std::path::PathBuf::from("/wt-feature-b"),
2752            ref_name: Some("refs/heads/feature-b".into()),
2753            sha: "bbb".into(),
2754            is_main: false,
2755        },
2756    )
2757    .await;
2758
2759    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2760
2761    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2762    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
2763
2764    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2765    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2766
2767    // Open both worktrees as workspaces — no main repo yet.
2768    let (multi_workspace, cx) =
2769        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2770    multi_workspace.update_in(cx, |mw, window, cx| {
2771        mw.test_add_workspace(project_b.clone(), window, cx);
2772    });
2773    let sidebar = setup_sidebar(&multi_workspace, cx);
2774
2775    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
2776    save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
2777
2778    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2779    cx.run_until_parked();
2780
2781    // Without the main repo, each worktree has its own header.
2782    assert_eq!(
2783        visible_entries_as_strings(&sidebar, cx),
2784        vec![
2785            "v [project]",
2786            "  Thread A {wt-feature-a}",
2787            "  Thread B {wt-feature-b}",
2788        ]
2789    );
2790
2791    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
2792    main_project
2793        .update(cx, |p, cx| p.git_scans_complete(cx))
2794        .await;
2795
2796    multi_workspace.update_in(cx, |mw, window, cx| {
2797        mw.test_add_workspace(main_project.clone(), window, cx);
2798    });
2799    cx.run_until_parked();
2800
2801    // Both worktree workspaces should now be absorbed under the main
2802    // repo header, with worktree chips.
2803    assert_eq!(
2804        visible_entries_as_strings(&sidebar, cx),
2805        vec![
2806            "v [project]",
2807            "  Thread A {wt-feature-a}",
2808            "  Thread B {wt-feature-b}",
2809        ]
2810    );
2811}
2812
2813#[gpui::test]
2814async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
2815    // When a group has two workspaces — one with threads and one
2816    // without — the threadless workspace should appear as a
2817    // "New Thread" button with its worktree chip.
2818    init_test(cx);
2819    let fs = FakeFs::new(cx.executor());
2820
2821    // Main repo with two linked worktrees.
2822    fs.insert_tree(
2823        "/project",
2824        serde_json::json!({
2825            ".git": {},
2826            "src": {},
2827        }),
2828    )
2829    .await;
2830    fs.add_linked_worktree_for_repo(
2831        Path::new("/project/.git"),
2832        false,
2833        git::repository::Worktree {
2834            path: std::path::PathBuf::from("/wt-feature-a"),
2835            ref_name: Some("refs/heads/feature-a".into()),
2836            sha: "aaa".into(),
2837            is_main: false,
2838        },
2839    )
2840    .await;
2841    fs.add_linked_worktree_for_repo(
2842        Path::new("/project/.git"),
2843        false,
2844        git::repository::Worktree {
2845            path: std::path::PathBuf::from("/wt-feature-b"),
2846            ref_name: Some("refs/heads/feature-b".into()),
2847            sha: "bbb".into(),
2848            is_main: false,
2849        },
2850    )
2851    .await;
2852
2853    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2854
2855    // Workspace A: worktree feature-a (has threads).
2856    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
2857    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2858
2859    // Workspace B: worktree feature-b (no threads).
2860    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
2861    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2862
2863    let (multi_workspace, cx) =
2864        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2865    multi_workspace.update_in(cx, |mw, window, cx| {
2866        mw.test_add_workspace(project_b.clone(), window, cx);
2867    });
2868    let sidebar = setup_sidebar(&multi_workspace, cx);
2869
2870    // Only save a thread for workspace A.
2871    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
2872
2873    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2874    cx.run_until_parked();
2875
2876    // Workspace A's thread appears normally. Workspace B (threadless)
2877    // appears as a "New Thread" button with its worktree chip.
2878    assert_eq!(
2879        visible_entries_as_strings(&sidebar, cx),
2880        vec![
2881            "v [project]",
2882            "  [+ New Thread {wt-feature-b}]",
2883            "  Thread A {wt-feature-a}",
2884        ]
2885    );
2886}
2887
2888#[gpui::test]
2889async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
2890    // A thread created in a workspace with roots from different git
2891    // worktrees should show a chip for each distinct worktree name.
2892    init_test(cx);
2893    let fs = FakeFs::new(cx.executor());
2894
2895    // Two main repos.
2896    fs.insert_tree(
2897        "/project_a",
2898        serde_json::json!({
2899            ".git": {},
2900            "src": {},
2901        }),
2902    )
2903    .await;
2904    fs.insert_tree(
2905        "/project_b",
2906        serde_json::json!({
2907            ".git": {},
2908            "src": {},
2909        }),
2910    )
2911    .await;
2912
2913    // Worktree checkouts.
2914    for repo in &["project_a", "project_b"] {
2915        let git_path = format!("/{repo}/.git");
2916        for branch in &["olivetti", "selectric"] {
2917            fs.add_linked_worktree_for_repo(
2918                Path::new(&git_path),
2919                false,
2920                git::repository::Worktree {
2921                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
2922                    ref_name: Some(format!("refs/heads/{branch}").into()),
2923                    sha: "aaa".into(),
2924                    is_main: false,
2925                },
2926            )
2927            .await;
2928        }
2929    }
2930
2931    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2932
2933    // Open a workspace with the worktree checkout paths as roots
2934    // (this is the workspace the thread was created in).
2935    let project = project::Project::test(
2936        fs.clone(),
2937        [
2938            "/worktrees/project_a/olivetti/project_a".as_ref(),
2939            "/worktrees/project_b/selectric/project_b".as_ref(),
2940        ],
2941        cx,
2942    )
2943    .await;
2944    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
2945
2946    let (multi_workspace, cx) =
2947        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2948    let sidebar = setup_sidebar(&multi_workspace, cx);
2949
2950    // Save a thread under the same paths as the workspace roots.
2951    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
2952
2953    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2954    cx.run_until_parked();
2955
2956    // Should show two distinct worktree chips.
2957    assert_eq!(
2958        visible_entries_as_strings(&sidebar, cx),
2959        vec![
2960            "v [project_a, project_b]",
2961            "  Cross Worktree Thread {olivetti}, {selectric}",
2962        ]
2963    );
2964}
2965
2966#[gpui::test]
2967async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
2968    // When a thread's roots span multiple repos but share the same
2969    // worktree name (e.g. both in "olivetti"), only one chip should
2970    // appear.
2971    init_test(cx);
2972    let fs = FakeFs::new(cx.executor());
2973
2974    fs.insert_tree(
2975        "/project_a",
2976        serde_json::json!({
2977            ".git": {},
2978            "src": {},
2979        }),
2980    )
2981    .await;
2982    fs.insert_tree(
2983        "/project_b",
2984        serde_json::json!({
2985            ".git": {},
2986            "src": {},
2987        }),
2988    )
2989    .await;
2990
2991    for repo in &["project_a", "project_b"] {
2992        let git_path = format!("/{repo}/.git");
2993        fs.add_linked_worktree_for_repo(
2994            Path::new(&git_path),
2995            false,
2996            git::repository::Worktree {
2997                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
2998                ref_name: Some("refs/heads/olivetti".into()),
2999                sha: "aaa".into(),
3000                is_main: false,
3001            },
3002        )
3003        .await;
3004    }
3005
3006    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3007
3008    let project = project::Project::test(
3009        fs.clone(),
3010        [
3011            "/worktrees/project_a/olivetti/project_a".as_ref(),
3012            "/worktrees/project_b/olivetti/project_b".as_ref(),
3013        ],
3014        cx,
3015    )
3016    .await;
3017    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
3018
3019    let (multi_workspace, cx) =
3020        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3021    let sidebar = setup_sidebar(&multi_workspace, cx);
3022
3023    // Thread with roots in both repos' "olivetti" worktrees.
3024    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
3025
3026    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3027    cx.run_until_parked();
3028
3029    // Both worktree paths have the name "olivetti", so only one chip.
3030    assert_eq!(
3031        visible_entries_as_strings(&sidebar, cx),
3032        vec![
3033            "v [project_a, project_b]",
3034            "  Same Branch Thread {olivetti}",
3035        ]
3036    );
3037}
3038
3039#[gpui::test]
3040async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
3041    // When a worktree workspace is absorbed under the main repo, a
3042    // running thread in the worktree's agent panel should still show
3043    // live status (spinner + "(running)") in the sidebar.
3044    agent_ui::test_support::init_test(cx);
3045    cx.update(|cx| {
3046        cx.update_flags(false, vec!["agent-v2".into()]);
3047        ThreadStore::init_global(cx);
3048        ThreadMetadataStore::init_global(cx);
3049        language_model::LanguageModelRegistry::test(cx);
3050        prompt_store::init(cx);
3051    });
3052
3053    let fs = FakeFs::new(cx.executor());
3054
3055    // Main repo with a linked worktree.
3056    fs.insert_tree(
3057        "/project",
3058        serde_json::json!({
3059            ".git": {},
3060            "src": {},
3061        }),
3062    )
3063    .await;
3064
3065    // Worktree checkout pointing back to the main repo.
3066    fs.add_linked_worktree_for_repo(
3067        Path::new("/project/.git"),
3068        false,
3069        git::repository::Worktree {
3070            path: std::path::PathBuf::from("/wt-feature-a"),
3071            ref_name: Some("refs/heads/feature-a".into()),
3072            sha: "aaa".into(),
3073            is_main: false,
3074        },
3075    )
3076    .await;
3077
3078    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3079
3080    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3081    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3082
3083    main_project
3084        .update(cx, |p, cx| p.git_scans_complete(cx))
3085        .await;
3086    worktree_project
3087        .update(cx, |p, cx| p.git_scans_complete(cx))
3088        .await;
3089
3090    // Create the MultiWorkspace with both projects.
3091    let (multi_workspace, cx) =
3092        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3093
3094    let sidebar = setup_sidebar(&multi_workspace, cx);
3095
3096    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3097        mw.test_add_workspace(worktree_project.clone(), window, cx)
3098    });
3099
3100    // Add an agent panel to the worktree workspace so we can run a
3101    // thread inside it.
3102    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3103
3104    // Switch back to the main workspace before setting up the sidebar.
3105    multi_workspace.update_in(cx, |mw, window, cx| {
3106        let workspace = mw.workspaces().next().unwrap().clone();
3107        mw.activate(workspace, window, cx);
3108    });
3109
3110    // Start a thread in the worktree workspace's panel and keep it
3111    // generating (don't resolve it).
3112    let connection = StubAgentConnection::new();
3113    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3114    send_message(&worktree_panel, cx);
3115
3116    let session_id = active_session_id(&worktree_panel, cx);
3117
3118    // Save metadata so the sidebar knows about this thread.
3119    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3120
3121    // Keep the thread generating by sending a chunk without ending
3122    // the turn.
3123    cx.update(|_, cx| {
3124        connection.send_update(
3125            session_id.clone(),
3126            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3127            cx,
3128        );
3129    });
3130    cx.run_until_parked();
3131
3132    // The worktree thread should be absorbed under the main project
3133    // and show live running status.
3134    let entries = visible_entries_as_strings(&sidebar, cx);
3135    assert_eq!(
3136        entries,
3137        vec![
3138            "v [project]",
3139            "  [~ Draft]",
3140            "  Hello {wt-feature-a} * (running)",
3141        ]
3142    );
3143}
3144
3145#[gpui::test]
3146async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
3147    agent_ui::test_support::init_test(cx);
3148    cx.update(|cx| {
3149        cx.update_flags(false, vec!["agent-v2".into()]);
3150        ThreadStore::init_global(cx);
3151        ThreadMetadataStore::init_global(cx);
3152        language_model::LanguageModelRegistry::test(cx);
3153        prompt_store::init(cx);
3154    });
3155
3156    let fs = FakeFs::new(cx.executor());
3157
3158    fs.insert_tree(
3159        "/project",
3160        serde_json::json!({
3161            ".git": {},
3162            "src": {},
3163        }),
3164    )
3165    .await;
3166
3167    fs.add_linked_worktree_for_repo(
3168        Path::new("/project/.git"),
3169        false,
3170        git::repository::Worktree {
3171            path: std::path::PathBuf::from("/wt-feature-a"),
3172            ref_name: Some("refs/heads/feature-a".into()),
3173            sha: "aaa".into(),
3174            is_main: false,
3175        },
3176    )
3177    .await;
3178
3179    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3180
3181    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3182    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3183
3184    main_project
3185        .update(cx, |p, cx| p.git_scans_complete(cx))
3186        .await;
3187    worktree_project
3188        .update(cx, |p, cx| p.git_scans_complete(cx))
3189        .await;
3190
3191    let (multi_workspace, cx) =
3192        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3193
3194    let sidebar = setup_sidebar(&multi_workspace, cx);
3195
3196    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3197        mw.test_add_workspace(worktree_project.clone(), window, cx)
3198    });
3199
3200    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
3201
3202    multi_workspace.update_in(cx, |mw, window, cx| {
3203        let workspace = mw.workspaces().next().unwrap().clone();
3204        mw.activate(workspace, window, cx);
3205    });
3206
3207    let connection = StubAgentConnection::new();
3208    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
3209    send_message(&worktree_panel, cx);
3210
3211    let session_id = active_session_id(&worktree_panel, cx);
3212    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
3213
3214    cx.update(|_, cx| {
3215        connection.send_update(
3216            session_id.clone(),
3217            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3218            cx,
3219        );
3220    });
3221    cx.run_until_parked();
3222
3223    assert_eq!(
3224        visible_entries_as_strings(&sidebar, cx),
3225        vec![
3226            "v [project]",
3227            "  [~ Draft]",
3228            "  Hello {wt-feature-a} * (running)",
3229        ]
3230    );
3231
3232    connection.end_turn(session_id, acp::StopReason::EndTurn);
3233    cx.run_until_parked();
3234
3235    assert_eq!(
3236        visible_entries_as_strings(&sidebar, cx),
3237        vec!["v [project]", "  [~ Draft]", "  Hello {wt-feature-a} * (!)",]
3238    );
3239}
3240
3241#[gpui::test]
3242async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
3243    init_test(cx);
3244    let fs = FakeFs::new(cx.executor());
3245
3246    fs.insert_tree(
3247        "/project",
3248        serde_json::json!({
3249            ".git": {},
3250            "src": {},
3251        }),
3252    )
3253    .await;
3254
3255    fs.add_linked_worktree_for_repo(
3256        Path::new("/project/.git"),
3257        false,
3258        git::repository::Worktree {
3259            path: std::path::PathBuf::from("/wt-feature-a"),
3260            ref_name: Some("refs/heads/feature-a".into()),
3261            sha: "aaa".into(),
3262            is_main: false,
3263        },
3264    )
3265    .await;
3266
3267    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3268
3269    // Only open the main repo — no workspace for the worktree.
3270    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3271    main_project
3272        .update(cx, |p, cx| p.git_scans_complete(cx))
3273        .await;
3274
3275    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3276    worktree_project
3277        .update(cx, |p, cx| p.git_scans_complete(cx))
3278        .await;
3279
3280    let (multi_workspace, cx) =
3281        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3282    let sidebar = setup_sidebar(&multi_workspace, cx);
3283
3284    // Save a thread for the worktree path (no workspace for it).
3285    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3286
3287    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3288    cx.run_until_parked();
3289
3290    // Thread should appear under the main repo with a worktree chip.
3291    assert_eq!(
3292        visible_entries_as_strings(&sidebar, cx),
3293        vec!["v [project]", "  WT Thread {wt-feature-a}"],
3294    );
3295
3296    // Only 1 workspace should exist.
3297    assert_eq!(
3298        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3299        1,
3300    );
3301
3302    // Focus the sidebar and select the worktree thread.
3303    focus_sidebar(&sidebar, cx);
3304    sidebar.update_in(cx, |sidebar, _window, _cx| {
3305        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3306    });
3307
3308    // Confirm to open the worktree thread.
3309    cx.dispatch_action(Confirm);
3310    cx.run_until_parked();
3311
3312    // A new workspace should have been created for the worktree path.
3313    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
3314        assert_eq!(
3315            mw.workspaces().count(),
3316            2,
3317            "confirming a worktree thread without a workspace should open one",
3318        );
3319        mw.workspaces().nth(1).unwrap().clone()
3320    });
3321
3322    let new_path_list =
3323        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
3324    assert_eq!(
3325        new_path_list,
3326        PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
3327        "the new workspace should have been opened for the worktree path",
3328    );
3329}
3330
3331#[gpui::test]
3332async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
3333    cx: &mut TestAppContext,
3334) {
3335    init_test(cx);
3336    let fs = FakeFs::new(cx.executor());
3337
3338    fs.insert_tree(
3339        "/project",
3340        serde_json::json!({
3341            ".git": {},
3342            "src": {},
3343        }),
3344    )
3345    .await;
3346
3347    fs.add_linked_worktree_for_repo(
3348        Path::new("/project/.git"),
3349        false,
3350        git::repository::Worktree {
3351            path: std::path::PathBuf::from("/wt-feature-a"),
3352            ref_name: Some("refs/heads/feature-a".into()),
3353            sha: "aaa".into(),
3354            is_main: false,
3355        },
3356    )
3357    .await;
3358
3359    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3360
3361    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3362    main_project
3363        .update(cx, |p, cx| p.git_scans_complete(cx))
3364        .await;
3365
3366    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3367    worktree_project
3368        .update(cx, |p, cx| p.git_scans_complete(cx))
3369        .await;
3370
3371    let (multi_workspace, cx) =
3372        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3373    let sidebar = setup_sidebar(&multi_workspace, cx);
3374
3375    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3376
3377    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3378    cx.run_until_parked();
3379
3380    assert_eq!(
3381        visible_entries_as_strings(&sidebar, cx),
3382        vec!["v [project]", "  WT Thread {wt-feature-a}"],
3383    );
3384
3385    focus_sidebar(&sidebar, cx);
3386    sidebar.update_in(cx, |sidebar, _window, _cx| {
3387        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
3388    });
3389
3390    let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
3391        let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
3392            if let ListEntry::ProjectHeader { label, .. } = entry {
3393                Some(label.as_ref())
3394            } else {
3395                None
3396            }
3397        });
3398
3399        let Some(project_header) = project_headers.next() else {
3400            panic!("expected exactly one sidebar project header named `project`, found none");
3401        };
3402        assert_eq!(
3403            project_header, "project",
3404            "expected the only sidebar project header to be `project`"
3405        );
3406        if let Some(unexpected_header) = project_headers.next() {
3407            panic!(
3408                "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
3409            );
3410        }
3411
3412        let mut saw_expected_thread = false;
3413        for entry in &sidebar.contents.entries {
3414            match entry {
3415                ListEntry::ProjectHeader { label, .. } => {
3416                    assert_eq!(
3417                        label.as_ref(),
3418                        "project",
3419                        "expected the only sidebar project header to be `project`"
3420                    );
3421                }
3422                ListEntry::Thread(thread)
3423                    if thread.metadata.title.as_ref() == "WT Thread"
3424                        && thread.worktrees.first().map(|wt| wt.name.as_ref())
3425                            == Some("wt-feature-a") =>
3426                {
3427                    saw_expected_thread = true;
3428                }
3429                ListEntry::Thread(thread) => {
3430                    let title = thread.metadata.title.as_ref();
3431                    let worktree_name = thread
3432                        .worktrees
3433                        .first()
3434                        .map(|wt| wt.name.as_ref())
3435                        .unwrap_or("<none>");
3436                    panic!(
3437                        "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`"
3438                    );
3439                }
3440                ListEntry::ViewMore { .. } => {
3441                    panic!("unexpected `View More` entry while opening linked worktree thread");
3442                }
3443                ListEntry::DraftThread { .. } | ListEntry::NewThread { .. } => {}
3444            }
3445        }
3446
3447        assert!(
3448            saw_expected_thread,
3449            "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
3450        );
3451    };
3452
3453    sidebar
3454        .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
3455        .detach();
3456
3457    let window = cx.windows()[0];
3458    cx.update_window(window, |_, window, cx| {
3459        window.dispatch_action(Confirm.boxed_clone(), cx);
3460    })
3461    .unwrap();
3462
3463    cx.run_until_parked();
3464
3465    sidebar.update(cx, assert_sidebar_state);
3466}
3467
3468#[gpui::test]
3469async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
3470    cx: &mut TestAppContext,
3471) {
3472    init_test(cx);
3473    let fs = FakeFs::new(cx.executor());
3474
3475    fs.insert_tree(
3476        "/project",
3477        serde_json::json!({
3478            ".git": {},
3479            "src": {},
3480        }),
3481    )
3482    .await;
3483
3484    fs.add_linked_worktree_for_repo(
3485        Path::new("/project/.git"),
3486        false,
3487        git::repository::Worktree {
3488            path: std::path::PathBuf::from("/wt-feature-a"),
3489            ref_name: Some("refs/heads/feature-a".into()),
3490            sha: "aaa".into(),
3491            is_main: false,
3492        },
3493    )
3494    .await;
3495
3496    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3497
3498    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
3499    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
3500
3501    main_project
3502        .update(cx, |p, cx| p.git_scans_complete(cx))
3503        .await;
3504    worktree_project
3505        .update(cx, |p, cx| p.git_scans_complete(cx))
3506        .await;
3507
3508    let (multi_workspace, cx) =
3509        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
3510
3511    let sidebar = setup_sidebar(&multi_workspace, cx);
3512
3513    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3514        mw.test_add_workspace(worktree_project.clone(), window, cx)
3515    });
3516
3517    // Activate the main workspace before setting up the sidebar.
3518    let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
3519        let workspace = mw.workspaces().next().unwrap().clone();
3520        mw.activate(workspace.clone(), window, cx);
3521        workspace
3522    });
3523
3524    save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
3525    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
3526
3527    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3528    cx.run_until_parked();
3529
3530    // The worktree workspace should be absorbed under the main repo.
3531    let entries = visible_entries_as_strings(&sidebar, cx);
3532    assert_eq!(entries.len(), 3);
3533    assert_eq!(entries[0], "v [project]");
3534    assert!(entries.contains(&"  Main Thread".to_string()));
3535    assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
3536
3537    let wt_thread_index = entries
3538        .iter()
3539        .position(|e| e.contains("WT Thread"))
3540        .expect("should find the worktree thread entry");
3541
3542    assert_eq!(
3543        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3544        main_workspace,
3545        "main workspace should be active initially"
3546    );
3547
3548    // Focus the sidebar and select the absorbed worktree thread.
3549    focus_sidebar(&sidebar, cx);
3550    sidebar.update_in(cx, |sidebar, _window, _cx| {
3551        sidebar.selection = Some(wt_thread_index);
3552    });
3553
3554    // Confirm to activate the worktree thread.
3555    cx.dispatch_action(Confirm);
3556    cx.run_until_parked();
3557
3558    // The worktree workspace should now be active, not the main one.
3559    let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3560    assert_eq!(
3561        active_workspace, worktree_workspace,
3562        "clicking an absorbed worktree thread should activate the worktree workspace"
3563    );
3564}
3565
3566#[gpui::test]
3567async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
3568    cx: &mut TestAppContext,
3569) {
3570    // Thread has saved metadata in ThreadStore. A matching workspace is
3571    // already open. Expected: activates the matching workspace.
3572    init_test(cx);
3573    let fs = FakeFs::new(cx.executor());
3574    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3575        .await;
3576    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3577        .await;
3578    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3579
3580    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3581    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3582
3583    let (multi_workspace, cx) =
3584        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3585
3586    let sidebar = setup_sidebar(&multi_workspace, cx);
3587
3588    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3589        mw.test_add_workspace(project_b.clone(), window, cx)
3590    });
3591    let workspace_a =
3592        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
3593
3594    // Save a thread with path_list pointing to project-b.
3595    let session_id = acp::SessionId::new(Arc::from("archived-1"));
3596    save_test_thread_metadata(&session_id, &project_b, cx).await;
3597
3598    // Ensure workspace A is active.
3599    multi_workspace.update_in(cx, |mw, window, cx| {
3600        let workspace = mw.workspaces().next().unwrap().clone();
3601        mw.activate(workspace, window, cx);
3602    });
3603    cx.run_until_parked();
3604    assert_eq!(
3605        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3606        workspace_a
3607    );
3608
3609    // Call activate_archived_thread – should resolve saved paths and
3610    // switch to the workspace for project-b.
3611    sidebar.update_in(cx, |sidebar, window, cx| {
3612        sidebar.activate_archived_thread(
3613            ThreadMetadata {
3614                session_id: session_id.clone(),
3615                agent_id: agent::ZED_AGENT_ID.clone(),
3616                title: "Archived Thread".into(),
3617                updated_at: Utc::now(),
3618                created_at: None,
3619                folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3620                main_worktree_paths: PathList::default(),
3621                archived: false,
3622            },
3623            window,
3624            cx,
3625        );
3626    });
3627    cx.run_until_parked();
3628
3629    assert_eq!(
3630        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3631        workspace_b,
3632        "should have activated the workspace matching the saved path_list"
3633    );
3634}
3635
3636#[gpui::test]
3637async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
3638    cx: &mut TestAppContext,
3639) {
3640    // Thread has no saved metadata but session_info has cwd. A matching
3641    // workspace is open. Expected: uses cwd to find and activate it.
3642    init_test(cx);
3643    let fs = FakeFs::new(cx.executor());
3644    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3645        .await;
3646    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3647        .await;
3648    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3649
3650    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3651    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3652
3653    let (multi_workspace, cx) =
3654        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3655
3656    let sidebar = setup_sidebar(&multi_workspace, cx);
3657
3658    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3659        mw.test_add_workspace(project_b, window, cx)
3660    });
3661    let workspace_a =
3662        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
3663
3664    // Start with workspace A active.
3665    multi_workspace.update_in(cx, |mw, window, cx| {
3666        let workspace = mw.workspaces().next().unwrap().clone();
3667        mw.activate(workspace, window, cx);
3668    });
3669    cx.run_until_parked();
3670    assert_eq!(
3671        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3672        workspace_a
3673    );
3674
3675    // No thread saved to the store – cwd is the only path hint.
3676    sidebar.update_in(cx, |sidebar, window, cx| {
3677        sidebar.activate_archived_thread(
3678            ThreadMetadata {
3679                session_id: acp::SessionId::new(Arc::from("unknown-session")),
3680                agent_id: agent::ZED_AGENT_ID.clone(),
3681                title: "CWD Thread".into(),
3682                updated_at: Utc::now(),
3683                created_at: None,
3684                folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]),
3685                main_worktree_paths: PathList::default(),
3686                archived: false,
3687            },
3688            window,
3689            cx,
3690        );
3691    });
3692    cx.run_until_parked();
3693
3694    assert_eq!(
3695        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3696        workspace_b,
3697        "should have activated the workspace matching the cwd"
3698    );
3699}
3700
3701#[gpui::test]
3702async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
3703    cx: &mut TestAppContext,
3704) {
3705    // Thread has no saved metadata and no cwd. Expected: falls back to
3706    // the currently active workspace.
3707    init_test(cx);
3708    let fs = FakeFs::new(cx.executor());
3709    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3710        .await;
3711    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3712        .await;
3713    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3714
3715    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3716    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3717
3718    let (multi_workspace, cx) =
3719        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3720
3721    let sidebar = setup_sidebar(&multi_workspace, cx);
3722
3723    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3724        mw.test_add_workspace(project_b, window, cx)
3725    });
3726
3727    // Activate workspace B (index 1) to make it the active one.
3728    multi_workspace.update_in(cx, |mw, window, cx| {
3729        let workspace = mw.workspaces().nth(1).unwrap().clone();
3730        mw.activate(workspace, window, cx);
3731    });
3732    cx.run_until_parked();
3733    assert_eq!(
3734        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3735        workspace_b
3736    );
3737
3738    // No saved thread, no cwd – should fall back to the active workspace.
3739    sidebar.update_in(cx, |sidebar, window, cx| {
3740        sidebar.activate_archived_thread(
3741            ThreadMetadata {
3742                session_id: acp::SessionId::new(Arc::from("no-context-session")),
3743                agent_id: agent::ZED_AGENT_ID.clone(),
3744                title: "Contextless Thread".into(),
3745                updated_at: Utc::now(),
3746                created_at: None,
3747                folder_paths: PathList::default(),
3748                main_worktree_paths: PathList::default(),
3749                archived: false,
3750            },
3751            window,
3752            cx,
3753        );
3754    });
3755    cx.run_until_parked();
3756
3757    assert_eq!(
3758        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
3759        workspace_b,
3760        "should have stayed on the active workspace when no path info is available"
3761    );
3762}
3763
3764#[gpui::test]
3765async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
3766    // Thread has saved metadata pointing to a path with no open workspace.
3767    // Expected: opens a new workspace for that path.
3768    init_test(cx);
3769    let fs = FakeFs::new(cx.executor());
3770    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3771        .await;
3772    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3773        .await;
3774    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3775
3776    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3777
3778    let (multi_workspace, cx) =
3779        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3780
3781    let sidebar = setup_sidebar(&multi_workspace, cx);
3782
3783    // Save a thread with path_list pointing to project-b – which has no
3784    // open workspace.
3785    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
3786    let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
3787
3788    assert_eq!(
3789        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3790        1,
3791        "should start with one workspace"
3792    );
3793
3794    sidebar.update_in(cx, |sidebar, window, cx| {
3795        sidebar.activate_archived_thread(
3796            ThreadMetadata {
3797                session_id: session_id.clone(),
3798                agent_id: agent::ZED_AGENT_ID.clone(),
3799                title: "New WS Thread".into(),
3800                updated_at: Utc::now(),
3801                created_at: None,
3802                folder_paths: path_list_b,
3803                main_worktree_paths: PathList::default(),
3804                archived: false,
3805            },
3806            window,
3807            cx,
3808        );
3809    });
3810    cx.run_until_parked();
3811
3812    assert_eq!(
3813        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
3814        2,
3815        "should have opened a second workspace for the archived thread's saved paths"
3816    );
3817}
3818
3819#[gpui::test]
3820async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
3821    init_test(cx);
3822    let fs = FakeFs::new(cx.executor());
3823    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3824        .await;
3825    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3826        .await;
3827    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3828
3829    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3830    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3831
3832    let multi_workspace_a =
3833        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3834    let multi_workspace_b =
3835        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
3836
3837    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3838    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
3839
3840    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
3841    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
3842
3843    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
3844    let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
3845
3846    let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
3847
3848    sidebar.update_in(cx_a, |sidebar, window, cx| {
3849        sidebar.activate_archived_thread(
3850            ThreadMetadata {
3851                session_id: session_id.clone(),
3852                agent_id: agent::ZED_AGENT_ID.clone(),
3853                title: "Cross Window Thread".into(),
3854                updated_at: Utc::now(),
3855                created_at: None,
3856                folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3857                main_worktree_paths: PathList::default(),
3858                archived: false,
3859            },
3860            window,
3861            cx,
3862        );
3863    });
3864    cx_a.run_until_parked();
3865
3866    assert_eq!(
3867        multi_workspace_a
3868            .read_with(cx_a, |mw, _| mw.workspaces().count())
3869            .unwrap(),
3870        1,
3871        "should not add the other window's workspace into the current window"
3872    );
3873    assert_eq!(
3874        multi_workspace_b
3875            .read_with(cx_a, |mw, _| mw.workspaces().count())
3876            .unwrap(),
3877        1,
3878        "should reuse the existing workspace in the other window"
3879    );
3880    assert!(
3881        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
3882        "should activate the window that already owns the matching workspace"
3883    );
3884    sidebar.read_with(cx_a, |sidebar, _| {
3885            assert!(
3886                !matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id: id, .. }) if id == &session_id),
3887                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
3888            );
3889        });
3890}
3891
3892#[gpui::test]
3893async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
3894    cx: &mut TestAppContext,
3895) {
3896    init_test(cx);
3897    let fs = FakeFs::new(cx.executor());
3898    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3899        .await;
3900    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3901        .await;
3902    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3903
3904    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3905    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3906
3907    let multi_workspace_a =
3908        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3909    let multi_workspace_b =
3910        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
3911
3912    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3913    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
3914
3915    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
3916    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
3917
3918    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
3919    let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
3920    let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
3921    let _panel_b = add_agent_panel(&workspace_b, cx_b);
3922
3923    let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
3924
3925    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
3926        sidebar.activate_archived_thread(
3927            ThreadMetadata {
3928                session_id: session_id.clone(),
3929                agent_id: agent::ZED_AGENT_ID.clone(),
3930                title: "Cross Window Thread".into(),
3931                updated_at: Utc::now(),
3932                created_at: None,
3933                folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
3934                main_worktree_paths: PathList::default(),
3935                archived: false,
3936            },
3937            window,
3938            cx,
3939        );
3940    });
3941    cx_a.run_until_parked();
3942
3943    assert_eq!(
3944        multi_workspace_a
3945            .read_with(cx_a, |mw, _| mw.workspaces().count())
3946            .unwrap(),
3947        1,
3948        "should not add the other window's workspace into the current window"
3949    );
3950    assert_eq!(
3951        multi_workspace_b
3952            .read_with(cx_a, |mw, _| mw.workspaces().count())
3953            .unwrap(),
3954        1,
3955        "should reuse the existing workspace in the other window"
3956    );
3957    assert!(
3958        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
3959        "should activate the window that already owns the matching workspace"
3960    );
3961    sidebar_a.read_with(cx_a, |sidebar, _| {
3962            assert!(
3963                !matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id: id, .. }) if id == &session_id),
3964                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
3965            );
3966        });
3967    sidebar_b.read_with(cx_b, |sidebar, _| {
3968        assert_active_thread(
3969            sidebar,
3970            &session_id,
3971            "target window's sidebar should eagerly focus the activated archived thread",
3972        );
3973    });
3974}
3975
3976#[gpui::test]
3977async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
3978    cx: &mut TestAppContext,
3979) {
3980    init_test(cx);
3981    let fs = FakeFs::new(cx.executor());
3982    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3983        .await;
3984    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3985
3986    let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3987    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3988
3989    let multi_workspace_b =
3990        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
3991    let multi_workspace_a =
3992        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3993
3994    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
3995    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
3996
3997    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
3998    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
3999
4000    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
4001    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
4002
4003    let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
4004
4005    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
4006        sidebar.activate_archived_thread(
4007            ThreadMetadata {
4008                session_id: session_id.clone(),
4009                agent_id: agent::ZED_AGENT_ID.clone(),
4010                title: "Current Window Thread".into(),
4011                updated_at: Utc::now(),
4012                created_at: None,
4013                folder_paths: PathList::new(&[PathBuf::from("/project-a")]),
4014                main_worktree_paths: PathList::default(),
4015                archived: false,
4016            },
4017            window,
4018            cx,
4019        );
4020    });
4021    cx_a.run_until_parked();
4022
4023    assert!(
4024        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
4025        "should keep activation in the current window when it already has a matching workspace"
4026    );
4027    sidebar_a.read_with(cx_a, |sidebar, _| {
4028        assert_active_thread(
4029            sidebar,
4030            &session_id,
4031            "current window's sidebar should eagerly focus the activated archived thread",
4032        );
4033    });
4034    assert_eq!(
4035        multi_workspace_a
4036            .read_with(cx_a, |mw, _| mw.workspaces().count())
4037            .unwrap(),
4038        1,
4039        "current window should continue reusing its existing workspace"
4040    );
4041    assert_eq!(
4042        multi_workspace_b
4043            .read_with(cx_a, |mw, _| mw.workspaces().count())
4044            .unwrap(),
4045        1,
4046        "other windows should not be activated just because they also match the saved paths"
4047    );
4048}
4049
4050#[gpui::test]
4051async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
4052    // Regression test: archive_thread previously always loaded the next thread
4053    // through group_workspace (the main workspace's ProjectHeader), even when
4054    // the next thread belonged to an absorbed linked-worktree workspace. That
4055    // caused the worktree thread to be loaded in the main panel, which bound it
4056    // to the main project and corrupted its stored folder_paths.
4057    //
4058    // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
4059    // falling back to group_workspace only for Closed workspaces.
4060    agent_ui::test_support::init_test(cx);
4061    cx.update(|cx| {
4062        cx.update_flags(false, vec!["agent-v2".into()]);
4063        ThreadStore::init_global(cx);
4064        ThreadMetadataStore::init_global(cx);
4065        language_model::LanguageModelRegistry::test(cx);
4066        prompt_store::init(cx);
4067    });
4068
4069    let fs = FakeFs::new(cx.executor());
4070
4071    fs.insert_tree(
4072        "/project",
4073        serde_json::json!({
4074            ".git": {},
4075            "src": {},
4076        }),
4077    )
4078    .await;
4079
4080    fs.add_linked_worktree_for_repo(
4081        Path::new("/project/.git"),
4082        false,
4083        git::repository::Worktree {
4084            path: std::path::PathBuf::from("/wt-feature-a"),
4085            ref_name: Some("refs/heads/feature-a".into()),
4086            sha: "aaa".into(),
4087            is_main: false,
4088        },
4089    )
4090    .await;
4091
4092    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4093
4094    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4095    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4096
4097    main_project
4098        .update(cx, |p, cx| p.git_scans_complete(cx))
4099        .await;
4100    worktree_project
4101        .update(cx, |p, cx| p.git_scans_complete(cx))
4102        .await;
4103
4104    let (multi_workspace, cx) =
4105        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
4106
4107    let sidebar = setup_sidebar(&multi_workspace, cx);
4108
4109    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4110        mw.test_add_workspace(worktree_project.clone(), window, cx)
4111    });
4112
4113    // Activate main workspace so the sidebar tracks the main panel.
4114    multi_workspace.update_in(cx, |mw, window, cx| {
4115        let workspace = mw.workspaces().next().unwrap().clone();
4116        mw.activate(workspace, window, cx);
4117    });
4118
4119    let main_workspace =
4120        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4121    let main_panel = add_agent_panel(&main_workspace, cx);
4122    let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
4123
4124    // Open Thread 2 in the main panel and keep it running.
4125    let connection = StubAgentConnection::new();
4126    open_thread_with_connection(&main_panel, connection.clone(), cx);
4127    send_message(&main_panel, cx);
4128
4129    let thread2_session_id = active_session_id(&main_panel, cx);
4130
4131    cx.update(|_, cx| {
4132        connection.send_update(
4133            thread2_session_id.clone(),
4134            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4135            cx,
4136        );
4137    });
4138
4139    // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
4140    save_thread_metadata(
4141        thread2_session_id.clone(),
4142        "Thread 2".into(),
4143        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4144        None,
4145        &main_project,
4146        cx,
4147    );
4148
4149    // Save thread 1's metadata with the worktree path and an older timestamp so
4150    // it sorts below thread 2. archive_thread will find it as the "next" candidate.
4151    let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
4152    save_thread_metadata(
4153        thread1_session_id,
4154        "Thread 1".into(),
4155        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4156        None,
4157        &worktree_project,
4158        cx,
4159    );
4160
4161    cx.run_until_parked();
4162
4163    // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
4164    let entries_before = visible_entries_as_strings(&sidebar, cx);
4165    assert!(
4166        entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
4167        "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
4168        entries_before
4169    );
4170
4171    // The sidebar should track T2 as the focused thread (derived from the
4172    // main panel's active view).
4173    sidebar.read_with(cx, |s, _| {
4174        assert_active_thread(
4175            s,
4176            &thread2_session_id,
4177            "focused thread should be Thread 2 before archiving",
4178        );
4179    });
4180
4181    // Archive thread 2.
4182    sidebar.update_in(cx, |sidebar, window, cx| {
4183        sidebar.archive_thread(&thread2_session_id, window, cx);
4184    });
4185
4186    cx.run_until_parked();
4187
4188    // The main panel's active thread must still be thread 2.
4189    let main_active = main_panel.read_with(cx, |panel, cx| {
4190        panel
4191            .active_agent_thread(cx)
4192            .map(|t| t.read(cx).session_id().clone())
4193    });
4194    assert_eq!(
4195        main_active,
4196        Some(thread2_session_id.clone()),
4197        "main panel should not have been taken over by loading the linked-worktree thread T1; \
4198             before the fix, archive_thread used group_workspace instead of next.workspace, \
4199             causing T1 to be loaded in the wrong panel"
4200    );
4201
4202    // Thread 1 should still appear in the sidebar with its worktree chip
4203    // (Thread 2 was archived so it is gone from the list).
4204    let entries_after = visible_entries_as_strings(&sidebar, cx);
4205    assert!(
4206        entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
4207        "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
4208        entries_after
4209    );
4210}
4211
4212#[gpui::test]
4213async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
4214    // When a multi-root workspace (e.g. [/other, /project]) shares a
4215    // repo with a single-root workspace (e.g. [/project]), linked
4216    // worktree threads from the shared repo should only appear under
4217    // the dedicated group [project], not under [other, project].
4218    init_test(cx);
4219    let fs = FakeFs::new(cx.executor());
4220
4221    // Two independent repos, each with their own git history.
4222    fs.insert_tree(
4223        "/project",
4224        serde_json::json!({
4225            ".git": {},
4226            "src": {},
4227        }),
4228    )
4229    .await;
4230    fs.insert_tree(
4231        "/other",
4232        serde_json::json!({
4233            ".git": {},
4234            "src": {},
4235        }),
4236    )
4237    .await;
4238
4239    // Register the linked worktree in the main repo.
4240    fs.add_linked_worktree_for_repo(
4241        Path::new("/project/.git"),
4242        false,
4243        git::repository::Worktree {
4244            path: std::path::PathBuf::from("/wt-feature-a"),
4245            ref_name: Some("refs/heads/feature-a".into()),
4246            sha: "aaa".into(),
4247            is_main: false,
4248        },
4249    )
4250    .await;
4251
4252    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4253
4254    // Workspace 1: just /project.
4255    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4256    project_only
4257        .update(cx, |p, cx| p.git_scans_complete(cx))
4258        .await;
4259
4260    // Workspace 2: /other and /project together (multi-root).
4261    let multi_root =
4262        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
4263    multi_root
4264        .update(cx, |p, cx| p.git_scans_complete(cx))
4265        .await;
4266
4267    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4268    worktree_project
4269        .update(cx, |p, cx| p.git_scans_complete(cx))
4270        .await;
4271
4272    let (multi_workspace, cx) =
4273        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
4274    let sidebar = setup_sidebar(&multi_workspace, cx);
4275    multi_workspace.update_in(cx, |mw, window, cx| {
4276        mw.test_add_workspace(multi_root.clone(), window, cx);
4277    });
4278
4279    // Save a thread under the linked worktree path.
4280    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
4281
4282    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4283    cx.run_until_parked();
4284
4285    // The thread should appear only under [project] (the dedicated
4286    // group for the /project repo), not under [other, project].
4287    assert_eq!(
4288        visible_entries_as_strings(&sidebar, cx),
4289        vec![
4290            "v [other, project]",
4291            "  [+ New Thread]",
4292            "v [project]",
4293            "  Worktree Thread {wt-feature-a}",
4294        ]
4295    );
4296}
4297
4298#[gpui::test]
4299async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
4300    let project = init_test_project_with_agent_panel("/my-project", cx).await;
4301    let (multi_workspace, cx) =
4302        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4303    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4304
4305    let switcher_ids =
4306        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
4307            sidebar.read_with(cx, |sidebar, cx| {
4308                let switcher = sidebar
4309                    .thread_switcher
4310                    .as_ref()
4311                    .expect("switcher should be open");
4312                switcher
4313                    .read(cx)
4314                    .entries()
4315                    .iter()
4316                    .map(|e| e.session_id.clone())
4317                    .collect()
4318            })
4319        };
4320
4321    let switcher_selected_id =
4322        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
4323            sidebar.read_with(cx, |sidebar, cx| {
4324                let switcher = sidebar
4325                    .thread_switcher
4326                    .as_ref()
4327                    .expect("switcher should be open");
4328                let s = switcher.read(cx);
4329                s.selected_entry()
4330                    .expect("should have selection")
4331                    .session_id
4332                    .clone()
4333            })
4334        };
4335
4336    // ── Setup: create three threads with distinct created_at times ──────
4337    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
4338    // We send messages in each so they also get last_message_sent_or_queued timestamps.
4339    let connection_c = StubAgentConnection::new();
4340    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4341        acp::ContentChunk::new("Done C".into()),
4342    )]);
4343    open_thread_with_connection(&panel, connection_c, cx);
4344    send_message(&panel, cx);
4345    let session_id_c = active_session_id(&panel, cx);
4346    save_thread_metadata(
4347        session_id_c.clone(),
4348        "Thread C".into(),
4349        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4350        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
4351        &project,
4352        cx,
4353    );
4354
4355    let connection_b = StubAgentConnection::new();
4356    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4357        acp::ContentChunk::new("Done B".into()),
4358    )]);
4359    open_thread_with_connection(&panel, connection_b, cx);
4360    send_message(&panel, cx);
4361    let session_id_b = active_session_id(&panel, cx);
4362    save_thread_metadata(
4363        session_id_b.clone(),
4364        "Thread B".into(),
4365        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4366        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
4367        &project,
4368        cx,
4369    );
4370
4371    let connection_a = StubAgentConnection::new();
4372    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4373        acp::ContentChunk::new("Done A".into()),
4374    )]);
4375    open_thread_with_connection(&panel, connection_a, cx);
4376    send_message(&panel, cx);
4377    let session_id_a = active_session_id(&panel, cx);
4378    save_thread_metadata(
4379        session_id_a.clone(),
4380        "Thread A".into(),
4381        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
4382        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
4383        &project,
4384        cx,
4385    );
4386
4387    // All three threads are now live. Thread A was opened last, so it's
4388    // the one being viewed. Opening each thread called record_thread_access,
4389    // so all three have last_accessed_at set.
4390    // Access order is: A (most recent), B, C (oldest).
4391
4392    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
4393    focus_sidebar(&sidebar, cx);
4394    sidebar.update_in(cx, |sidebar, window, cx| {
4395        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4396    });
4397    cx.run_until_parked();
4398
4399    // All three have last_accessed_at, so they sort by access time.
4400    // A was accessed most recently (it's the currently viewed thread),
4401    // then B, then C.
4402    assert_eq!(
4403        switcher_ids(&sidebar, cx),
4404        vec![
4405            session_id_a.clone(),
4406            session_id_b.clone(),
4407            session_id_c.clone()
4408        ],
4409    );
4410    // First ctrl-tab selects the second entry (B).
4411    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
4412
4413    // Dismiss the switcher without confirming.
4414    sidebar.update_in(cx, |sidebar, _window, cx| {
4415        sidebar.dismiss_thread_switcher(cx);
4416    });
4417    cx.run_until_parked();
4418
4419    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
4420    sidebar.update_in(cx, |sidebar, window, cx| {
4421        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4422    });
4423    cx.run_until_parked();
4424
4425    // Cycle twice to land on Thread C (index 2).
4426    sidebar.read_with(cx, |sidebar, cx| {
4427        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4428        assert_eq!(switcher.read(cx).selected_index(), 1);
4429    });
4430    sidebar.update_in(cx, |sidebar, _window, cx| {
4431        sidebar
4432            .thread_switcher
4433            .as_ref()
4434            .unwrap()
4435            .update(cx, |s, cx| s.cycle_selection(cx));
4436    });
4437    cx.run_until_parked();
4438    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
4439
4440    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
4441
4442    // Confirm on Thread C.
4443    sidebar.update_in(cx, |sidebar, window, cx| {
4444        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4445        let focus = switcher.focus_handle(cx);
4446        focus.dispatch_action(&menu::Confirm, window, cx);
4447    });
4448    cx.run_until_parked();
4449
4450    // Switcher should be dismissed after confirm.
4451    sidebar.read_with(cx, |sidebar, _cx| {
4452        assert!(
4453            sidebar.thread_switcher.is_none(),
4454            "switcher should be dismissed"
4455        );
4456    });
4457
4458    sidebar.update(cx, |sidebar, _cx| {
4459        let last_accessed = sidebar
4460            .thread_last_accessed
4461            .keys()
4462            .cloned()
4463            .collect::<Vec<_>>();
4464        assert_eq!(last_accessed.len(), 1);
4465        assert!(last_accessed.contains(&session_id_c));
4466        assert!(
4467            sidebar
4468                .active_entry
4469                .as_ref()
4470                .expect("active_entry should be set")
4471                .is_active_thread(&session_id_c)
4472        );
4473    });
4474
4475    sidebar.update_in(cx, |sidebar, window, cx| {
4476        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4477    });
4478    cx.run_until_parked();
4479
4480    assert_eq!(
4481        switcher_ids(&sidebar, cx),
4482        vec![
4483            session_id_c.clone(),
4484            session_id_a.clone(),
4485            session_id_b.clone()
4486        ],
4487    );
4488
4489    // Confirm on Thread A.
4490    sidebar.update_in(cx, |sidebar, window, cx| {
4491        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4492        let focus = switcher.focus_handle(cx);
4493        focus.dispatch_action(&menu::Confirm, window, cx);
4494    });
4495    cx.run_until_parked();
4496
4497    sidebar.update(cx, |sidebar, _cx| {
4498        let last_accessed = sidebar
4499            .thread_last_accessed
4500            .keys()
4501            .cloned()
4502            .collect::<Vec<_>>();
4503        assert_eq!(last_accessed.len(), 2);
4504        assert!(last_accessed.contains(&session_id_c));
4505        assert!(last_accessed.contains(&session_id_a));
4506        assert!(
4507            sidebar
4508                .active_entry
4509                .as_ref()
4510                .expect("active_entry should be set")
4511                .is_active_thread(&session_id_a)
4512        );
4513    });
4514
4515    sidebar.update_in(cx, |sidebar, window, cx| {
4516        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4517    });
4518    cx.run_until_parked();
4519
4520    assert_eq!(
4521        switcher_ids(&sidebar, cx),
4522        vec![
4523            session_id_a.clone(),
4524            session_id_c.clone(),
4525            session_id_b.clone(),
4526        ],
4527    );
4528
4529    sidebar.update_in(cx, |sidebar, _window, cx| {
4530        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4531        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
4532    });
4533    cx.run_until_parked();
4534
4535    // Confirm on Thread B.
4536    sidebar.update_in(cx, |sidebar, window, cx| {
4537        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4538        let focus = switcher.focus_handle(cx);
4539        focus.dispatch_action(&menu::Confirm, window, cx);
4540    });
4541    cx.run_until_parked();
4542
4543    sidebar.update(cx, |sidebar, _cx| {
4544        let last_accessed = sidebar
4545            .thread_last_accessed
4546            .keys()
4547            .cloned()
4548            .collect::<Vec<_>>();
4549        assert_eq!(last_accessed.len(), 3);
4550        assert!(last_accessed.contains(&session_id_c));
4551        assert!(last_accessed.contains(&session_id_a));
4552        assert!(last_accessed.contains(&session_id_b));
4553        assert!(
4554            sidebar
4555                .active_entry
4556                .as_ref()
4557                .expect("active_entry should be set")
4558                .is_active_thread(&session_id_b)
4559        );
4560    });
4561
4562    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
4563    // This thread was never opened in a panel — it only exists in metadata.
4564    save_thread_metadata(
4565        acp::SessionId::new(Arc::from("thread-historical")),
4566        "Historical Thread".into(),
4567        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
4568        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
4569        &project,
4570        cx,
4571    );
4572
4573    sidebar.update_in(cx, |sidebar, window, cx| {
4574        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4575    });
4576    cx.run_until_parked();
4577
4578    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
4579    // so it falls to tier 3 (sorted by created_at). It should appear after all
4580    // accessed threads, even though its created_at (June 2024) is much later
4581    // than the others.
4582    //
4583    // But the live threads (A, B, C) each had send_message called which sets
4584    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
4585    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
4586    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
4587
4588    let ids = switcher_ids(&sidebar, cx);
4589    assert_eq!(
4590        ids,
4591        vec![
4592            session_id_b.clone(),
4593            session_id_a.clone(),
4594            session_id_c.clone(),
4595            session_id_hist.clone()
4596        ],
4597    );
4598
4599    sidebar.update_in(cx, |sidebar, _window, cx| {
4600        sidebar.dismiss_thread_switcher(cx);
4601    });
4602    cx.run_until_parked();
4603
4604    // ── 4. Add another historical thread with older created_at ─────────
4605    save_thread_metadata(
4606        acp::SessionId::new(Arc::from("thread-old-historical")),
4607        "Old Historical Thread".into(),
4608        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
4609        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
4610        &project,
4611        cx,
4612    );
4613
4614    sidebar.update_in(cx, |sidebar, window, cx| {
4615        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4616    });
4617    cx.run_until_parked();
4618
4619    // Both historical threads have no access or message times. They should
4620    // appear after accessed threads, sorted by created_at (newest first).
4621    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
4622    let ids = switcher_ids(&sidebar, cx);
4623    assert_eq!(
4624        ids,
4625        vec![
4626            session_id_b,
4627            session_id_a,
4628            session_id_c,
4629            session_id_hist,
4630            session_id_old_hist,
4631        ],
4632    );
4633
4634    sidebar.update_in(cx, |sidebar, _window, cx| {
4635        sidebar.dismiss_thread_switcher(cx);
4636    });
4637    cx.run_until_parked();
4638}
4639
4640#[gpui::test]
4641async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
4642    let project = init_test_project("/my-project", cx).await;
4643    let (multi_workspace, cx) =
4644        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4645    let sidebar = setup_sidebar(&multi_workspace, cx);
4646
4647    save_thread_metadata(
4648        acp::SessionId::new(Arc::from("thread-to-archive")),
4649        "Thread To Archive".into(),
4650        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4651        None,
4652        &project,
4653        cx,
4654    );
4655    cx.run_until_parked();
4656
4657    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4658    cx.run_until_parked();
4659
4660    let entries = visible_entries_as_strings(&sidebar, cx);
4661    assert!(
4662        entries.iter().any(|e| e.contains("Thread To Archive")),
4663        "expected thread to be visible before archiving, got: {entries:?}"
4664    );
4665
4666    sidebar.update_in(cx, |sidebar, window, cx| {
4667        sidebar.archive_thread(
4668            &acp::SessionId::new(Arc::from("thread-to-archive")),
4669            window,
4670            cx,
4671        );
4672    });
4673    cx.run_until_parked();
4674
4675    let entries = visible_entries_as_strings(&sidebar, cx);
4676    assert!(
4677        !entries.iter().any(|e| e.contains("Thread To Archive")),
4678        "expected thread to be hidden after archiving, got: {entries:?}"
4679    );
4680
4681    cx.update(|_, cx| {
4682        let store = ThreadMetadataStore::global(cx);
4683        let archived: Vec<_> = store.read(cx).archived_entries().collect();
4684        assert_eq!(archived.len(), 1);
4685        assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive");
4686        assert!(archived[0].archived);
4687    });
4688}
4689
4690#[gpui::test]
4691async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
4692    let project = init_test_project("/my-project", cx).await;
4693    let (multi_workspace, cx) =
4694        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4695    let sidebar = setup_sidebar(&multi_workspace, cx);
4696
4697    save_thread_metadata(
4698        acp::SessionId::new(Arc::from("visible-thread")),
4699        "Visible Thread".into(),
4700        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4701        None,
4702        &project,
4703        cx,
4704    );
4705
4706    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
4707    save_thread_metadata(
4708        archived_thread_session_id.clone(),
4709        "Archived Thread".into(),
4710        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4711        None,
4712        &project,
4713        cx,
4714    );
4715
4716    cx.update(|_, cx| {
4717        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4718            store.archive(&archived_thread_session_id, cx)
4719        })
4720    });
4721    cx.run_until_parked();
4722
4723    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4724    cx.run_until_parked();
4725
4726    let entries = visible_entries_as_strings(&sidebar, cx);
4727    assert!(
4728        entries.iter().any(|e| e.contains("Visible Thread")),
4729        "expected visible thread in sidebar, got: {entries:?}"
4730    );
4731    assert!(
4732        !entries.iter().any(|e| e.contains("Archived Thread")),
4733        "expected archived thread to be hidden from sidebar, got: {entries:?}"
4734    );
4735
4736    cx.update(|_, cx| {
4737        let store = ThreadMetadataStore::global(cx);
4738        let all: Vec<_> = store.read(cx).entries().collect();
4739        assert_eq!(
4740            all.len(),
4741            2,
4742            "expected 2 total entries in the store, got: {}",
4743            all.len()
4744        );
4745
4746        let archived: Vec<_> = store.read(cx).archived_entries().collect();
4747        assert_eq!(archived.len(), 1);
4748        assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread");
4749    });
4750}
4751
4752#[gpui::test]
4753async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
4754    // When only a linked worktree workspace is open (not the main repo),
4755    // threads saved against the main repo should still appear in the sidebar.
4756    init_test(cx);
4757    let fs = FakeFs::new(cx.executor());
4758
4759    // Create the main repo with a linked worktree.
4760    fs.insert_tree(
4761        "/project",
4762        serde_json::json!({
4763            ".git": {
4764                "worktrees": {
4765                    "feature-a": {
4766                        "commondir": "../../",
4767                        "HEAD": "ref: refs/heads/feature-a",
4768                    },
4769                },
4770            },
4771            "src": {},
4772        }),
4773    )
4774    .await;
4775
4776    fs.insert_tree(
4777        "/wt-feature-a",
4778        serde_json::json!({
4779            ".git": "gitdir: /project/.git/worktrees/feature-a",
4780            "src": {},
4781        }),
4782    )
4783    .await;
4784
4785    fs.add_linked_worktree_for_repo(
4786        std::path::Path::new("/project/.git"),
4787        false,
4788        git::repository::Worktree {
4789            path: std::path::PathBuf::from("/wt-feature-a"),
4790            ref_name: Some("refs/heads/feature-a".into()),
4791            sha: "abc".into(),
4792            is_main: false,
4793        },
4794    )
4795    .await;
4796
4797    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4798
4799    // Only open the linked worktree as a workspace — NOT the main repo.
4800    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4801    worktree_project
4802        .update(cx, |p, cx| p.git_scans_complete(cx))
4803        .await;
4804
4805    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4806    main_project
4807        .update(cx, |p, cx| p.git_scans_complete(cx))
4808        .await;
4809
4810    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4811        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
4812    });
4813    let sidebar = setup_sidebar(&multi_workspace, cx);
4814
4815    // Save a thread against the MAIN repo path.
4816    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
4817
4818    // Save a thread against the linked worktree path.
4819    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
4820
4821    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4822    cx.run_until_parked();
4823
4824    // Both threads should be visible: the worktree thread by direct lookup,
4825    // and the main repo thread because the workspace is a linked worktree
4826    // and we also query the main repo path.
4827    let entries = visible_entries_as_strings(&sidebar, cx);
4828    assert!(
4829        entries.iter().any(|e| e.contains("Main Repo Thread")),
4830        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
4831    );
4832    assert!(
4833        entries.iter().any(|e| e.contains("Worktree Thread")),
4834        "expected worktree thread to be visible, got: {entries:?}"
4835    );
4836}
4837
4838async fn init_multi_project_test(
4839    paths: &[&str],
4840    cx: &mut TestAppContext,
4841) -> (Arc<FakeFs>, Entity<project::Project>) {
4842    agent_ui::test_support::init_test(cx);
4843    cx.update(|cx| {
4844        cx.update_flags(false, vec!["agent-v2".into()]);
4845        ThreadStore::init_global(cx);
4846        ThreadMetadataStore::init_global(cx);
4847        language_model::LanguageModelRegistry::test(cx);
4848        prompt_store::init(cx);
4849    });
4850    let fs = FakeFs::new(cx.executor());
4851    for path in paths {
4852        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
4853            .await;
4854    }
4855    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4856    let project =
4857        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
4858    (fs, project)
4859}
4860
4861async fn add_test_project(
4862    path: &str,
4863    fs: &Arc<FakeFs>,
4864    multi_workspace: &Entity<MultiWorkspace>,
4865    cx: &mut gpui::VisualTestContext,
4866) -> Entity<Workspace> {
4867    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
4868    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4869        mw.test_add_workspace(project, window, cx)
4870    });
4871    cx.run_until_parked();
4872    workspace
4873}
4874
4875#[gpui::test]
4876async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
4877    let (fs, project_a) =
4878        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
4879    let (multi_workspace, cx) =
4880        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4881    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
4882
4883    // Sidebar starts closed. Initial workspace A is transient.
4884    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
4885    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
4886    assert_eq!(
4887        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4888        1
4889    );
4890    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
4891
4892    // Add B — replaces A as the transient workspace.
4893    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
4894    assert_eq!(
4895        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4896        1
4897    );
4898    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
4899
4900    // Add C — replaces B as the transient workspace.
4901    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
4902    assert_eq!(
4903        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4904        1
4905    );
4906    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
4907}
4908
4909#[gpui::test]
4910async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
4911    let (fs, project_a) = init_multi_project_test(
4912        &["/project-a", "/project-b", "/project-c", "/project-d"],
4913        cx,
4914    )
4915    .await;
4916    let (multi_workspace, cx) =
4917        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4918    let _sidebar = setup_sidebar(&multi_workspace, cx);
4919    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
4920
4921    // Add B — retained since sidebar is open.
4922    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
4923    assert_eq!(
4924        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4925        2
4926    );
4927
4928    // Switch to A — B survives. (Switching from one internal workspace, to another)
4929    multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
4930    cx.run_until_parked();
4931    assert_eq!(
4932        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4933        2
4934    );
4935
4936    // Close sidebar — both A and B remain retained.
4937    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
4938    cx.run_until_parked();
4939    assert_eq!(
4940        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4941        2
4942    );
4943
4944    // Add C — added as new transient workspace. (switching from retained, to transient)
4945    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
4946    assert_eq!(
4947        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4948        3
4949    );
4950    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
4951
4952    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
4953    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
4954    assert_eq!(
4955        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4956        3
4957    );
4958    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
4959}
4960
4961#[gpui::test]
4962async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
4963    let (fs, project_a) =
4964        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
4965    let (multi_workspace, cx) =
4966        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4967    setup_sidebar_closed(&multi_workspace, cx);
4968
4969    // Add B — replaces A as the transient workspace (A is discarded).
4970    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
4971    assert_eq!(
4972        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4973        1
4974    );
4975    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
4976
4977    // Open sidebar — promotes the transient B to retained.
4978    multi_workspace.update_in(cx, |mw, window, cx| {
4979        mw.toggle_sidebar(window, cx);
4980    });
4981    cx.run_until_parked();
4982    assert_eq!(
4983        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4984        1
4985    );
4986    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
4987
4988    // Close sidebar — the retained B remains.
4989    multi_workspace.update_in(cx, |mw, window, cx| {
4990        mw.toggle_sidebar(window, cx);
4991    });
4992
4993    // Add C — added as new transient workspace.
4994    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
4995    assert_eq!(
4996        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4997        2
4998    );
4999    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
5000}
5001
5002#[gpui::test]
5003async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
5004    init_test(cx);
5005    let fs = FakeFs::new(cx.executor());
5006
5007    fs.insert_tree(
5008        "/project",
5009        serde_json::json!({
5010            ".git": {
5011                "worktrees": {
5012                    "feature-a": {
5013                        "commondir": "../../",
5014                        "HEAD": "ref: refs/heads/feature-a",
5015                    },
5016                },
5017            },
5018            "src": {},
5019        }),
5020    )
5021    .await;
5022
5023    fs.insert_tree(
5024        "/wt-feature-a",
5025        serde_json::json!({
5026            ".git": "gitdir: /project/.git/worktrees/feature-a",
5027            "src": {},
5028        }),
5029    )
5030    .await;
5031
5032    fs.add_linked_worktree_for_repo(
5033        Path::new("/project/.git"),
5034        false,
5035        git::repository::Worktree {
5036            path: PathBuf::from("/wt-feature-a"),
5037            ref_name: Some("refs/heads/feature-a".into()),
5038            sha: "abc".into(),
5039            is_main: false,
5040        },
5041    )
5042    .await;
5043
5044    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5045
5046    // Only a linked worktree workspace is open — no workspace for /project.
5047    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5048    worktree_project
5049        .update(cx, |p, cx| p.git_scans_complete(cx))
5050        .await;
5051
5052    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5053        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
5054    });
5055    let sidebar = setup_sidebar(&multi_workspace, cx);
5056
5057    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
5058    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
5059    cx.update(|_, cx| {
5060        let metadata = ThreadMetadata {
5061            session_id: legacy_session.clone(),
5062            agent_id: agent::ZED_AGENT_ID.clone(),
5063            title: "Legacy Main Thread".into(),
5064            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5065            created_at: None,
5066            folder_paths: PathList::new(&[PathBuf::from("/project")]),
5067            main_worktree_paths: PathList::default(),
5068            archived: false,
5069        };
5070        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
5071    });
5072    cx.run_until_parked();
5073
5074    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5075    cx.run_until_parked();
5076
5077    // The legacy thread should appear in the sidebar under the project group.
5078    let entries = visible_entries_as_strings(&sidebar, cx);
5079    assert!(
5080        entries.iter().any(|e| e.contains("Legacy Main Thread")),
5081        "legacy thread should be visible: {entries:?}",
5082    );
5083
5084    // Verify only 1 workspace before clicking.
5085    assert_eq!(
5086        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5087        1,
5088    );
5089
5090    // Focus and select the legacy thread, then confirm.
5091    focus_sidebar(&sidebar, cx);
5092    let thread_index = sidebar.read_with(cx, |sidebar, _| {
5093        sidebar
5094            .contents
5095            .entries
5096            .iter()
5097            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
5098            .expect("legacy thread should be in entries")
5099    });
5100    sidebar.update_in(cx, |sidebar, _window, _cx| {
5101        sidebar.selection = Some(thread_index);
5102    });
5103    cx.dispatch_action(Confirm);
5104    cx.run_until_parked();
5105
5106    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5107    let new_path_list =
5108        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
5109    assert_eq!(
5110        new_path_list,
5111        PathList::new(&[PathBuf::from("/project")]),
5112        "the new workspace should be for the main repo, not the linked worktree",
5113    );
5114}
5115
5116mod property_test {
5117    use super::*;
5118    use gpui::proptest::prelude::*;
5119
5120    struct UnopenedWorktree {
5121        path: String,
5122        main_workspace_path: String,
5123    }
5124
5125    struct TestState {
5126        fs: Arc<FakeFs>,
5127        thread_counter: u32,
5128        workspace_counter: u32,
5129        worktree_counter: u32,
5130        saved_thread_ids: Vec<acp::SessionId>,
5131        workspace_paths: Vec<String>,
5132        main_repo_indices: Vec<usize>,
5133        unopened_worktrees: Vec<UnopenedWorktree>,
5134    }
5135
5136    impl TestState {
5137        fn new(fs: Arc<FakeFs>, initial_workspace_path: String) -> Self {
5138            Self {
5139                fs,
5140                thread_counter: 0,
5141                workspace_counter: 1,
5142                worktree_counter: 0,
5143                saved_thread_ids: Vec::new(),
5144                workspace_paths: vec![initial_workspace_path],
5145                main_repo_indices: vec![0],
5146                unopened_worktrees: Vec::new(),
5147            }
5148        }
5149
5150        fn next_thread_id(&mut self) -> acp::SessionId {
5151            let id = self.thread_counter;
5152            self.thread_counter += 1;
5153            let session_id = acp::SessionId::new(Arc::from(format!("prop-thread-{id}")));
5154            self.saved_thread_ids.push(session_id.clone());
5155            session_id
5156        }
5157
5158        fn remove_thread(&mut self, index: usize) -> acp::SessionId {
5159            self.saved_thread_ids.remove(index)
5160        }
5161
5162        fn next_workspace_path(&mut self) -> String {
5163            let id = self.workspace_counter;
5164            self.workspace_counter += 1;
5165            format!("/prop-project-{id}")
5166        }
5167
5168        fn next_worktree_name(&mut self) -> String {
5169            let id = self.worktree_counter;
5170            self.worktree_counter += 1;
5171            format!("wt-{id}")
5172        }
5173    }
5174
5175    #[derive(Debug)]
5176    enum Operation {
5177        SaveThread { workspace_index: usize },
5178        SaveWorktreeThread { worktree_index: usize },
5179        DeleteThread { index: usize },
5180        ToggleAgentPanel,
5181        CreateDraftThread,
5182        AddWorkspace,
5183        OpenWorktreeAsWorkspace { worktree_index: usize },
5184        RemoveWorkspace { index: usize },
5185        SwitchWorkspace { index: usize },
5186        AddLinkedWorktree { workspace_index: usize },
5187    }
5188
5189    // Distribution (out of 20 slots):
5190    //   SaveThread:              5 slots (~23%)
5191    //   SaveWorktreeThread:      2 slots (~9%)
5192    //   DeleteThread:            2 slots (~9%)
5193    //   ToggleAgentPanel:        2 slots (~9%)
5194    //   CreateDraftThread:       2 slots (~9%)
5195    //   AddWorkspace:            1 slot  (~5%)
5196    //   OpenWorktreeAsWorkspace: 1 slot  (~5%)
5197    //   RemoveWorkspace:         1 slot  (~5%)
5198    //   SwitchWorkspace:         2 slots (~9%)
5199    //   AddLinkedWorktree:       4 slots (~18%)
5200    const DISTRIBUTION_SLOTS: u32 = 22;
5201
5202    impl TestState {
5203        fn generate_operation(&self, raw: u32) -> Operation {
5204            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
5205            let workspace_count = self.workspace_paths.len();
5206
5207            match raw % DISTRIBUTION_SLOTS {
5208                0..=4 => Operation::SaveThread {
5209                    workspace_index: extra % workspace_count,
5210                },
5211                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
5212                    worktree_index: extra % self.unopened_worktrees.len(),
5213                },
5214                5..=6 => Operation::SaveThread {
5215                    workspace_index: extra % workspace_count,
5216                },
5217                7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread {
5218                    index: extra % self.saved_thread_ids.len(),
5219                },
5220                7..=8 => Operation::SaveThread {
5221                    workspace_index: extra % workspace_count,
5222                },
5223                9..=10 => Operation::ToggleAgentPanel,
5224                11..=12 => Operation::CreateDraftThread,
5225                13 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace {
5226                    worktree_index: extra % self.unopened_worktrees.len(),
5227                },
5228                13 => Operation::AddWorkspace,
5229                14 if workspace_count > 1 => Operation::RemoveWorkspace {
5230                    index: extra % workspace_count,
5231                },
5232                14 => Operation::AddWorkspace,
5233                15..=16 => Operation::SwitchWorkspace {
5234                    index: extra % workspace_count,
5235                },
5236                17..=21 if !self.main_repo_indices.is_empty() => {
5237                    let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()];
5238                    Operation::AddLinkedWorktree {
5239                        workspace_index: main_index,
5240                    }
5241                }
5242                17..=21 => Operation::SaveThread {
5243                    workspace_index: extra % workspace_count,
5244                },
5245                _ => unreachable!(),
5246            }
5247        }
5248    }
5249
5250    fn save_thread_to_path(
5251        state: &mut TestState,
5252        project: &Entity<project::Project>,
5253        cx: &mut gpui::VisualTestContext,
5254    ) {
5255        let session_id = state.next_thread_id();
5256        let title: SharedString = format!("Thread {}", session_id).into();
5257        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
5258            .unwrap()
5259            + chrono::Duration::seconds(state.thread_counter as i64);
5260        save_thread_metadata(session_id, title, updated_at, None, project, cx);
5261    }
5262
5263    fn save_thread_to_path_with_main(
5264        state: &mut TestState,
5265        path_list: PathList,
5266        main_worktree_paths: PathList,
5267        cx: &mut gpui::VisualTestContext,
5268    ) {
5269        let session_id = state.next_thread_id();
5270        let title: SharedString = format!("Thread {}", session_id).into();
5271        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
5272            .unwrap()
5273            + chrono::Duration::seconds(state.thread_counter as i64);
5274        let metadata = ThreadMetadata {
5275            session_id,
5276            agent_id: agent::ZED_AGENT_ID.clone(),
5277            title,
5278            updated_at,
5279            created_at: None,
5280            folder_paths: path_list,
5281            main_worktree_paths,
5282            archived: false,
5283        };
5284        cx.update(|_, cx| {
5285            ThreadMetadataStore::global(cx)
5286                .update(cx, |store, cx| store.save_manually(metadata, cx))
5287        });
5288        cx.run_until_parked();
5289    }
5290
5291    async fn perform_operation(
5292        operation: Operation,
5293        state: &mut TestState,
5294        multi_workspace: &Entity<MultiWorkspace>,
5295        _sidebar: &Entity<Sidebar>,
5296        cx: &mut gpui::VisualTestContext,
5297    ) {
5298        match operation {
5299            Operation::SaveThread { workspace_index } => {
5300                let project = multi_workspace.read_with(cx, |mw, cx| {
5301                    mw.workspaces()
5302                        .nth(workspace_index)
5303                        .unwrap()
5304                        .read(cx)
5305                        .project()
5306                        .clone()
5307                });
5308                save_thread_to_path(state, &project, cx);
5309            }
5310            Operation::SaveWorktreeThread { worktree_index } => {
5311                let worktree = &state.unopened_worktrees[worktree_index];
5312                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
5313                let main_worktree_paths =
5314                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
5315                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
5316            }
5317            Operation::DeleteThread { index } => {
5318                let session_id = state.remove_thread(index);
5319                cx.update(|_, cx| {
5320                    ThreadMetadataStore::global(cx)
5321                        .update(cx, |store, cx| store.delete(session_id, cx));
5322                });
5323            }
5324            Operation::ToggleAgentPanel => {
5325                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5326                let panel_open =
5327                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
5328                workspace.update_in(cx, |workspace, window, cx| {
5329                    if panel_open {
5330                        workspace.close_panel::<AgentPanel>(window, cx);
5331                    } else {
5332                        workspace.open_panel::<AgentPanel>(window, cx);
5333                    }
5334                });
5335            }
5336            Operation::CreateDraftThread => {
5337                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5338                let panel =
5339                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
5340                if let Some(panel) = panel {
5341                    let connection = StubAgentConnection::new();
5342                    open_thread_with_connection(&panel, connection, cx);
5343                    cx.run_until_parked();
5344                }
5345                workspace.update_in(cx, |workspace, window, cx| {
5346                    workspace.focus_panel::<AgentPanel>(window, cx);
5347                });
5348            }
5349            Operation::AddWorkspace => {
5350                let path = state.next_workspace_path();
5351                state
5352                    .fs
5353                    .insert_tree(
5354                        &path,
5355                        serde_json::json!({
5356                            ".git": {},
5357                            "src": {},
5358                        }),
5359                    )
5360                    .await;
5361                let project = project::Project::test(
5362                    state.fs.clone() as Arc<dyn fs::Fs>,
5363                    [path.as_ref()],
5364                    cx,
5365                )
5366                .await;
5367                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5368                let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5369                    mw.test_add_workspace(project.clone(), window, cx)
5370                });
5371                add_agent_panel(&workspace, cx);
5372                let new_index = state.workspace_paths.len();
5373                state.workspace_paths.push(path);
5374                state.main_repo_indices.push(new_index);
5375            }
5376            Operation::OpenWorktreeAsWorkspace { worktree_index } => {
5377                let worktree = state.unopened_worktrees.remove(worktree_index);
5378                let project = project::Project::test(
5379                    state.fs.clone() as Arc<dyn fs::Fs>,
5380                    [worktree.path.as_ref()],
5381                    cx,
5382                )
5383                .await;
5384                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5385                let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5386                    mw.test_add_workspace(project.clone(), window, cx)
5387                });
5388                add_agent_panel(&workspace, cx);
5389                state.workspace_paths.push(worktree.path);
5390            }
5391            Operation::RemoveWorkspace { index } => {
5392                let removed = multi_workspace.update_in(cx, |mw, window, cx| {
5393                    let workspace = mw.workspaces().nth(index).unwrap().clone();
5394                    mw.remove(&workspace, window, cx)
5395                });
5396                if removed {
5397                    state.workspace_paths.remove(index);
5398                    state.main_repo_indices.retain(|i| *i != index);
5399                    for i in &mut state.main_repo_indices {
5400                        if *i > index {
5401                            *i -= 1;
5402                        }
5403                    }
5404                }
5405            }
5406            Operation::SwitchWorkspace { index } => {
5407                let workspace = multi_workspace
5408                    .read_with(cx, |mw, _| mw.workspaces().nth(index).unwrap().clone());
5409                multi_workspace.update_in(cx, |mw, window, cx| {
5410                    mw.activate(workspace, window, cx);
5411                });
5412            }
5413            Operation::AddLinkedWorktree { workspace_index } => {
5414                let main_path = state.workspace_paths[workspace_index].clone();
5415                let dot_git = format!("{}/.git", main_path);
5416                let worktree_name = state.next_worktree_name();
5417                let worktree_path = format!("/worktrees/{}", worktree_name);
5418
5419                state.fs
5420                    .insert_tree(
5421                        &worktree_path,
5422                        serde_json::json!({
5423                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
5424                            "src": {},
5425                        }),
5426                    )
5427                    .await;
5428
5429                // Also create the worktree metadata dir inside the main repo's .git
5430                state
5431                    .fs
5432                    .insert_tree(
5433                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
5434                        serde_json::json!({
5435                            "commondir": "../../",
5436                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
5437                        }),
5438                    )
5439                    .await;
5440
5441                let dot_git_path = std::path::Path::new(&dot_git);
5442                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
5443                state
5444                    .fs
5445                    .add_linked_worktree_for_repo(
5446                        dot_git_path,
5447                        false,
5448                        git::repository::Worktree {
5449                            path: worktree_pathbuf,
5450                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
5451                            sha: "aaa".into(),
5452                            is_main: false,
5453                        },
5454                    )
5455                    .await;
5456
5457                // Re-scan the main workspace's project so it discovers the new worktree.
5458                let main_workspace = multi_workspace.read_with(cx, |mw, _| {
5459                    mw.workspaces().nth(workspace_index).unwrap().clone()
5460                });
5461                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
5462                main_project
5463                    .update(cx, |p, cx| p.git_scans_complete(cx))
5464                    .await;
5465
5466                state.unopened_worktrees.push(UnopenedWorktree {
5467                    path: worktree_path,
5468                    main_workspace_path: main_path.clone(),
5469                });
5470            }
5471        }
5472    }
5473
5474    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
5475        sidebar.update_in(cx, |sidebar, _window, cx| {
5476            sidebar.collapsed_groups.clear();
5477            let path_lists: Vec<PathList> = sidebar
5478                .contents
5479                .entries
5480                .iter()
5481                .filter_map(|entry| match entry {
5482                    ListEntry::ProjectHeader { key, .. } => Some(key.path_list().clone()),
5483                    _ => None,
5484                })
5485                .collect();
5486            for path_list in path_lists {
5487                sidebar.expanded_groups.insert(path_list, 10_000);
5488            }
5489            sidebar.update_entries(cx);
5490        });
5491    }
5492
5493    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
5494        verify_every_workspace_in_multiworkspace_is_shown(sidebar, cx)?;
5495        verify_all_threads_are_shown(sidebar, cx)?;
5496        verify_active_state_matches_current_workspace(sidebar, cx)?;
5497        Ok(())
5498    }
5499
5500    fn verify_every_workspace_in_multiworkspace_is_shown(
5501        sidebar: &Sidebar,
5502        cx: &App,
5503    ) -> anyhow::Result<()> {
5504        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5505            anyhow::bail!("sidebar should still have an associated multi-workspace");
5506        };
5507
5508        let mw = multi_workspace.read(cx);
5509
5510        // Every project group key in the multi-workspace that has a
5511        // non-empty path list should appear as a ProjectHeader in the
5512        // sidebar.
5513        let expected_keys: HashSet<&project::ProjectGroupKey> = mw
5514            .project_group_keys()
5515            .filter(|k| !k.path_list().paths().is_empty())
5516            .collect();
5517
5518        let sidebar_keys: HashSet<&project::ProjectGroupKey> = sidebar
5519            .contents
5520            .entries
5521            .iter()
5522            .filter_map(|entry| match entry {
5523                ListEntry::ProjectHeader { key, .. } => Some(key),
5524                _ => None,
5525            })
5526            .collect();
5527
5528        let missing = &expected_keys - &sidebar_keys;
5529        let stray = &sidebar_keys - &expected_keys;
5530
5531        anyhow::ensure!(
5532            missing.is_empty() && stray.is_empty(),
5533            "sidebar project groups don't match multi-workspace.\n\
5534             Only in multi-workspace (missing): {:?}\n\
5535             Only in sidebar (stray): {:?}",
5536            missing,
5537            stray,
5538        );
5539
5540        Ok(())
5541    }
5542
5543    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
5544        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5545            anyhow::bail!("sidebar should still have an associated multi-workspace");
5546        };
5547        let workspaces = multi_workspace
5548            .read(cx)
5549            .workspaces()
5550            .cloned()
5551            .collect::<Vec<_>>();
5552        let thread_store = ThreadMetadataStore::global(cx);
5553
5554        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
5555            .contents
5556            .entries
5557            .iter()
5558            .filter_map(|entry| entry.session_id().cloned())
5559            .collect();
5560
5561        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
5562
5563        // Query using the same approach as the sidebar: iterate project
5564        // group keys, then do main + legacy queries per group.
5565        let mw = multi_workspace.read(cx);
5566        let mut workspaces_by_group: HashMap<project::ProjectGroupKey, Vec<Entity<Workspace>>> =
5567            HashMap::default();
5568        for workspace in &workspaces {
5569            let key = workspace.read(cx).project_group_key(cx);
5570            workspaces_by_group
5571                .entry(key)
5572                .or_default()
5573                .push(workspace.clone());
5574        }
5575
5576        for group_key in mw.project_group_keys() {
5577            let path_list = group_key.path_list().clone();
5578            if path_list.paths().is_empty() {
5579                continue;
5580            }
5581
5582            let group_workspaces = workspaces_by_group
5583                .get(group_key)
5584                .map(|ws| ws.as_slice())
5585                .unwrap_or_default();
5586
5587            // Main code path queries (run for all groups, even without workspaces).
5588            for metadata in thread_store
5589                .read(cx)
5590                .entries_for_main_worktree_path(&path_list)
5591            {
5592                metadata_thread_ids.insert(metadata.session_id.clone());
5593            }
5594            for metadata in thread_store.read(cx).entries_for_path(&path_list) {
5595                metadata_thread_ids.insert(metadata.session_id.clone());
5596            }
5597
5598            // Legacy: per-workspace queries for different root paths.
5599            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
5600                .iter()
5601                .flat_map(|ws| {
5602                    ws.read(cx)
5603                        .root_paths(cx)
5604                        .into_iter()
5605                        .map(|p| p.to_path_buf())
5606                })
5607                .collect();
5608
5609            for workspace in group_workspaces {
5610                let ws_path_list = workspace_path_list(workspace, cx);
5611                if ws_path_list != path_list {
5612                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list) {
5613                        metadata_thread_ids.insert(metadata.session_id.clone());
5614                    }
5615                }
5616            }
5617
5618            for workspace in group_workspaces {
5619                for snapshot in root_repository_snapshots(workspace, cx) {
5620                    let repo_path_list =
5621                        PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
5622                    if repo_path_list != path_list {
5623                        continue;
5624                    }
5625                    for linked_worktree in snapshot.linked_worktrees() {
5626                        if covered_paths.contains(&*linked_worktree.path) {
5627                            continue;
5628                        }
5629                        let worktree_path_list =
5630                            PathList::new(std::slice::from_ref(&linked_worktree.path));
5631                        for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list)
5632                        {
5633                            metadata_thread_ids.insert(metadata.session_id.clone());
5634                        }
5635                    }
5636                }
5637            }
5638        }
5639
5640        anyhow::ensure!(
5641            sidebar_thread_ids == metadata_thread_ids,
5642            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
5643            sidebar_thread_ids,
5644            metadata_thread_ids,
5645        );
5646        Ok(())
5647    }
5648
5649    fn verify_active_state_matches_current_workspace(
5650        sidebar: &Sidebar,
5651        cx: &App,
5652    ) -> anyhow::Result<()> {
5653        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
5654            anyhow::bail!("sidebar should still have an associated multi-workspace");
5655        };
5656
5657        let active_workspace = multi_workspace.read(cx).workspace();
5658
5659        // 1. active_entry must always be Some after rebuild_contents.
5660        let entry = sidebar
5661            .active_entry
5662            .as_ref()
5663            .ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?;
5664
5665        // 2. The entry's workspace must agree with the multi-workspace's
5666        //    active workspace.
5667        anyhow::ensure!(
5668            entry.workspace().entity_id() == active_workspace.entity_id(),
5669            "active_entry workspace ({:?}) != active workspace ({:?})",
5670            entry.workspace().entity_id(),
5671            active_workspace.entity_id(),
5672        );
5673
5674        // 3. The entry must match the agent panel's current state.
5675        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
5676        if panel.read(cx).active_thread_is_draft(cx) {
5677            anyhow::ensure!(
5678                matches!(entry, ActiveEntry::Draft(_)),
5679                "panel shows a draft but active_entry is {:?}",
5680                entry,
5681            );
5682        } else if let Some(session_id) = panel
5683            .read(cx)
5684            .active_conversation_view()
5685            .and_then(|cv| cv.read(cx).parent_id(cx))
5686        {
5687            anyhow::ensure!(
5688                matches!(entry, ActiveEntry::Thread { session_id: id, .. } if id == &session_id),
5689                "panel has session {:?} but active_entry is {:?}",
5690                session_id,
5691                entry,
5692            );
5693        }
5694
5695        // 4. Exactly one entry in sidebar contents must be uniquely
5696        //    identified by the active_entry.
5697        let matching_count = sidebar
5698            .contents
5699            .entries
5700            .iter()
5701            .filter(|e| entry.matches_entry(e))
5702            .count();
5703        anyhow::ensure!(
5704            matching_count == 1,
5705            "expected exactly 1 sidebar entry matching active_entry {:?}, found {}",
5706            entry,
5707            matching_count,
5708        );
5709
5710        Ok(())
5711    }
5712
5713    #[gpui::property_test(config = ProptestConfig {
5714        cases: 10,
5715        ..Default::default()
5716    })]
5717    async fn test_sidebar_invariants(
5718        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)]
5719        raw_operations: Vec<u32>,
5720        cx: &mut TestAppContext,
5721    ) {
5722        agent_ui::test_support::init_test(cx);
5723        cx.update(|cx| {
5724            cx.update_flags(false, vec!["agent-v2".into()]);
5725            ThreadStore::init_global(cx);
5726            ThreadMetadataStore::init_global(cx);
5727            language_model::LanguageModelRegistry::test(cx);
5728            prompt_store::init(cx);
5729        });
5730
5731        let fs = FakeFs::new(cx.executor());
5732        fs.insert_tree(
5733            "/my-project",
5734            serde_json::json!({
5735                ".git": {},
5736                "src": {},
5737            }),
5738        )
5739        .await;
5740        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5741        let project =
5742            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
5743                .await;
5744        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5745
5746        let (multi_workspace, cx) =
5747            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5748        let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
5749
5750        let mut state = TestState::new(fs, "/my-project".to_string());
5751        let mut executed: Vec<String> = Vec::new();
5752
5753        for &raw_op in &raw_operations {
5754            let operation = state.generate_operation(raw_op);
5755            executed.push(format!("{:?}", operation));
5756            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
5757            cx.run_until_parked();
5758
5759            update_sidebar(&sidebar, cx);
5760            cx.run_until_parked();
5761
5762            let result =
5763                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
5764            if let Err(err) = result {
5765                let log = executed.join("\n  ");
5766                panic!(
5767                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
5768                    executed.len(),
5769                );
5770            }
5771        }
5772    }
5773}
5774
5775#[gpui::test]
5776async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
5777    cx: &mut TestAppContext,
5778    server_cx: &mut TestAppContext,
5779) {
5780    init_test(cx);
5781
5782    cx.update(|cx| {
5783        release_channel::init(semver::Version::new(0, 0, 0), cx);
5784    });
5785
5786    let app_state = cx.update(|cx| {
5787        let app_state = workspace::AppState::test(cx);
5788        workspace::init(app_state.clone(), cx);
5789        app_state
5790    });
5791
5792    // Set up the remote server side.
5793    let server_fs = FakeFs::new(server_cx.executor());
5794    server_fs
5795        .insert_tree(
5796            "/project",
5797            serde_json::json!({
5798                ".git": {},
5799                "src": { "main.rs": "fn main() {}" }
5800            }),
5801        )
5802        .await;
5803    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5804
5805    // Create the linked worktree checkout path on the remote server,
5806    // but do not yet register it as a git-linked worktree. The real
5807    // regrouping update in this test should happen only after the
5808    // sidebar opens the closed remote thread.
5809    server_fs
5810        .insert_tree(
5811            "/project-wt-1",
5812            serde_json::json!({
5813                "src": { "main.rs": "fn main() {}" }
5814            }),
5815        )
5816        .await;
5817
5818    server_cx.update(|cx| {
5819        release_channel::init(semver::Version::new(0, 0, 0), cx);
5820    });
5821
5822    let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
5823
5824    server_cx.update(remote_server::HeadlessProject::init);
5825    let server_executor = server_cx.executor();
5826    let _headless = server_cx.new(|cx| {
5827        remote_server::HeadlessProject::new(
5828            remote_server::HeadlessAppState {
5829                session: server_session,
5830                fs: server_fs.clone(),
5831                http_client: Arc::new(http_client::BlockedHttpClient),
5832                node_runtime: node_runtime::NodeRuntime::unavailable(),
5833                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
5834                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
5835                startup_time: std::time::Instant::now(),
5836            },
5837            false,
5838            cx,
5839        )
5840    });
5841
5842    // Connect the client side and build a remote project.
5843    let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
5844    let project = cx.update(|cx| {
5845        let project_client = client::Client::new(
5846            Arc::new(clock::FakeSystemClock::new()),
5847            http_client::FakeHttpClient::with_404_response(),
5848            cx,
5849        );
5850        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
5851        project::Project::remote(
5852            remote_client,
5853            project_client,
5854            node_runtime::NodeRuntime::unavailable(),
5855            user_store,
5856            app_state.languages.clone(),
5857            app_state.fs.clone(),
5858            false,
5859            cx,
5860        )
5861    });
5862
5863    // Open the remote worktree.
5864    project
5865        .update(cx, |project, cx| {
5866            project.find_or_create_worktree(Path::new("/project"), true, cx)
5867        })
5868        .await
5869        .expect("should open remote worktree");
5870    cx.run_until_parked();
5871
5872    // Verify the project is remote.
5873    project.read_with(cx, |project, cx| {
5874        assert!(!project.is_local(), "project should be remote");
5875        assert!(
5876            project.remote_connection_options(cx).is_some(),
5877            "project should have remote connection options"
5878        );
5879    });
5880
5881    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
5882
5883    // Create MultiWorkspace with the remote project.
5884    let (multi_workspace, cx) =
5885        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5886    let sidebar = setup_sidebar(&multi_workspace, cx);
5887
5888    cx.run_until_parked();
5889
5890    // Save a thread for the main remote workspace (folder_paths match
5891    // the open workspace, so it will be classified as Open).
5892    let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
5893    save_thread_metadata(
5894        main_thread_id.clone(),
5895        "Main Thread".into(),
5896        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5897        None,
5898        &project,
5899        cx,
5900    );
5901    cx.run_until_parked();
5902
5903    // Save a thread whose folder_paths point to a linked worktree path
5904    // that doesn't have an open workspace ("/project-wt-1"), but whose
5905    // main_worktree_paths match the project group key so it appears
5906    // in the sidebar under the same remote group. This simulates a
5907    // linked worktree workspace that was closed.
5908    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
5909    let main_worktree_paths =
5910        project.read_with(cx, |p, cx| p.project_group_key(cx).path_list().clone());
5911    cx.update(|_window, cx| {
5912        let metadata = ThreadMetadata {
5913            session_id: remote_thread_id.clone(),
5914            agent_id: agent::ZED_AGENT_ID.clone(),
5915            title: "Worktree Thread".into(),
5916            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
5917            created_at: None,
5918            folder_paths: PathList::new(&[PathBuf::from("/project-wt-1")]),
5919            main_worktree_paths,
5920            archived: false,
5921        };
5922        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
5923    });
5924    cx.run_until_parked();
5925
5926    focus_sidebar(&sidebar, cx);
5927    sidebar.update_in(cx, |sidebar, _window, _cx| {
5928        sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
5929            matches!(
5930                entry,
5931                ListEntry::Thread(thread) if thread.metadata.session_id == remote_thread_id
5932            )
5933        });
5934    });
5935
5936    let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
5937    let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
5938
5939    sidebar
5940        .update(cx, |_, cx| {
5941            cx.observe_self(move |sidebar, _cx| {
5942                let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
5943                    if let ListEntry::ProjectHeader { label, .. } = entry {
5944                        Some(label.as_ref())
5945                    } else {
5946                        None
5947                    }
5948                });
5949
5950                let Some(project_header) = project_headers.next() else {
5951                    saw_separate_project_header_for_observer
5952                        .store(true, std::sync::atomic::Ordering::SeqCst);
5953                    return;
5954                };
5955
5956                if project_header != "project" || project_headers.next().is_some() {
5957                    saw_separate_project_header_for_observer
5958                        .store(true, std::sync::atomic::Ordering::SeqCst);
5959                }
5960            })
5961        })
5962        .detach();
5963
5964    multi_workspace.update(cx, |multi_workspace, cx| {
5965        let workspace = multi_workspace.workspace().clone();
5966        workspace.update(cx, |workspace: &mut Workspace, cx| {
5967            let remote_client = workspace
5968                .project()
5969                .read(cx)
5970                .remote_client()
5971                .expect("main remote project should have a remote client");
5972            remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
5973                remote_client.force_server_not_running(cx);
5974            });
5975        });
5976    });
5977    cx.run_until_parked();
5978
5979    let (server_session_2, connect_guard_2) =
5980        remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
5981    let _headless_2 = server_cx.new(|cx| {
5982        remote_server::HeadlessProject::new(
5983            remote_server::HeadlessAppState {
5984                session: server_session_2,
5985                fs: server_fs.clone(),
5986                http_client: Arc::new(http_client::BlockedHttpClient),
5987                node_runtime: node_runtime::NodeRuntime::unavailable(),
5988                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
5989                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
5990                startup_time: std::time::Instant::now(),
5991            },
5992            false,
5993            cx,
5994        )
5995    });
5996    drop(connect_guard_2);
5997
5998    let window = cx.windows()[0];
5999    cx.update_window(window, |_, window, cx| {
6000        window.dispatch_action(Confirm.boxed_clone(), cx);
6001    })
6002    .unwrap();
6003
6004    cx.run_until_parked();
6005
6006    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
6007        assert_eq!(
6008            mw.workspaces().count(),
6009            2,
6010            "confirming a closed remote thread should open a second workspace"
6011        );
6012        mw.workspaces()
6013            .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
6014            .unwrap()
6015            .clone()
6016    });
6017
6018    server_fs
6019        .add_linked_worktree_for_repo(
6020            Path::new("/project/.git"),
6021            true,
6022            git::repository::Worktree {
6023                path: PathBuf::from("/project-wt-1"),
6024                ref_name: Some("refs/heads/feature-wt".into()),
6025                sha: "abc123".into(),
6026                is_main: false,
6027            },
6028        )
6029        .await;
6030
6031    server_cx.run_until_parked();
6032    cx.run_until_parked();
6033    server_cx.run_until_parked();
6034    cx.run_until_parked();
6035
6036    let entries_after_update = visible_entries_as_strings(&sidebar, cx);
6037    let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
6038        workspace.project().read(cx).project_group_key(cx)
6039    });
6040
6041    assert_eq!(
6042        group_after_update,
6043        project.read_with(cx, |project, cx| project.project_group_key(cx)),
6044        "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
6045         final sidebar entries: {:?}",
6046        entries_after_update,
6047    );
6048
6049    sidebar.update(cx, |sidebar, _cx| {
6050        assert_remote_project_integration_sidebar_state(
6051            sidebar,
6052            &main_thread_id,
6053            &remote_thread_id,
6054        );
6055    });
6056
6057    assert!(
6058        !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
6059        "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
6060         final group: {:?}; final sidebar entries: {:?}",
6061        group_after_update,
6062        entries_after_update,
6063    );
6064}