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