sidebar_tests.rs

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