sidebar_tests.rs

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