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