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