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