sidebar_tests.rs

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