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