sidebar_tests.rs

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