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