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