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