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