sidebar_tests.rs

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