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