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