sidebar_tests.rs

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