sidebar_tests.rs

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