sidebar_tests.rs

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