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, Fs};
  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
4310    // archive_thread spawns a chain of tasks:
4311    //   1. cx.spawn_in for workspace removal (awaits mw.remove())
4312    //   2. start_archive_worktree_task spawns cx.spawn for git persist + disk removal
4313    //   3. persist/remove do background_spawn work internally
4314    // Each layer needs run_until_parked to drive to completion.
4315    cx.run_until_parked();
4316    cx.run_until_parked();
4317    cx.run_until_parked();
4318
4319    // The linked worktree workspace should have been removed.
4320    assert_eq!(
4321        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
4322        1,
4323        "linked worktree workspace should be removed after archiving its last thread"
4324    );
4325
4326    // The linked worktree checkout directory should also be removed from disk.
4327    assert!(
4328        !fs.is_dir(Path::new("/wt-feature-a")).await,
4329        "linked worktree directory should be removed from disk after archiving its last thread"
4330    );
4331
4332    // The main thread should still be visible.
4333    let entries = visible_entries_as_strings(&sidebar, cx);
4334    assert!(
4335        entries.iter().any(|e| e.contains("Main Thread")),
4336        "main thread should still be visible: {entries:?}"
4337    );
4338    assert!(
4339        !entries.iter().any(|e| e.contains("Worktree Thread")),
4340        "archived worktree thread should not be visible: {entries:?}"
4341    );
4342}
4343
4344#[gpui::test]
4345async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
4346    // When a multi-root workspace (e.g. [/other, /project]) shares a
4347    // repo with a single-root workspace (e.g. [/project]), linked
4348    // worktree threads from the shared repo should only appear under
4349    // the dedicated group [project], not under [other, project].
4350    init_test(cx);
4351    let fs = FakeFs::new(cx.executor());
4352
4353    // Two independent repos, each with their own git history.
4354    fs.insert_tree(
4355        "/project",
4356        serde_json::json!({
4357            ".git": {},
4358            "src": {},
4359        }),
4360    )
4361    .await;
4362    fs.insert_tree(
4363        "/other",
4364        serde_json::json!({
4365            ".git": {},
4366            "src": {},
4367        }),
4368    )
4369    .await;
4370
4371    // Register the linked worktree in the main repo.
4372    fs.add_linked_worktree_for_repo(
4373        Path::new("/project/.git"),
4374        false,
4375        git::repository::Worktree {
4376            path: std::path::PathBuf::from("/wt-feature-a"),
4377            ref_name: Some("refs/heads/feature-a".into()),
4378            sha: "aaa".into(),
4379            is_main: false,
4380        },
4381    )
4382    .await;
4383
4384    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4385
4386    // Workspace 1: just /project.
4387    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4388    project_only
4389        .update(cx, |p, cx| p.git_scans_complete(cx))
4390        .await;
4391
4392    // Workspace 2: /other and /project together (multi-root).
4393    let multi_root =
4394        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
4395    multi_root
4396        .update(cx, |p, cx| p.git_scans_complete(cx))
4397        .await;
4398
4399    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4400    worktree_project
4401        .update(cx, |p, cx| p.git_scans_complete(cx))
4402        .await;
4403
4404    let (multi_workspace, cx) =
4405        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
4406    let sidebar = setup_sidebar(&multi_workspace, cx);
4407    multi_workspace.update_in(cx, |mw, window, cx| {
4408        mw.test_add_workspace(multi_root.clone(), window, cx);
4409    });
4410
4411    // Save a thread under the linked worktree path.
4412    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
4413
4414    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4415    cx.run_until_parked();
4416
4417    // The thread should appear only under [project] (the dedicated
4418    // group for the /project repo), not under [other, project].
4419    assert_eq!(
4420        visible_entries_as_strings(&sidebar, cx),
4421        vec![
4422            "v [other, project]",
4423            "  [+ New Thread]",
4424            "v [project]",
4425            "  Worktree Thread {wt-feature-a}",
4426        ]
4427    );
4428}
4429
4430#[gpui::test]
4431async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
4432    let project = init_test_project_with_agent_panel("/my-project", cx).await;
4433    let (multi_workspace, cx) =
4434        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4435    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4436
4437    let switcher_ids =
4438        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
4439            sidebar.read_with(cx, |sidebar, cx| {
4440                let switcher = sidebar
4441                    .thread_switcher
4442                    .as_ref()
4443                    .expect("switcher should be open");
4444                switcher
4445                    .read(cx)
4446                    .entries()
4447                    .iter()
4448                    .map(|e| e.session_id.clone())
4449                    .collect()
4450            })
4451        };
4452
4453    let switcher_selected_id =
4454        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
4455            sidebar.read_with(cx, |sidebar, cx| {
4456                let switcher = sidebar
4457                    .thread_switcher
4458                    .as_ref()
4459                    .expect("switcher should be open");
4460                let s = switcher.read(cx);
4461                s.selected_entry()
4462                    .expect("should have selection")
4463                    .session_id
4464                    .clone()
4465            })
4466        };
4467
4468    // ── Setup: create three threads with distinct created_at times ──────
4469    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
4470    // We send messages in each so they also get last_message_sent_or_queued timestamps.
4471    let connection_c = StubAgentConnection::new();
4472    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4473        acp::ContentChunk::new("Done C".into()),
4474    )]);
4475    open_thread_with_connection(&panel, connection_c, cx);
4476    send_message(&panel, cx);
4477    let session_id_c = active_session_id(&panel, cx);
4478    save_thread_metadata(
4479        session_id_c.clone(),
4480        "Thread C".into(),
4481        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4482        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
4483        &project,
4484        cx,
4485    );
4486
4487    let connection_b = StubAgentConnection::new();
4488    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4489        acp::ContentChunk::new("Done B".into()),
4490    )]);
4491    open_thread_with_connection(&panel, connection_b, cx);
4492    send_message(&panel, cx);
4493    let session_id_b = active_session_id(&panel, cx);
4494    save_thread_metadata(
4495        session_id_b.clone(),
4496        "Thread B".into(),
4497        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4498        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
4499        &project,
4500        cx,
4501    );
4502
4503    let connection_a = StubAgentConnection::new();
4504    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4505        acp::ContentChunk::new("Done A".into()),
4506    )]);
4507    open_thread_with_connection(&panel, connection_a, cx);
4508    send_message(&panel, cx);
4509    let session_id_a = active_session_id(&panel, cx);
4510    save_thread_metadata(
4511        session_id_a.clone(),
4512        "Thread A".into(),
4513        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
4514        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
4515        &project,
4516        cx,
4517    );
4518
4519    // All three threads are now live. Thread A was opened last, so it's
4520    // the one being viewed. Opening each thread called record_thread_access,
4521    // so all three have last_accessed_at set.
4522    // Access order is: A (most recent), B, C (oldest).
4523
4524    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
4525    focus_sidebar(&sidebar, cx);
4526    sidebar.update_in(cx, |sidebar, window, cx| {
4527        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4528    });
4529    cx.run_until_parked();
4530
4531    // All three have last_accessed_at, so they sort by access time.
4532    // A was accessed most recently (it's the currently viewed thread),
4533    // then B, then C.
4534    assert_eq!(
4535        switcher_ids(&sidebar, cx),
4536        vec![
4537            session_id_a.clone(),
4538            session_id_b.clone(),
4539            session_id_c.clone()
4540        ],
4541    );
4542    // First ctrl-tab selects the second entry (B).
4543    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
4544
4545    // Dismiss the switcher without confirming.
4546    sidebar.update_in(cx, |sidebar, _window, cx| {
4547        sidebar.dismiss_thread_switcher(cx);
4548    });
4549    cx.run_until_parked();
4550
4551    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
4552    sidebar.update_in(cx, |sidebar, window, cx| {
4553        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4554    });
4555    cx.run_until_parked();
4556
4557    // Cycle twice to land on Thread C (index 2).
4558    sidebar.read_with(cx, |sidebar, cx| {
4559        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4560        assert_eq!(switcher.read(cx).selected_index(), 1);
4561    });
4562    sidebar.update_in(cx, |sidebar, _window, cx| {
4563        sidebar
4564            .thread_switcher
4565            .as_ref()
4566            .unwrap()
4567            .update(cx, |s, cx| s.cycle_selection(cx));
4568    });
4569    cx.run_until_parked();
4570    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
4571
4572    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
4573
4574    // Confirm on Thread C.
4575    sidebar.update_in(cx, |sidebar, window, cx| {
4576        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4577        let focus = switcher.focus_handle(cx);
4578        focus.dispatch_action(&menu::Confirm, window, cx);
4579    });
4580    cx.run_until_parked();
4581
4582    // Switcher should be dismissed after confirm.
4583    sidebar.read_with(cx, |sidebar, _cx| {
4584        assert!(
4585            sidebar.thread_switcher.is_none(),
4586            "switcher should be dismissed"
4587        );
4588    });
4589
4590    sidebar.update(cx, |sidebar, _cx| {
4591        let last_accessed = sidebar
4592            .thread_last_accessed
4593            .keys()
4594            .cloned()
4595            .collect::<Vec<_>>();
4596        assert_eq!(last_accessed.len(), 1);
4597        assert!(last_accessed.contains(&session_id_c));
4598        assert!(
4599            sidebar
4600                .active_entry
4601                .as_ref()
4602                .expect("active_entry should be set")
4603                .is_active_thread(&session_id_c)
4604        );
4605    });
4606
4607    sidebar.update_in(cx, |sidebar, window, cx| {
4608        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4609    });
4610    cx.run_until_parked();
4611
4612    assert_eq!(
4613        switcher_ids(&sidebar, cx),
4614        vec![
4615            session_id_c.clone(),
4616            session_id_a.clone(),
4617            session_id_b.clone()
4618        ],
4619    );
4620
4621    // Confirm on Thread A.
4622    sidebar.update_in(cx, |sidebar, window, cx| {
4623        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4624        let focus = switcher.focus_handle(cx);
4625        focus.dispatch_action(&menu::Confirm, window, cx);
4626    });
4627    cx.run_until_parked();
4628
4629    sidebar.update(cx, |sidebar, _cx| {
4630        let last_accessed = sidebar
4631            .thread_last_accessed
4632            .keys()
4633            .cloned()
4634            .collect::<Vec<_>>();
4635        assert_eq!(last_accessed.len(), 2);
4636        assert!(last_accessed.contains(&session_id_c));
4637        assert!(last_accessed.contains(&session_id_a));
4638        assert!(
4639            sidebar
4640                .active_entry
4641                .as_ref()
4642                .expect("active_entry should be set")
4643                .is_active_thread(&session_id_a)
4644        );
4645    });
4646
4647    sidebar.update_in(cx, |sidebar, window, cx| {
4648        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4649    });
4650    cx.run_until_parked();
4651
4652    assert_eq!(
4653        switcher_ids(&sidebar, cx),
4654        vec![
4655            session_id_a.clone(),
4656            session_id_c.clone(),
4657            session_id_b.clone(),
4658        ],
4659    );
4660
4661    sidebar.update_in(cx, |sidebar, _window, cx| {
4662        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4663        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
4664    });
4665    cx.run_until_parked();
4666
4667    // Confirm on Thread B.
4668    sidebar.update_in(cx, |sidebar, window, cx| {
4669        let switcher = sidebar.thread_switcher.as_ref().unwrap();
4670        let focus = switcher.focus_handle(cx);
4671        focus.dispatch_action(&menu::Confirm, window, cx);
4672    });
4673    cx.run_until_parked();
4674
4675    sidebar.update(cx, |sidebar, _cx| {
4676        let last_accessed = sidebar
4677            .thread_last_accessed
4678            .keys()
4679            .cloned()
4680            .collect::<Vec<_>>();
4681        assert_eq!(last_accessed.len(), 3);
4682        assert!(last_accessed.contains(&session_id_c));
4683        assert!(last_accessed.contains(&session_id_a));
4684        assert!(last_accessed.contains(&session_id_b));
4685        assert!(
4686            sidebar
4687                .active_entry
4688                .as_ref()
4689                .expect("active_entry should be set")
4690                .is_active_thread(&session_id_b)
4691        );
4692    });
4693
4694    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
4695    // This thread was never opened in a panel — it only exists in metadata.
4696    save_thread_metadata(
4697        acp::SessionId::new(Arc::from("thread-historical")),
4698        "Historical Thread".into(),
4699        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
4700        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
4701        &project,
4702        cx,
4703    );
4704
4705    sidebar.update_in(cx, |sidebar, window, cx| {
4706        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4707    });
4708    cx.run_until_parked();
4709
4710    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
4711    // so it falls to tier 3 (sorted by created_at). It should appear after all
4712    // accessed threads, even though its created_at (June 2024) is much later
4713    // than the others.
4714    //
4715    // But the live threads (A, B, C) each had send_message called which sets
4716    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
4717    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
4718    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
4719
4720    let ids = switcher_ids(&sidebar, cx);
4721    assert_eq!(
4722        ids,
4723        vec![
4724            session_id_b.clone(),
4725            session_id_a.clone(),
4726            session_id_c.clone(),
4727            session_id_hist.clone()
4728        ],
4729    );
4730
4731    sidebar.update_in(cx, |sidebar, _window, cx| {
4732        sidebar.dismiss_thread_switcher(cx);
4733    });
4734    cx.run_until_parked();
4735
4736    // ── 4. Add another historical thread with older created_at ─────────
4737    save_thread_metadata(
4738        acp::SessionId::new(Arc::from("thread-old-historical")),
4739        "Old Historical Thread".into(),
4740        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
4741        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
4742        &project,
4743        cx,
4744    );
4745
4746    sidebar.update_in(cx, |sidebar, window, cx| {
4747        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
4748    });
4749    cx.run_until_parked();
4750
4751    // Both historical threads have no access or message times. They should
4752    // appear after accessed threads, sorted by created_at (newest first).
4753    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
4754    let ids = switcher_ids(&sidebar, cx);
4755    assert_eq!(
4756        ids,
4757        vec![
4758            session_id_b,
4759            session_id_a,
4760            session_id_c,
4761            session_id_hist,
4762            session_id_old_hist,
4763        ],
4764    );
4765
4766    sidebar.update_in(cx, |sidebar, _window, cx| {
4767        sidebar.dismiss_thread_switcher(cx);
4768    });
4769    cx.run_until_parked();
4770}
4771
4772#[gpui::test]
4773async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
4774    let project = init_test_project("/my-project", cx).await;
4775    let (multi_workspace, cx) =
4776        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4777    let sidebar = setup_sidebar(&multi_workspace, cx);
4778
4779    save_thread_metadata(
4780        acp::SessionId::new(Arc::from("thread-to-archive")),
4781        "Thread To Archive".into(),
4782        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4783        None,
4784        &project,
4785        cx,
4786    );
4787    cx.run_until_parked();
4788
4789    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4790    cx.run_until_parked();
4791
4792    let entries = visible_entries_as_strings(&sidebar, cx);
4793    assert!(
4794        entries.iter().any(|e| e.contains("Thread To Archive")),
4795        "expected thread to be visible before archiving, got: {entries:?}"
4796    );
4797
4798    sidebar.update_in(cx, |sidebar, window, cx| {
4799        sidebar.archive_thread(
4800            &acp::SessionId::new(Arc::from("thread-to-archive")),
4801            window,
4802            cx,
4803        );
4804    });
4805    cx.run_until_parked();
4806
4807    let entries = visible_entries_as_strings(&sidebar, cx);
4808    assert!(
4809        !entries.iter().any(|e| e.contains("Thread To Archive")),
4810        "expected thread to be hidden after archiving, got: {entries:?}"
4811    );
4812
4813    cx.update(|_, cx| {
4814        let store = ThreadMetadataStore::global(cx);
4815        let archived: Vec<_> = store.read(cx).archived_entries().collect();
4816        assert_eq!(archived.len(), 1);
4817        assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive");
4818        assert!(archived[0].archived);
4819    });
4820}
4821
4822#[gpui::test]
4823async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
4824    // Tests two archive scenarios:
4825    // 1. Archiving a thread in a non-active workspace leaves active_entry
4826    //    as the current draft.
4827    // 2. Archiving the thread the user is looking at falls back to a draft
4828    //    on the same workspace.
4829    agent_ui::test_support::init_test(cx);
4830    cx.update(|cx| {
4831        ThreadStore::init_global(cx);
4832        ThreadMetadataStore::init_global(cx);
4833        language_model::LanguageModelRegistry::test(cx);
4834        prompt_store::init(cx);
4835    });
4836
4837    let fs = FakeFs::new(cx.executor());
4838    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4839        .await;
4840    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4841        .await;
4842    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4843
4844    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4845    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4846
4847    let (multi_workspace, cx) =
4848        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4849    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4850
4851    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4852        mw.test_add_workspace(project_b.clone(), window, cx)
4853    });
4854    let panel_b = add_agent_panel(&workspace_b, cx);
4855    cx.run_until_parked();
4856
4857    // --- Scenario 1: archive a thread in the non-active workspace ---
4858
4859    // Create a thread in project-a (non-active — project-b is active).
4860    let connection = acp_thread::StubAgentConnection::new();
4861    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4862        acp::ContentChunk::new("Done".into()),
4863    )]);
4864    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
4865    agent_ui::test_support::send_message(&panel_a, cx);
4866    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
4867    cx.run_until_parked();
4868
4869    sidebar.update_in(cx, |sidebar, window, cx| {
4870        sidebar.archive_thread(&thread_a, window, cx);
4871    });
4872    cx.run_until_parked();
4873
4874    // active_entry should still be a draft on workspace_b (the active one).
4875    sidebar.read_with(cx, |sidebar, _| {
4876        assert!(
4877            matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
4878            "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
4879            sidebar.active_entry,
4880        );
4881    });
4882
4883    // --- Scenario 2: archive the thread the user is looking at ---
4884
4885    // Create a thread in project-b (the active workspace) and verify it
4886    // becomes the active entry.
4887    let connection = acp_thread::StubAgentConnection::new();
4888    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4889        acp::ContentChunk::new("Done".into()),
4890    )]);
4891    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
4892    agent_ui::test_support::send_message(&panel_b, cx);
4893    let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
4894    cx.run_until_parked();
4895
4896    sidebar.read_with(cx, |sidebar, _| {
4897        assert!(
4898            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id, .. }) if *session_id == thread_b),
4899            "expected active_entry to be Thread({thread_b}), got: {:?}",
4900            sidebar.active_entry,
4901        );
4902    });
4903
4904    sidebar.update_in(cx, |sidebar, window, cx| {
4905        sidebar.archive_thread(&thread_b, window, cx);
4906    });
4907    cx.run_until_parked();
4908
4909    // Should fall back to a draft on the same workspace.
4910    sidebar.read_with(cx, |sidebar, _| {
4911        assert!(
4912            matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
4913            "expected Draft(workspace_b) after archiving active thread, got: {:?}",
4914            sidebar.active_entry,
4915        );
4916    });
4917}
4918
4919#[gpui::test]
4920async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) {
4921    // When a thread is archived while the user is in a different workspace,
4922    // the archiving code clears the thread from its panel (via
4923    // `clear_active_thread`). Switching back to that workspace should show
4924    // a draft, not the archived thread.
4925    agent_ui::test_support::init_test(cx);
4926    cx.update(|cx| {
4927        ThreadStore::init_global(cx);
4928        ThreadMetadataStore::init_global(cx);
4929        language_model::LanguageModelRegistry::test(cx);
4930        prompt_store::init(cx);
4931    });
4932
4933    let fs = FakeFs::new(cx.executor());
4934    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4935        .await;
4936    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4937        .await;
4938    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4939
4940    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4941    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4942
4943    let (multi_workspace, cx) =
4944        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4945    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4946
4947    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4948        mw.test_add_workspace(project_b.clone(), window, cx)
4949    });
4950    let _panel_b = add_agent_panel(&workspace_b, cx);
4951    cx.run_until_parked();
4952
4953    // Create a thread in project-a's panel (currently non-active).
4954    let connection = acp_thread::StubAgentConnection::new();
4955    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4956        acp::ContentChunk::new("Done".into()),
4957    )]);
4958    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
4959    agent_ui::test_support::send_message(&panel_a, cx);
4960    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
4961    cx.run_until_parked();
4962
4963    // Archive it while project-b is active.
4964    sidebar.update_in(cx, |sidebar, window, cx| {
4965        sidebar.archive_thread(&thread_a, window, cx);
4966    });
4967    cx.run_until_parked();
4968
4969    // Switch back to project-a. Its panel was cleared during archiving,
4970    // so active_entry should be Draft.
4971    let workspace_a =
4972        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
4973    multi_workspace.update_in(cx, |mw, window, cx| {
4974        mw.activate(workspace_a.clone(), window, cx);
4975    });
4976    cx.run_until_parked();
4977
4978    sidebar.update_in(cx, |sidebar, _window, cx| {
4979        sidebar.update_entries(cx);
4980    });
4981    cx.run_until_parked();
4982
4983    sidebar.read_with(cx, |sidebar, _| {
4984        assert!(
4985            matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_a),
4986            "expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}",
4987            sidebar.active_entry,
4988        );
4989    });
4990}
4991
4992#[gpui::test]
4993async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
4994    let project = init_test_project("/my-project", cx).await;
4995    let (multi_workspace, cx) =
4996        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4997    let sidebar = setup_sidebar(&multi_workspace, cx);
4998
4999    save_thread_metadata(
5000        acp::SessionId::new(Arc::from("visible-thread")),
5001        "Visible Thread".into(),
5002        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5003        None,
5004        &project,
5005        cx,
5006    );
5007
5008    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
5009    save_thread_metadata(
5010        archived_thread_session_id.clone(),
5011        "Archived Thread".into(),
5012        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5013        None,
5014        &project,
5015        cx,
5016    );
5017
5018    cx.update(|_, cx| {
5019        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5020            store.archive(&archived_thread_session_id, None, cx)
5021        })
5022    });
5023    cx.run_until_parked();
5024
5025    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5026    cx.run_until_parked();
5027
5028    let entries = visible_entries_as_strings(&sidebar, cx);
5029    assert!(
5030        entries.iter().any(|e| e.contains("Visible Thread")),
5031        "expected visible thread in sidebar, got: {entries:?}"
5032    );
5033    assert!(
5034        !entries.iter().any(|e| e.contains("Archived Thread")),
5035        "expected archived thread to be hidden from sidebar, got: {entries:?}"
5036    );
5037
5038    cx.update(|_, cx| {
5039        let store = ThreadMetadataStore::global(cx);
5040        let all: Vec<_> = store.read(cx).entries().collect();
5041        assert_eq!(
5042            all.len(),
5043            2,
5044            "expected 2 total entries in the store, got: {}",
5045            all.len()
5046        );
5047
5048        let archived: Vec<_> = store.read(cx).archived_entries().collect();
5049        assert_eq!(archived.len(), 1);
5050        assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread");
5051    });
5052}
5053
5054#[gpui::test]
5055async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
5056    cx: &mut TestAppContext,
5057) {
5058    // When a linked worktree has a single thread and that thread is archived,
5059    // the sidebar must NOT create a new thread on the same worktree (which
5060    // would prevent the worktree from being cleaned up on disk). Instead,
5061    // archive_thread switches to a sibling thread on the main workspace (or
5062    // creates a draft there) before archiving the metadata.
5063    agent_ui::test_support::init_test(cx);
5064    cx.update(|cx| {
5065        ThreadStore::init_global(cx);
5066        ThreadMetadataStore::init_global(cx);
5067        language_model::LanguageModelRegistry::test(cx);
5068        prompt_store::init(cx);
5069    });
5070
5071    let fs = FakeFs::new(cx.executor());
5072
5073    fs.insert_tree(
5074        "/project",
5075        serde_json::json!({
5076            ".git": {},
5077            "src": {},
5078        }),
5079    )
5080    .await;
5081
5082    fs.add_linked_worktree_for_repo(
5083        Path::new("/project/.git"),
5084        false,
5085        git::repository::Worktree {
5086            path: std::path::PathBuf::from("/wt-ochre-drift"),
5087            ref_name: Some("refs/heads/ochre-drift".into()),
5088            sha: "aaa".into(),
5089            is_main: false,
5090        },
5091    )
5092    .await;
5093
5094    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5095
5096    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5097    let worktree_project =
5098        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
5099
5100    main_project
5101        .update(cx, |p, cx| p.git_scans_complete(cx))
5102        .await;
5103    worktree_project
5104        .update(cx, |p, cx| p.git_scans_complete(cx))
5105        .await;
5106
5107    let (multi_workspace, cx) =
5108        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5109
5110    let sidebar = setup_sidebar(&multi_workspace, cx);
5111
5112    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5113        mw.test_add_workspace(worktree_project.clone(), window, cx)
5114    });
5115
5116    // Set up both workspaces with agent panels.
5117    let main_workspace =
5118        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5119    let _main_panel = add_agent_panel(&main_workspace, cx);
5120    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
5121
5122    // Activate the linked worktree workspace so the sidebar tracks it.
5123    multi_workspace.update_in(cx, |mw, window, cx| {
5124        mw.activate(worktree_workspace.clone(), window, cx);
5125    });
5126
5127    // Open a thread in the linked worktree panel and send a message
5128    // so it becomes the active thread.
5129    let connection = StubAgentConnection::new();
5130    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
5131    send_message(&worktree_panel, cx);
5132
5133    let worktree_thread_id = active_session_id(&worktree_panel, cx);
5134
5135    // Give the thread a response chunk so it has content.
5136    cx.update(|_, cx| {
5137        connection.send_update(
5138            worktree_thread_id.clone(),
5139            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5140            cx,
5141        );
5142    });
5143
5144    // Save the worktree thread's metadata.
5145    save_thread_metadata(
5146        worktree_thread_id.clone(),
5147        "Ochre Drift Thread".into(),
5148        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5149        None,
5150        &worktree_project,
5151        cx,
5152    );
5153
5154    // Also save a thread on the main project so there's a sibling in the
5155    // group that can be selected after archiving.
5156    save_thread_metadata(
5157        acp::SessionId::new(Arc::from("main-project-thread")),
5158        "Main Project Thread".into(),
5159        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5160        None,
5161        &main_project,
5162        cx,
5163    );
5164
5165    cx.run_until_parked();
5166
5167    // Verify the linked worktree thread appears with its chip.
5168    // The live thread title comes from the message text ("Hello"), not
5169    // the metadata title we saved.
5170    let entries_before = visible_entries_as_strings(&sidebar, cx);
5171    assert!(
5172        entries_before
5173            .iter()
5174            .any(|s| s.contains("{wt-ochre-drift}")),
5175        "expected worktree thread with chip before archiving, got: {entries_before:?}"
5176    );
5177    assert!(
5178        entries_before
5179            .iter()
5180            .any(|s| s.contains("Main Project Thread")),
5181        "expected main project thread before archiving, got: {entries_before:?}"
5182    );
5183
5184    // Confirm the worktree thread is the active entry.
5185    sidebar.read_with(cx, |s, _| {
5186        assert_active_thread(
5187            s,
5188            &worktree_thread_id,
5189            "worktree thread should be active before archiving",
5190        );
5191    });
5192
5193    // Archive the worktree thread — it's the only thread using ochre-drift.
5194    sidebar.update_in(cx, |sidebar, window, cx| {
5195        sidebar.archive_thread(&worktree_thread_id, window, cx);
5196    });
5197
5198    cx.run_until_parked();
5199
5200    // The archived thread should no longer appear in the sidebar.
5201    let entries_after = visible_entries_as_strings(&sidebar, cx);
5202    assert!(
5203        !entries_after
5204            .iter()
5205            .any(|s| s.contains("Ochre Drift Thread")),
5206        "archived thread should be hidden, got: {entries_after:?}"
5207    );
5208
5209    // No "+ New Thread" entry should appear with the ochre-drift worktree
5210    // chip — that would keep the worktree alive and prevent cleanup.
5211    assert!(
5212        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
5213        "no entry should reference the archived worktree, got: {entries_after:?}"
5214    );
5215
5216    // The main project thread should still be visible.
5217    assert!(
5218        entries_after
5219            .iter()
5220            .any(|s| s.contains("Main Project Thread")),
5221        "main project thread should still be visible, got: {entries_after:?}"
5222    );
5223}
5224
5225#[gpui::test]
5226async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_creates_draft_on_main(
5227    cx: &mut TestAppContext,
5228) {
5229    // When a linked worktree thread is the ONLY thread in the project group
5230    // (no threads on the main repo either), archiving it should create a
5231    // draft on the main workspace, not the linked worktree workspace.
5232    agent_ui::test_support::init_test(cx);
5233    cx.update(|cx| {
5234        ThreadStore::init_global(cx);
5235        ThreadMetadataStore::init_global(cx);
5236        language_model::LanguageModelRegistry::test(cx);
5237        prompt_store::init(cx);
5238    });
5239
5240    let fs = FakeFs::new(cx.executor());
5241
5242    fs.insert_tree(
5243        "/project",
5244        serde_json::json!({
5245            ".git": {},
5246            "src": {},
5247        }),
5248    )
5249    .await;
5250
5251    fs.add_linked_worktree_for_repo(
5252        Path::new("/project/.git"),
5253        false,
5254        git::repository::Worktree {
5255            path: std::path::PathBuf::from("/wt-ochre-drift"),
5256            ref_name: Some("refs/heads/ochre-drift".into()),
5257            sha: "aaa".into(),
5258            is_main: false,
5259        },
5260    )
5261    .await;
5262
5263    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5264
5265    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5266    let worktree_project =
5267        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
5268
5269    main_project
5270        .update(cx, |p, cx| p.git_scans_complete(cx))
5271        .await;
5272    worktree_project
5273        .update(cx, |p, cx| p.git_scans_complete(cx))
5274        .await;
5275
5276    let (multi_workspace, cx) =
5277        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5278
5279    let sidebar = setup_sidebar(&multi_workspace, cx);
5280
5281    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5282        mw.test_add_workspace(worktree_project.clone(), window, cx)
5283    });
5284
5285    let main_workspace =
5286        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5287    let _main_panel = add_agent_panel(&main_workspace, cx);
5288    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
5289
5290    // Activate the linked worktree workspace.
5291    multi_workspace.update_in(cx, |mw, window, cx| {
5292        mw.activate(worktree_workspace.clone(), window, cx);
5293    });
5294
5295    // Open a thread on the linked worktree — this is the ONLY thread.
5296    let connection = StubAgentConnection::new();
5297    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
5298    send_message(&worktree_panel, cx);
5299
5300    let worktree_thread_id = active_session_id(&worktree_panel, cx);
5301
5302    cx.update(|_, cx| {
5303        connection.send_update(
5304            worktree_thread_id.clone(),
5305            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5306            cx,
5307        );
5308    });
5309
5310    save_thread_metadata(
5311        worktree_thread_id.clone(),
5312        "Ochre Drift Thread".into(),
5313        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5314        None,
5315        &worktree_project,
5316        cx,
5317    );
5318
5319    cx.run_until_parked();
5320
5321    // Archive it — there are no other threads in the group.
5322    sidebar.update_in(cx, |sidebar, window, cx| {
5323        sidebar.archive_thread(&worktree_thread_id, window, cx);
5324    });
5325
5326    cx.run_until_parked();
5327
5328    let entries_after = visible_entries_as_strings(&sidebar, cx);
5329
5330    // No entry should reference the linked worktree.
5331    assert!(
5332        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
5333        "no entry should reference the archived worktree, got: {entries_after:?}"
5334    );
5335
5336    // The active entry should be a draft on the main workspace.
5337    sidebar.read_with(cx, |s, _| {
5338        assert_active_draft(
5339            s,
5340            &main_workspace,
5341            "active entry should be a draft on the main workspace",
5342        );
5343    });
5344}
5345
5346#[gpui::test]
5347async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
5348    // When a linked worktree thread is archived but the group has other
5349    // threads (e.g. on the main project), archive_thread should select
5350    // the nearest sibling.
5351    agent_ui::test_support::init_test(cx);
5352    cx.update(|cx| {
5353        ThreadStore::init_global(cx);
5354        ThreadMetadataStore::init_global(cx);
5355        language_model::LanguageModelRegistry::test(cx);
5356        prompt_store::init(cx);
5357    });
5358
5359    let fs = FakeFs::new(cx.executor());
5360
5361    fs.insert_tree(
5362        "/project",
5363        serde_json::json!({
5364            ".git": {},
5365            "src": {},
5366        }),
5367    )
5368    .await;
5369
5370    fs.add_linked_worktree_for_repo(
5371        Path::new("/project/.git"),
5372        false,
5373        git::repository::Worktree {
5374            path: std::path::PathBuf::from("/wt-ochre-drift"),
5375            ref_name: Some("refs/heads/ochre-drift".into()),
5376            sha: "aaa".into(),
5377            is_main: false,
5378        },
5379    )
5380    .await;
5381
5382    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5383
5384    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5385    let worktree_project =
5386        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
5387
5388    main_project
5389        .update(cx, |p, cx| p.git_scans_complete(cx))
5390        .await;
5391    worktree_project
5392        .update(cx, |p, cx| p.git_scans_complete(cx))
5393        .await;
5394
5395    let (multi_workspace, cx) =
5396        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5397
5398    let sidebar = setup_sidebar(&multi_workspace, cx);
5399
5400    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5401        mw.test_add_workspace(worktree_project.clone(), window, cx)
5402    });
5403
5404    let main_workspace =
5405        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
5406    let _main_panel = add_agent_panel(&main_workspace, cx);
5407    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
5408
5409    // Activate the linked worktree workspace.
5410    multi_workspace.update_in(cx, |mw, window, cx| {
5411        mw.activate(worktree_workspace.clone(), window, cx);
5412    });
5413
5414    // Open a thread on the linked worktree.
5415    let connection = StubAgentConnection::new();
5416    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
5417    send_message(&worktree_panel, cx);
5418
5419    let worktree_thread_id = active_session_id(&worktree_panel, cx);
5420
5421    cx.update(|_, cx| {
5422        connection.send_update(
5423            worktree_thread_id.clone(),
5424            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5425            cx,
5426        );
5427    });
5428
5429    save_thread_metadata(
5430        worktree_thread_id.clone(),
5431        "Ochre Drift Thread".into(),
5432        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5433        None,
5434        &worktree_project,
5435        cx,
5436    );
5437
5438    // Save a sibling thread on the main project.
5439    let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
5440    save_thread_metadata(
5441        main_thread_id,
5442        "Main Project Thread".into(),
5443        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5444        None,
5445        &main_project,
5446        cx,
5447    );
5448
5449    cx.run_until_parked();
5450
5451    // Confirm the worktree thread is active.
5452    sidebar.read_with(cx, |s, _| {
5453        assert_active_thread(
5454            s,
5455            &worktree_thread_id,
5456            "worktree thread should be active before archiving",
5457        );
5458    });
5459
5460    // Archive the worktree thread.
5461    sidebar.update_in(cx, |sidebar, window, cx| {
5462        sidebar.archive_thread(&worktree_thread_id, window, cx);
5463    });
5464
5465    cx.run_until_parked();
5466
5467    // The worktree workspace was removed and a draft was created on the
5468    // main workspace. No entry should reference the linked worktree.
5469    let entries_after = visible_entries_as_strings(&sidebar, cx);
5470    assert!(
5471        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
5472        "no entry should reference the archived worktree, got: {entries_after:?}"
5473    );
5474
5475    // The main project thread should still be visible.
5476    assert!(
5477        entries_after
5478            .iter()
5479            .any(|s| s.contains("Main Project Thread")),
5480        "main project thread should still be visible, got: {entries_after:?}"
5481    );
5482}
5483
5484#[gpui::test]
5485async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
5486    // When a linked worktree is opened as its own workspace and the user
5487    // switches away, the workspace must still be reachable from a NewThread
5488    // sidebar entry. Pressing RemoveSelectedThread (shift-backspace) on that
5489    // entry should remove the workspace.
5490    init_test(cx);
5491    let fs = FakeFs::new(cx.executor());
5492
5493    fs.insert_tree(
5494        "/project",
5495        serde_json::json!({
5496            ".git": {
5497                "worktrees": {
5498                    "feature-a": {
5499                        "commondir": "../../",
5500                        "HEAD": "ref: refs/heads/feature-a",
5501                    },
5502                },
5503            },
5504            "src": {},
5505        }),
5506    )
5507    .await;
5508
5509    fs.insert_tree(
5510        "/wt-feature-a",
5511        serde_json::json!({
5512            ".git": "gitdir: /project/.git/worktrees/feature-a",
5513            "src": {},
5514        }),
5515    )
5516    .await;
5517
5518    fs.add_linked_worktree_for_repo(
5519        Path::new("/project/.git"),
5520        false,
5521        git::repository::Worktree {
5522            path: PathBuf::from("/wt-feature-a"),
5523            ref_name: Some("refs/heads/feature-a".into()),
5524            sha: "aaa".into(),
5525            is_main: false,
5526        },
5527    )
5528    .await;
5529
5530    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5531
5532    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5533    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5534
5535    main_project
5536        .update(cx, |p, cx| p.git_scans_complete(cx))
5537        .await;
5538    worktree_project
5539        .update(cx, |p, cx| p.git_scans_complete(cx))
5540        .await;
5541
5542    let (multi_workspace, cx) =
5543        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
5544    let sidebar = setup_sidebar(&multi_workspace, cx);
5545
5546    // Open the linked worktree as a separate workspace (simulates cmd-o).
5547    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5548        mw.test_add_workspace(worktree_project.clone(), window, cx)
5549    });
5550    add_agent_panel(&worktree_workspace, cx);
5551    cx.run_until_parked();
5552
5553    // Switch back to the main workspace.
5554    multi_workspace.update_in(cx, |mw, window, cx| {
5555        let main_ws = mw.workspaces().next().unwrap().clone();
5556        mw.activate(main_ws, window, cx);
5557    });
5558    cx.run_until_parked();
5559
5560    sidebar.update_in(cx, |sidebar, _window, cx| {
5561        sidebar.update_entries(cx);
5562    });
5563    cx.run_until_parked();
5564
5565    // The linked worktree workspace must be reachable from some sidebar entry.
5566    let worktree_ws_id = worktree_workspace.entity_id();
5567    let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
5568        let mw = multi_workspace.read(cx);
5569        sidebar
5570            .contents
5571            .entries
5572            .iter()
5573            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
5574            .map(|ws| ws.entity_id())
5575            .collect()
5576    });
5577    assert!(
5578        reachable.contains(&worktree_ws_id),
5579        "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
5580    );
5581
5582    // Find the NewThread entry for the linked worktree and dismiss it.
5583    let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
5584        sidebar
5585            .contents
5586            .entries
5587            .iter()
5588            .position(|entry| {
5589                matches!(
5590                    entry,
5591                    ListEntry::NewThread {
5592                        workspace: Some(_),
5593                        ..
5594                    }
5595                )
5596            })
5597            .expect("expected a NewThread entry for the linked worktree")
5598    });
5599
5600    assert_eq!(
5601        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5602        2
5603    );
5604
5605    sidebar.update_in(cx, |sidebar, window, cx| {
5606        sidebar.selection = Some(new_thread_ix);
5607        sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
5608    });
5609    cx.run_until_parked();
5610
5611    assert_eq!(
5612        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5613        1,
5614        "linked worktree workspace should be removed after dismissing NewThread entry"
5615    );
5616}
5617
5618#[gpui::test]
5619async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
5620    // When only a linked worktree workspace is open (not the main repo),
5621    // threads saved against the main repo should still appear in the sidebar.
5622    init_test(cx);
5623    let fs = FakeFs::new(cx.executor());
5624
5625    // Create the main repo with a linked worktree.
5626    fs.insert_tree(
5627        "/project",
5628        serde_json::json!({
5629            ".git": {
5630                "worktrees": {
5631                    "feature-a": {
5632                        "commondir": "../../",
5633                        "HEAD": "ref: refs/heads/feature-a",
5634                    },
5635                },
5636            },
5637            "src": {},
5638        }),
5639    )
5640    .await;
5641
5642    fs.insert_tree(
5643        "/wt-feature-a",
5644        serde_json::json!({
5645            ".git": "gitdir: /project/.git/worktrees/feature-a",
5646            "src": {},
5647        }),
5648    )
5649    .await;
5650
5651    fs.add_linked_worktree_for_repo(
5652        std::path::Path::new("/project/.git"),
5653        false,
5654        git::repository::Worktree {
5655            path: std::path::PathBuf::from("/wt-feature-a"),
5656            ref_name: Some("refs/heads/feature-a".into()),
5657            sha: "abc".into(),
5658            is_main: false,
5659        },
5660    )
5661    .await;
5662
5663    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5664
5665    // Only open the linked worktree as a workspace — NOT the main repo.
5666    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5667    worktree_project
5668        .update(cx, |p, cx| p.git_scans_complete(cx))
5669        .await;
5670
5671    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5672    main_project
5673        .update(cx, |p, cx| p.git_scans_complete(cx))
5674        .await;
5675
5676    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5677        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
5678    });
5679    let sidebar = setup_sidebar(&multi_workspace, cx);
5680
5681    // Save a thread against the MAIN repo path.
5682    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
5683
5684    // Save a thread against the linked worktree path.
5685    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
5686
5687    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5688    cx.run_until_parked();
5689
5690    // Both threads should be visible: the worktree thread by direct lookup,
5691    // and the main repo thread because the workspace is a linked worktree
5692    // and we also query the main repo path.
5693    let entries = visible_entries_as_strings(&sidebar, cx);
5694    assert!(
5695        entries.iter().any(|e| e.contains("Main Repo Thread")),
5696        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
5697    );
5698    assert!(
5699        entries.iter().any(|e| e.contains("Worktree Thread")),
5700        "expected worktree thread to be visible, got: {entries:?}"
5701    );
5702}
5703
5704async fn init_multi_project_test(
5705    paths: &[&str],
5706    cx: &mut TestAppContext,
5707) -> (Arc<FakeFs>, Entity<project::Project>) {
5708    agent_ui::test_support::init_test(cx);
5709    cx.update(|cx| {
5710        ThreadStore::init_global(cx);
5711        ThreadMetadataStore::init_global(cx);
5712        language_model::LanguageModelRegistry::test(cx);
5713        prompt_store::init(cx);
5714    });
5715    let fs = FakeFs::new(cx.executor());
5716    for path in paths {
5717        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
5718            .await;
5719    }
5720    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5721    let project =
5722        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
5723    (fs, project)
5724}
5725
5726async fn add_test_project(
5727    path: &str,
5728    fs: &Arc<FakeFs>,
5729    multi_workspace: &Entity<MultiWorkspace>,
5730    cx: &mut gpui::VisualTestContext,
5731) -> Entity<Workspace> {
5732    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
5733    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5734        mw.test_add_workspace(project, window, cx)
5735    });
5736    cx.run_until_parked();
5737    workspace
5738}
5739
5740#[gpui::test]
5741async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
5742    let (fs, project_a) =
5743        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
5744    let (multi_workspace, cx) =
5745        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
5746    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
5747
5748    // Sidebar starts closed. Initial workspace A is transient.
5749    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5750    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
5751    assert_eq!(
5752        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5753        1
5754    );
5755    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
5756
5757    // Add B — replaces A as the transient workspace.
5758    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
5759    assert_eq!(
5760        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5761        1
5762    );
5763    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
5764
5765    // Add C — replaces B as the transient workspace.
5766    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
5767    assert_eq!(
5768        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5769        1
5770    );
5771    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
5772}
5773
5774#[gpui::test]
5775async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
5776    let (fs, project_a) = init_multi_project_test(
5777        &["/project-a", "/project-b", "/project-c", "/project-d"],
5778        cx,
5779    )
5780    .await;
5781    let (multi_workspace, cx) =
5782        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
5783    let _sidebar = setup_sidebar(&multi_workspace, cx);
5784    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
5785
5786    // Add B — retained since sidebar is open.
5787    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
5788    assert_eq!(
5789        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5790        2
5791    );
5792
5793    // Switch to A — B survives. (Switching from one internal workspace, to another)
5794    multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
5795    cx.run_until_parked();
5796    assert_eq!(
5797        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5798        2
5799    );
5800
5801    // Close sidebar — both A and B remain retained.
5802    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
5803    cx.run_until_parked();
5804    assert_eq!(
5805        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5806        2
5807    );
5808
5809    // Add C — added as new transient workspace. (switching from retained, to transient)
5810    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
5811    assert_eq!(
5812        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5813        3
5814    );
5815    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
5816
5817    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
5818    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
5819    assert_eq!(
5820        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5821        3
5822    );
5823    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
5824}
5825
5826#[gpui::test]
5827async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
5828    let (fs, project_a) =
5829        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
5830    let (multi_workspace, cx) =
5831        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
5832    setup_sidebar_closed(&multi_workspace, cx);
5833
5834    // Add B — replaces A as the transient workspace (A is discarded).
5835    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
5836    assert_eq!(
5837        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5838        1
5839    );
5840    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
5841
5842    // Open sidebar — promotes the transient B to retained.
5843    multi_workspace.update_in(cx, |mw, window, cx| {
5844        mw.toggle_sidebar(window, cx);
5845    });
5846    cx.run_until_parked();
5847    assert_eq!(
5848        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5849        1
5850    );
5851    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
5852
5853    // Close sidebar — the retained B remains.
5854    multi_workspace.update_in(cx, |mw, window, cx| {
5855        mw.toggle_sidebar(window, cx);
5856    });
5857
5858    // Add C — added as new transient workspace.
5859    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
5860    assert_eq!(
5861        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5862        2
5863    );
5864    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
5865}
5866
5867#[gpui::test]
5868async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
5869    init_test(cx);
5870    let fs = FakeFs::new(cx.executor());
5871
5872    fs.insert_tree(
5873        "/project",
5874        serde_json::json!({
5875            ".git": {
5876                "worktrees": {
5877                    "feature-a": {
5878                        "commondir": "../../",
5879                        "HEAD": "ref: refs/heads/feature-a",
5880                    },
5881                },
5882            },
5883            "src": {},
5884        }),
5885    )
5886    .await;
5887
5888    fs.insert_tree(
5889        "/wt-feature-a",
5890        serde_json::json!({
5891            ".git": "gitdir: /project/.git/worktrees/feature-a",
5892            "src": {},
5893        }),
5894    )
5895    .await;
5896
5897    fs.add_linked_worktree_for_repo(
5898        Path::new("/project/.git"),
5899        false,
5900        git::repository::Worktree {
5901            path: PathBuf::from("/wt-feature-a"),
5902            ref_name: Some("refs/heads/feature-a".into()),
5903            sha: "abc".into(),
5904            is_main: false,
5905        },
5906    )
5907    .await;
5908
5909    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5910
5911    // Only a linked worktree workspace is open — no workspace for /project.
5912    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5913    worktree_project
5914        .update(cx, |p, cx| p.git_scans_complete(cx))
5915        .await;
5916
5917    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5918        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
5919    });
5920    let sidebar = setup_sidebar(&multi_workspace, cx);
5921
5922    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
5923    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
5924    cx.update(|_, cx| {
5925        let metadata = ThreadMetadata {
5926            session_id: legacy_session.clone(),
5927            agent_id: agent::ZED_AGENT_ID.clone(),
5928            title: "Legacy Main Thread".into(),
5929            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5930            created_at: None,
5931            folder_paths: PathList::new(&[PathBuf::from("/project")]),
5932            main_worktree_paths: PathList::default(),
5933            archived: false,
5934        };
5935        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
5936    });
5937    cx.run_until_parked();
5938
5939    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5940    cx.run_until_parked();
5941
5942    // The legacy thread should appear in the sidebar under the project group.
5943    let entries = visible_entries_as_strings(&sidebar, cx);
5944    assert!(
5945        entries.iter().any(|e| e.contains("Legacy Main Thread")),
5946        "legacy thread should be visible: {entries:?}",
5947    );
5948
5949    // Verify only 1 workspace before clicking.
5950    assert_eq!(
5951        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
5952        1,
5953    );
5954
5955    // Focus and select the legacy thread, then confirm.
5956    focus_sidebar(&sidebar, cx);
5957    let thread_index = sidebar.read_with(cx, |sidebar, _| {
5958        sidebar
5959            .contents
5960            .entries
5961            .iter()
5962            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
5963            .expect("legacy thread should be in entries")
5964    });
5965    sidebar.update_in(cx, |sidebar, _window, _cx| {
5966        sidebar.selection = Some(thread_index);
5967    });
5968    cx.dispatch_action(Confirm);
5969    cx.run_until_parked();
5970
5971    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
5972    let new_path_list =
5973        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
5974    assert_eq!(
5975        new_path_list,
5976        PathList::new(&[PathBuf::from("/project")]),
5977        "the new workspace should be for the main repo, not the linked worktree",
5978    );
5979}
5980
5981mod property_test {
5982    use super::*;
5983    use gpui::proptest::prelude::*;
5984
5985    struct UnopenedWorktree {
5986        path: String,
5987        main_workspace_path: String,
5988    }
5989
5990    struct TestState {
5991        fs: Arc<FakeFs>,
5992        thread_counter: u32,
5993        workspace_counter: u32,
5994        worktree_counter: u32,
5995        saved_thread_ids: Vec<acp::SessionId>,
5996        unopened_worktrees: Vec<UnopenedWorktree>,
5997    }
5998
5999    impl TestState {
6000        fn new(fs: Arc<FakeFs>) -> Self {
6001            Self {
6002                fs,
6003                thread_counter: 0,
6004                workspace_counter: 1,
6005                worktree_counter: 0,
6006                saved_thread_ids: Vec::new(),
6007                unopened_worktrees: Vec::new(),
6008            }
6009        }
6010
6011        fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
6012            let id = self.thread_counter;
6013            self.thread_counter += 1;
6014            acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
6015        }
6016
6017        fn next_workspace_path(&mut self) -> String {
6018            let id = self.workspace_counter;
6019            self.workspace_counter += 1;
6020            format!("/prop-project-{id}")
6021        }
6022
6023        fn next_worktree_name(&mut self) -> String {
6024            let id = self.worktree_counter;
6025            self.worktree_counter += 1;
6026            format!("wt-{id}")
6027        }
6028    }
6029
6030    #[derive(Debug)]
6031    enum Operation {
6032        SaveThread { project_group_index: usize },
6033        SaveWorktreeThread { worktree_index: usize },
6034        ToggleAgentPanel,
6035        CreateDraftThread,
6036        AddProject { use_worktree: bool },
6037        ArchiveThread { index: usize },
6038        SwitchToThread { index: usize },
6039        SwitchToProjectGroup { index: usize },
6040        AddLinkedWorktree { project_group_index: usize },
6041    }
6042
6043    // Distribution (out of 20 slots):
6044    //   SaveThread:              5 slots (~25%)
6045    //   SaveWorktreeThread:      2 slots (~10%)
6046    //   ToggleAgentPanel:        1 slot  (~5%)
6047    //   CreateDraftThread:       1 slot  (~5%)
6048    //   AddProject:              1 slot  (~5%)
6049    //   ArchiveThread:           2 slots (~10%)
6050    //   SwitchToThread:          2 slots (~10%)
6051    //   SwitchToProjectGroup:    2 slots (~10%)
6052    //   AddLinkedWorktree:       4 slots (~20%)
6053    const DISTRIBUTION_SLOTS: u32 = 20;
6054
6055    impl TestState {
6056        fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
6057            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
6058
6059            match raw % DISTRIBUTION_SLOTS {
6060                0..=4 => Operation::SaveThread {
6061                    project_group_index: extra % project_group_count,
6062                },
6063                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
6064                    worktree_index: extra % self.unopened_worktrees.len(),
6065                },
6066                5..=6 => Operation::SaveThread {
6067                    project_group_index: extra % project_group_count,
6068                },
6069                7 => Operation::ToggleAgentPanel,
6070                8 => Operation::CreateDraftThread,
6071                9 => Operation::AddProject {
6072                    use_worktree: !self.unopened_worktrees.is_empty(),
6073                },
6074                10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
6075                    index: extra % self.saved_thread_ids.len(),
6076                },
6077                10..=11 => Operation::AddProject {
6078                    use_worktree: !self.unopened_worktrees.is_empty(),
6079                },
6080                12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
6081                    index: extra % self.saved_thread_ids.len(),
6082                },
6083                12..=13 => Operation::SwitchToProjectGroup {
6084                    index: extra % project_group_count,
6085                },
6086                14..=15 => Operation::SwitchToProjectGroup {
6087                    index: extra % project_group_count,
6088                },
6089                16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
6090                    project_group_index: extra % project_group_count,
6091                },
6092                16..=19 => Operation::SaveThread {
6093                    project_group_index: extra % project_group_count,
6094                },
6095                _ => unreachable!(),
6096            }
6097        }
6098    }
6099
6100    fn save_thread_to_path_with_main(
6101        state: &mut TestState,
6102        path_list: PathList,
6103        main_worktree_paths: PathList,
6104        cx: &mut gpui::VisualTestContext,
6105    ) {
6106        let session_id = state.next_metadata_only_thread_id();
6107        let title: SharedString = format!("Thread {}", session_id).into();
6108        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
6109            .unwrap()
6110            + chrono::Duration::seconds(state.thread_counter as i64);
6111        let metadata = ThreadMetadata {
6112            session_id,
6113            agent_id: agent::ZED_AGENT_ID.clone(),
6114            title,
6115            updated_at,
6116            created_at: None,
6117            folder_paths: path_list,
6118            main_worktree_paths,
6119            archived: false,
6120        };
6121        cx.update(|_, cx| {
6122            ThreadMetadataStore::global(cx)
6123                .update(cx, |store, cx| store.save_manually(metadata, cx))
6124        });
6125        cx.run_until_parked();
6126    }
6127
6128    async fn perform_operation(
6129        operation: Operation,
6130        state: &mut TestState,
6131        multi_workspace: &Entity<MultiWorkspace>,
6132        sidebar: &Entity<Sidebar>,
6133        cx: &mut gpui::VisualTestContext,
6134    ) {
6135        match operation {
6136            Operation::SaveThread {
6137                project_group_index,
6138            } => {
6139                // Find a workspace for this project group and create a real
6140                // thread via its agent panel.
6141                let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
6142                    let key = mw.project_group_keys().nth(project_group_index).unwrap();
6143                    let ws = mw
6144                        .workspaces_for_project_group(key, cx)
6145                        .next()
6146                        .unwrap_or(mw.workspace())
6147                        .clone();
6148                    let project = ws.read(cx).project().clone();
6149                    (ws, project)
6150                });
6151
6152                let panel =
6153                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
6154                if let Some(panel) = panel {
6155                    let connection = StubAgentConnection::new();
6156                    connection.set_next_prompt_updates(vec![
6157                        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
6158                            "Done".into(),
6159                        )),
6160                    ]);
6161                    open_thread_with_connection(&panel, connection, cx);
6162                    send_message(&panel, cx);
6163                    let session_id = active_session_id(&panel, cx);
6164                    state.saved_thread_ids.push(session_id.clone());
6165
6166                    let title: SharedString = format!("Thread {}", state.thread_counter).into();
6167                    state.thread_counter += 1;
6168                    let updated_at =
6169                        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
6170                            .unwrap()
6171                            + chrono::Duration::seconds(state.thread_counter as i64);
6172                    save_thread_metadata(session_id, title, updated_at, None, &project, cx);
6173                }
6174            }
6175            Operation::SaveWorktreeThread { worktree_index } => {
6176                let worktree = &state.unopened_worktrees[worktree_index];
6177                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
6178                let main_worktree_paths =
6179                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
6180                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
6181            }
6182
6183            Operation::ToggleAgentPanel => {
6184                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6185                let panel_open =
6186                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
6187                workspace.update_in(cx, |workspace, window, cx| {
6188                    if panel_open {
6189                        workspace.close_panel::<AgentPanel>(window, cx);
6190                    } else {
6191                        workspace.open_panel::<AgentPanel>(window, cx);
6192                    }
6193                });
6194            }
6195            Operation::CreateDraftThread => {
6196                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
6197                let panel =
6198                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
6199                if let Some(panel) = panel {
6200                    let connection = StubAgentConnection::new();
6201                    open_thread_with_connection(&panel, connection, cx);
6202                    cx.run_until_parked();
6203                }
6204                workspace.update_in(cx, |workspace, window, cx| {
6205                    workspace.focus_panel::<AgentPanel>(window, cx);
6206                });
6207            }
6208            Operation::AddProject { use_worktree } => {
6209                let path = if use_worktree {
6210                    // Open an existing linked worktree as a project (simulates Cmd+O
6211                    // on a worktree directory).
6212                    state.unopened_worktrees.remove(0).path
6213                } else {
6214                    // Create a brand new project.
6215                    let path = state.next_workspace_path();
6216                    state
6217                        .fs
6218                        .insert_tree(
6219                            &path,
6220                            serde_json::json!({
6221                                ".git": {},
6222                                "src": {},
6223                            }),
6224                        )
6225                        .await;
6226                    path
6227                };
6228                let project = project::Project::test(
6229                    state.fs.clone() as Arc<dyn fs::Fs>,
6230                    [path.as_ref()],
6231                    cx,
6232                )
6233                .await;
6234                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
6235                multi_workspace.update_in(cx, |mw, window, cx| {
6236                    mw.test_add_workspace(project.clone(), window, cx)
6237                });
6238            }
6239            Operation::ArchiveThread { index } => {
6240                let session_id = state.saved_thread_ids[index].clone();
6241                sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
6242                    sidebar.archive_thread(&session_id, window, cx);
6243                });
6244                cx.run_until_parked();
6245                state.saved_thread_ids.remove(index);
6246            }
6247            Operation::SwitchToThread { index } => {
6248                let session_id = state.saved_thread_ids[index].clone();
6249                // Find the thread's position in the sidebar entries and select it.
6250                let thread_index = sidebar.read_with(cx, |sidebar, _| {
6251                    sidebar.contents.entries.iter().position(|entry| {
6252                        matches!(
6253                            entry,
6254                            ListEntry::Thread(t) if t.metadata.session_id == session_id
6255                        )
6256                    })
6257                });
6258                if let Some(ix) = thread_index {
6259                    sidebar.update_in(cx, |sidebar, window, cx| {
6260                        sidebar.selection = Some(ix);
6261                        sidebar.confirm(&Confirm, window, cx);
6262                    });
6263                    cx.run_until_parked();
6264                }
6265            }
6266            Operation::SwitchToProjectGroup { index } => {
6267                let workspace = multi_workspace.read_with(cx, |mw, cx| {
6268                    let key = mw.project_group_keys().nth(index).unwrap();
6269                    mw.workspaces_for_project_group(key, cx)
6270                        .next()
6271                        .unwrap_or(mw.workspace())
6272                        .clone()
6273                });
6274                multi_workspace.update_in(cx, |mw, window, cx| {
6275                    mw.activate(workspace, window, cx);
6276                });
6277            }
6278            Operation::AddLinkedWorktree {
6279                project_group_index,
6280            } => {
6281                // Get the main worktree path from the project group key.
6282                let main_path = multi_workspace.read_with(cx, |mw, _| {
6283                    let key = mw.project_group_keys().nth(project_group_index).unwrap();
6284                    key.path_list()
6285                        .paths()
6286                        .first()
6287                        .unwrap()
6288                        .to_string_lossy()
6289                        .to_string()
6290                });
6291                let dot_git = format!("{}/.git", main_path);
6292                let worktree_name = state.next_worktree_name();
6293                let worktree_path = format!("/worktrees/{}", worktree_name);
6294
6295                state.fs
6296                    .insert_tree(
6297                        &worktree_path,
6298                        serde_json::json!({
6299                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
6300                            "src": {},
6301                        }),
6302                    )
6303                    .await;
6304
6305                // Also create the worktree metadata dir inside the main repo's .git
6306                state
6307                    .fs
6308                    .insert_tree(
6309                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
6310                        serde_json::json!({
6311                            "commondir": "../../",
6312                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
6313                        }),
6314                    )
6315                    .await;
6316
6317                let dot_git_path = std::path::Path::new(&dot_git);
6318                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
6319                state
6320                    .fs
6321                    .add_linked_worktree_for_repo(
6322                        dot_git_path,
6323                        false,
6324                        git::repository::Worktree {
6325                            path: worktree_pathbuf,
6326                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
6327                            sha: "aaa".into(),
6328                            is_main: false,
6329                        },
6330                    )
6331                    .await;
6332
6333                // Re-scan the main workspace's project so it discovers the new worktree.
6334                let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
6335                    let key = mw.project_group_keys().nth(project_group_index).unwrap();
6336                    mw.workspaces_for_project_group(key, cx)
6337                        .next()
6338                        .unwrap()
6339                        .clone()
6340                });
6341                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
6342                main_project
6343                    .update(cx, |p, cx| p.git_scans_complete(cx))
6344                    .await;
6345
6346                state.unopened_worktrees.push(UnopenedWorktree {
6347                    path: worktree_path,
6348                    main_workspace_path: main_path.clone(),
6349                });
6350            }
6351        }
6352    }
6353
6354    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
6355        sidebar.update_in(cx, |sidebar, _window, cx| {
6356            sidebar.collapsed_groups.clear();
6357            let path_lists: Vec<PathList> = sidebar
6358                .contents
6359                .entries
6360                .iter()
6361                .filter_map(|entry| match entry {
6362                    ListEntry::ProjectHeader { key, .. } => Some(key.path_list().clone()),
6363                    _ => None,
6364                })
6365                .collect();
6366            for path_list in path_lists {
6367                sidebar.expanded_groups.insert(path_list, 10_000);
6368            }
6369            sidebar.update_entries(cx);
6370        });
6371    }
6372
6373    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
6374        verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
6375        verify_all_threads_are_shown(sidebar, cx)?;
6376        verify_active_state_matches_current_workspace(sidebar, cx)?;
6377        verify_all_workspaces_are_reachable(sidebar, cx)?;
6378        Ok(())
6379    }
6380
6381    fn verify_every_group_in_multiworkspace_is_shown(
6382        sidebar: &Sidebar,
6383        cx: &App,
6384    ) -> anyhow::Result<()> {
6385        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
6386            anyhow::bail!("sidebar should still have an associated multi-workspace");
6387        };
6388
6389        let mw = multi_workspace.read(cx);
6390
6391        // Every project group key in the multi-workspace that has a
6392        // non-empty path list should appear as a ProjectHeader in the
6393        // sidebar.
6394        let expected_keys: HashSet<&project::ProjectGroupKey> = mw
6395            .project_group_keys()
6396            .filter(|k| !k.path_list().paths().is_empty())
6397            .collect();
6398
6399        let sidebar_keys: HashSet<&project::ProjectGroupKey> = sidebar
6400            .contents
6401            .entries
6402            .iter()
6403            .filter_map(|entry| match entry {
6404                ListEntry::ProjectHeader { key, .. } => Some(key),
6405                _ => None,
6406            })
6407            .collect();
6408
6409        let missing = &expected_keys - &sidebar_keys;
6410        let stray = &sidebar_keys - &expected_keys;
6411
6412        anyhow::ensure!(
6413            missing.is_empty() && stray.is_empty(),
6414            "sidebar project groups don't match multi-workspace.\n\
6415             Only in multi-workspace (missing): {:?}\n\
6416             Only in sidebar (stray): {:?}",
6417            missing,
6418            stray,
6419        );
6420
6421        Ok(())
6422    }
6423
6424    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
6425        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
6426            anyhow::bail!("sidebar should still have an associated multi-workspace");
6427        };
6428        let workspaces = multi_workspace
6429            .read(cx)
6430            .workspaces()
6431            .cloned()
6432            .collect::<Vec<_>>();
6433        let thread_store = ThreadMetadataStore::global(cx);
6434
6435        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
6436            .contents
6437            .entries
6438            .iter()
6439            .filter_map(|entry| entry.session_id().cloned())
6440            .collect();
6441
6442        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
6443
6444        // Query using the same approach as the sidebar: iterate project
6445        // group keys, then do main + legacy queries per group.
6446        let mw = multi_workspace.read(cx);
6447        let mut workspaces_by_group: HashMap<project::ProjectGroupKey, Vec<Entity<Workspace>>> =
6448            HashMap::default();
6449        for workspace in &workspaces {
6450            let key = workspace.read(cx).project_group_key(cx);
6451            workspaces_by_group
6452                .entry(key)
6453                .or_default()
6454                .push(workspace.clone());
6455        }
6456
6457        for group_key in mw.project_group_keys() {
6458            let path_list = group_key.path_list().clone();
6459            if path_list.paths().is_empty() {
6460                continue;
6461            }
6462
6463            let group_workspaces = workspaces_by_group
6464                .get(group_key)
6465                .map(|ws| ws.as_slice())
6466                .unwrap_or_default();
6467
6468            // Main code path queries (run for all groups, even without workspaces).
6469            for metadata in thread_store
6470                .read(cx)
6471                .entries_for_main_worktree_path(&path_list)
6472            {
6473                metadata_thread_ids.insert(metadata.session_id.clone());
6474            }
6475            for metadata in thread_store.read(cx).entries_for_path(&path_list) {
6476                metadata_thread_ids.insert(metadata.session_id.clone());
6477            }
6478
6479            // Legacy: per-workspace queries for different root paths.
6480            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
6481                .iter()
6482                .flat_map(|ws| {
6483                    ws.read(cx)
6484                        .root_paths(cx)
6485                        .into_iter()
6486                        .map(|p| p.to_path_buf())
6487                })
6488                .collect();
6489
6490            for workspace in group_workspaces {
6491                let ws_path_list = workspace_path_list(workspace, cx);
6492                if ws_path_list != path_list {
6493                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list) {
6494                        metadata_thread_ids.insert(metadata.session_id.clone());
6495                    }
6496                }
6497            }
6498
6499            for workspace in group_workspaces {
6500                for snapshot in root_repository_snapshots(workspace, cx) {
6501                    let repo_path_list =
6502                        PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
6503                    if repo_path_list != path_list {
6504                        continue;
6505                    }
6506                    for linked_worktree in snapshot.linked_worktrees() {
6507                        if covered_paths.contains(&*linked_worktree.path) {
6508                            continue;
6509                        }
6510                        let worktree_path_list =
6511                            PathList::new(std::slice::from_ref(&linked_worktree.path));
6512                        for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list)
6513                        {
6514                            metadata_thread_ids.insert(metadata.session_id.clone());
6515                        }
6516                    }
6517                }
6518            }
6519        }
6520
6521        anyhow::ensure!(
6522            sidebar_thread_ids == metadata_thread_ids,
6523            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
6524            sidebar_thread_ids,
6525            metadata_thread_ids,
6526        );
6527        Ok(())
6528    }
6529
6530    fn verify_active_state_matches_current_workspace(
6531        sidebar: &Sidebar,
6532        cx: &App,
6533    ) -> anyhow::Result<()> {
6534        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
6535            anyhow::bail!("sidebar should still have an associated multi-workspace");
6536        };
6537
6538        let active_workspace = multi_workspace.read(cx).workspace();
6539
6540        // 1. active_entry must always be Some after rebuild_contents.
6541        let entry = sidebar
6542            .active_entry
6543            .as_ref()
6544            .ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?;
6545
6546        // 2. The entry's workspace must agree with the multi-workspace's
6547        //    active workspace.
6548        anyhow::ensure!(
6549            entry.workspace().entity_id() == active_workspace.entity_id(),
6550            "active_entry workspace ({:?}) != active workspace ({:?})",
6551            entry.workspace().entity_id(),
6552            active_workspace.entity_id(),
6553        );
6554
6555        // 3. The entry must match the agent panel's current state.
6556        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
6557        if panel.read(cx).active_thread_is_draft(cx) {
6558            anyhow::ensure!(
6559                matches!(entry, ActiveEntry::Draft(_)),
6560                "panel shows a draft but active_entry is {:?}",
6561                entry,
6562            );
6563        } else if let Some(session_id) = panel
6564            .read(cx)
6565            .active_conversation_view()
6566            .and_then(|cv| cv.read(cx).parent_id(cx))
6567        {
6568            anyhow::ensure!(
6569                matches!(entry, ActiveEntry::Thread { session_id: id, .. } if id == &session_id),
6570                "panel has session {:?} but active_entry is {:?}",
6571                session_id,
6572                entry,
6573            );
6574        }
6575
6576        // 4. Exactly one entry in sidebar contents must be uniquely
6577        //    identified by the active_entry.
6578        let matching_count = sidebar
6579            .contents
6580            .entries
6581            .iter()
6582            .filter(|e| entry.matches_entry(e))
6583            .count();
6584        anyhow::ensure!(
6585            matching_count == 1,
6586            "expected exactly 1 sidebar entry matching active_entry {:?}, found {}",
6587            entry,
6588            matching_count,
6589        );
6590
6591        Ok(())
6592    }
6593
6594    /// Every workspace in the multi-workspace should be "reachable" from
6595    /// the sidebar — meaning there is at least one entry (thread, draft,
6596    /// new-thread, or project header) that, when clicked, would activate
6597    /// that workspace.
6598    fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
6599        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
6600            anyhow::bail!("sidebar should still have an associated multi-workspace");
6601        };
6602
6603        let mw = multi_workspace.read(cx);
6604
6605        let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
6606            .contents
6607            .entries
6608            .iter()
6609            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
6610            .map(|ws| ws.entity_id())
6611            .collect();
6612
6613        let all_workspace_ids: HashSet<gpui::EntityId> =
6614            mw.workspaces().map(|ws| ws.entity_id()).collect();
6615
6616        let unreachable = &all_workspace_ids - &reachable_workspaces;
6617
6618        anyhow::ensure!(
6619            unreachable.is_empty(),
6620            "The following workspaces are not reachable from any sidebar entry: {:?}",
6621            unreachable,
6622        );
6623
6624        Ok(())
6625    }
6626
6627    #[gpui::property_test(config = ProptestConfig {
6628        cases: 50,
6629        ..Default::default()
6630    })]
6631    #[ignore = "temporarily disabled to unblock PRs from landing"]
6632    async fn test_sidebar_invariants(
6633        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)]
6634        raw_operations: Vec<u32>,
6635        cx: &mut TestAppContext,
6636    ) {
6637        agent_ui::test_support::init_test(cx);
6638        cx.update(|cx| {
6639            ThreadStore::init_global(cx);
6640            ThreadMetadataStore::init_global(cx);
6641            language_model::LanguageModelRegistry::test(cx);
6642            prompt_store::init(cx);
6643
6644            // Auto-add an AgentPanel to every workspace so that implicitly
6645            // created workspaces (e.g. from thread activation) also have one.
6646            cx.observe_new(
6647                |workspace: &mut Workspace,
6648                 window: Option<&mut Window>,
6649                 cx: &mut gpui::Context<Workspace>| {
6650                    if let Some(window) = window {
6651                        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
6652                        workspace.add_panel(panel, window, cx);
6653                    }
6654                },
6655            )
6656            .detach();
6657        });
6658
6659        let fs = FakeFs::new(cx.executor());
6660        fs.insert_tree(
6661            "/my-project",
6662            serde_json::json!({
6663                ".git": {},
6664                "src": {},
6665            }),
6666        )
6667        .await;
6668        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6669        let project =
6670            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
6671                .await;
6672        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
6673
6674        let (multi_workspace, cx) =
6675            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6676        let sidebar = setup_sidebar(&multi_workspace, cx);
6677
6678        let mut state = TestState::new(fs);
6679        let mut executed: Vec<String> = Vec::new();
6680
6681        for &raw_op in &raw_operations {
6682            let project_group_count =
6683                multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().count());
6684            let operation = state.generate_operation(raw_op, project_group_count);
6685            executed.push(format!("{:?}", operation));
6686            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
6687            cx.run_until_parked();
6688
6689            update_sidebar(&sidebar, cx);
6690            cx.run_until_parked();
6691
6692            let result =
6693                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
6694            if let Err(err) = result {
6695                let log = executed.join("\n  ");
6696                panic!(
6697                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
6698                    executed.len(),
6699                );
6700            }
6701        }
6702    }
6703}