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