sidebar_tests.rs

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