sidebar_tests.rs

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