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