sidebar_tests.rs

    1use super::*;
    2use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
    3use agent::ThreadStore;
    4use agent_ui::{
    5    ThreadId,
    6    test_support::{active_session_id, open_thread_with_connection, send_message},
    7    thread_metadata_store::{ThreadMetadata, WorktreePaths},
    8};
    9use chrono::DateTime;
   10use fs::{FakeFs, Fs};
   11use gpui::TestAppContext;
   12use pretty_assertions::assert_eq;
   13use project::AgentId;
   14use settings::SettingsStore;
   15use std::{
   16    path::{Path, PathBuf},
   17    sync::Arc,
   18};
   19use util::path_list::PathList;
   20
   21fn init_test(cx: &mut TestAppContext) {
   22    cx.update(|cx| {
   23        let settings_store = SettingsStore::test(cx);
   24        cx.set_global(settings_store);
   25        theme_settings::init(theme::LoadThemes::JustBase, cx);
   26        editor::init(cx);
   27        ThreadStore::init_global(cx);
   28        ThreadMetadataStore::init_global(cx);
   29        language_model::LanguageModelRegistry::test(cx);
   30        prompt_store::init(cx);
   31    });
   32}
   33
   34#[track_caller]
   35fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
   36    let active = sidebar.active_entry.as_ref();
   37    let matches = active.is_some_and(|entry| {
   38        // Match by session_id directly on active_entry.
   39        entry.session_id.as_ref() == Some(session_id)
   40            // Or match by finding the thread in sidebar entries.
   41            || sidebar.contents.entries.iter().any(|list_entry| {
   42                matches!(list_entry, ListEntry::Thread(t)
   43                    if t.metadata.session_id.as_ref() == Some(session_id)
   44                        && entry.matches_entry(list_entry))
   45            })
   46    });
   47    assert!(
   48        matches,
   49        "{msg}: expected active_entry for session {session_id:?}, got {:?}",
   50        active,
   51    );
   52}
   53
   54#[track_caller]
   55fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   56    let thread_id = sidebar
   57        .contents
   58        .entries
   59        .iter()
   60        .find_map(|entry| match entry {
   61            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
   62                Some(t.metadata.thread_id)
   63            }
   64            _ => None,
   65        });
   66    match thread_id {
   67        Some(tid) => {
   68            matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid)
   69        }
   70        // Thread not in sidebar entries — can't confirm it's active.
   71        None => false,
   72    }
   73}
   74
   75#[track_caller]
   76fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
   77    assert!(
   78        matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace),
   79        "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
   80        workspace.entity_id(),
   81        sidebar.active_entry,
   82    );
   83}
   84
   85fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   86    sidebar
   87        .contents
   88        .entries
   89        .iter()
   90        .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
   91}
   92
   93#[track_caller]
   94fn assert_remote_project_integration_sidebar_state(
   95    sidebar: &mut Sidebar,
   96    main_thread_id: &acp::SessionId,
   97    remote_thread_id: &acp::SessionId,
   98) {
   99    let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
  100        if let ListEntry::ProjectHeader { label, .. } = entry {
  101            Some(label.as_ref())
  102        } else {
  103            None
  104        }
  105    });
  106
  107    let Some(project_header) = project_headers.next() else {
  108        panic!("expected exactly one sidebar project header named `project`, found none");
  109    };
  110    assert_eq!(
  111        project_header, "project",
  112        "expected the only sidebar project header to be `project`"
  113    );
  114    if let Some(unexpected_header) = project_headers.next() {
  115        panic!(
  116            "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
  117        );
  118    }
  119
  120    let mut saw_main_thread = false;
  121    let mut saw_remote_thread = false;
  122    for entry in &sidebar.contents.entries {
  123        match entry {
  124            ListEntry::ProjectHeader { label, .. } => {
  125                assert_eq!(
  126                    label.as_ref(),
  127                    "project",
  128                    "expected the only sidebar project header to be `project`"
  129                );
  130            }
  131            ListEntry::Thread(thread)
  132                if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
  133            {
  134                saw_main_thread = true;
  135            }
  136            ListEntry::Thread(thread)
  137                if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
  138            {
  139                saw_remote_thread = true;
  140            }
  141            ListEntry::Thread(thread) if thread.is_draft => {}
  142            ListEntry::Thread(thread) => {
  143                let title = thread.metadata.display_title();
  144                panic!(
  145                    "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
  146                    title
  147                );
  148            }
  149            ListEntry::ViewMore { .. } => {
  150                panic!(
  151                    "unexpected `View More` entry while simulating remote project integration flicker"
  152                );
  153            }
  154        }
  155    }
  156
  157    assert!(
  158        saw_main_thread,
  159        "expected the sidebar to keep showing `Main Thread` under `project`"
  160    );
  161    assert!(
  162        saw_remote_thread,
  163        "expected the sidebar to keep showing `Worktree Thread` under `project`"
  164    );
  165}
  166
  167async fn init_test_project(
  168    worktree_path: &str,
  169    cx: &mut TestAppContext,
  170) -> Entity<project::Project> {
  171    init_test(cx);
  172    let fs = FakeFs::new(cx.executor());
  173    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
  174        .await;
  175    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
  176    project::Project::test(fs, [worktree_path.as_ref()], cx).await
  177}
  178
  179fn setup_sidebar(
  180    multi_workspace: &Entity<MultiWorkspace>,
  181    cx: &mut gpui::VisualTestContext,
  182) -> Entity<Sidebar> {
  183    let sidebar = setup_sidebar_closed(multi_workspace, cx);
  184    multi_workspace.update_in(cx, |mw, window, cx| {
  185        mw.toggle_sidebar(window, cx);
  186    });
  187    cx.run_until_parked();
  188    sidebar
  189}
  190
  191fn setup_sidebar_closed(
  192    multi_workspace: &Entity<MultiWorkspace>,
  193    cx: &mut gpui::VisualTestContext,
  194) -> Entity<Sidebar> {
  195    let multi_workspace = multi_workspace.clone();
  196    let sidebar =
  197        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  198    multi_workspace.update(cx, |mw, cx| {
  199        mw.register_sidebar(sidebar.clone(), cx);
  200    });
  201    cx.run_until_parked();
  202    sidebar
  203}
  204
  205async fn save_n_test_threads(
  206    count: u32,
  207    project: &Entity<project::Project>,
  208    cx: &mut gpui::VisualTestContext,
  209) {
  210    for i in 0..count {
  211        save_thread_metadata(
  212            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
  213            Some(format!("Thread {}", i + 1).into()),
  214            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
  215            None,
  216            project,
  217            cx,
  218        )
  219    }
  220    cx.run_until_parked();
  221}
  222
  223async fn save_test_thread_metadata(
  224    session_id: &acp::SessionId,
  225    project: &Entity<project::Project>,
  226    cx: &mut TestAppContext,
  227) {
  228    save_thread_metadata(
  229        session_id.clone(),
  230        Some("Test".into()),
  231        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  232        None,
  233        project,
  234        cx,
  235    )
  236}
  237
  238async fn save_named_thread_metadata(
  239    session_id: &str,
  240    title: &str,
  241    project: &Entity<project::Project>,
  242    cx: &mut gpui::VisualTestContext,
  243) {
  244    save_thread_metadata(
  245        acp::SessionId::new(Arc::from(session_id)),
  246        Some(SharedString::from(title.to_string())),
  247        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  248        None,
  249        project,
  250        cx,
  251    );
  252    cx.run_until_parked();
  253}
  254
  255fn save_thread_metadata(
  256    session_id: acp::SessionId,
  257    title: Option<SharedString>,
  258    updated_at: DateTime<Utc>,
  259    created_at: Option<DateTime<Utc>>,
  260    project: &Entity<project::Project>,
  261    cx: &mut TestAppContext,
  262) {
  263    cx.update(|cx| {
  264        let worktree_paths = project.read(cx).worktree_paths(cx);
  265        let thread_id = ThreadMetadataStore::global(cx)
  266            .read(cx)
  267            .entries()
  268            .find(|e| e.session_id.as_ref() == Some(&session_id))
  269            .map(|e| e.thread_id)
  270            .unwrap_or_else(ThreadId::new);
  271        let metadata = ThreadMetadata {
  272            thread_id,
  273            session_id: Some(session_id),
  274            agent_id: agent::ZED_AGENT_ID.clone(),
  275            title,
  276            updated_at,
  277            created_at,
  278            worktree_paths,
  279            archived: false,
  280            remote_connection: None,
  281        };
  282        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  283    });
  284    cx.run_until_parked();
  285}
  286
  287fn save_thread_metadata_with_main_paths(
  288    session_id: &str,
  289    title: &str,
  290    folder_paths: PathList,
  291    main_worktree_paths: PathList,
  292    updated_at: DateTime<Utc>,
  293    cx: &mut TestAppContext,
  294) {
  295    let session_id = acp::SessionId::new(Arc::from(session_id));
  296    let title = SharedString::from(title.to_string());
  297    let thread_id = cx.update(|cx| {
  298        ThreadMetadataStore::global(cx)
  299            .read(cx)
  300            .entries()
  301            .find(|e| e.session_id.as_ref() == Some(&session_id))
  302            .map(|e| e.thread_id)
  303            .unwrap_or_else(ThreadId::new)
  304    });
  305    let metadata = ThreadMetadata {
  306        thread_id,
  307        session_id: Some(session_id),
  308        agent_id: agent::ZED_AGENT_ID.clone(),
  309        title: Some(title),
  310        updated_at,
  311        created_at: None,
  312        worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
  313        archived: false,
  314        remote_connection: None,
  315    };
  316    cx.update(|cx| {
  317        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  318    });
  319    cx.run_until_parked();
  320}
  321
  322fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
  323    sidebar.update_in(cx, |_, window, cx| {
  324        cx.focus_self(window);
  325    });
  326    cx.run_until_parked();
  327}
  328
  329fn request_test_tool_authorization(
  330    thread: &Entity<AcpThread>,
  331    tool_call_id: &str,
  332    option_id: &str,
  333    cx: &mut gpui::VisualTestContext,
  334) {
  335    let tool_call_id = acp::ToolCallId::new(tool_call_id);
  336    let label = format!("Tool {tool_call_id}");
  337    let option_id = acp::PermissionOptionId::new(option_id);
  338    let _authorization_task = cx.update(|_, cx| {
  339        thread.update(cx, |thread, cx| {
  340            thread
  341                .request_tool_call_authorization(
  342                    acp::ToolCall::new(tool_call_id, label)
  343                        .kind(acp::ToolKind::Edit)
  344                        .into(),
  345                    PermissionOptions::Flat(vec![acp::PermissionOption::new(
  346                        option_id,
  347                        "Allow",
  348                        acp::PermissionOptionKind::AllowOnce,
  349                    )]),
  350                    cx,
  351                )
  352                .unwrap()
  353        })
  354    });
  355    cx.run_until_parked();
  356}
  357
  358fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
  359    let mut seen = Vec::new();
  360    let mut chips = Vec::new();
  361    for wt in worktrees {
  362        if wt.kind == ui::WorktreeKind::Main {
  363            continue;
  364        }
  365        if !seen.contains(&wt.name) {
  366            seen.push(wt.name.clone());
  367            chips.push(format!("{{{}}}", wt.name));
  368        }
  369    }
  370    if chips.is_empty() {
  371        String::new()
  372    } else {
  373        format!(" {}", chips.join(", "))
  374    }
  375}
  376
  377fn visible_entries_as_strings(
  378    sidebar: &Entity<Sidebar>,
  379    cx: &mut gpui::VisualTestContext,
  380) -> Vec<String> {
  381    sidebar.read_with(cx, |sidebar, cx| {
  382        sidebar
  383            .contents
  384            .entries
  385            .iter()
  386            .enumerate()
  387            .map(|(ix, entry)| {
  388                let selected = if sidebar.selection == Some(ix) {
  389                    "  <== selected"
  390                } else {
  391                    ""
  392                };
  393                match entry {
  394                    ListEntry::ProjectHeader {
  395                        label,
  396                        key,
  397                        highlight_positions: _,
  398                        ..
  399                    } => {
  400                        let icon = if sidebar.is_group_collapsed(key, cx) {
  401                            ">"
  402                        } else {
  403                            "v"
  404                        };
  405                        format!("{} [{}]{}", icon, label, selected)
  406                    }
  407                    ListEntry::Thread(thread) => {
  408                        let title = thread.metadata.display_title();
  409                        let worktree = format_linked_worktree_chips(&thread.worktrees);
  410
  411                        if thread.is_draft {
  412                            let is_active = sidebar
  413                                .active_entry
  414                                .as_ref()
  415                                .is_some_and(|e| e.matches_entry(entry));
  416                            let active_marker = if is_active { " *" } else { "" };
  417                            format!("  [~ Draft{worktree}]{active_marker}{selected}")
  418                        } else {
  419                            let live = if thread.is_live { " *" } else { "" };
  420                            let status_str = match thread.status {
  421                                AgentThreadStatus::Running => " (running)",
  422                                AgentThreadStatus::Error => " (error)",
  423                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
  424                                _ => "",
  425                            };
  426                            let notified = if sidebar
  427                                .contents
  428                                .is_thread_notified(&thread.metadata.thread_id)
  429                            {
  430                                " (!)"
  431                            } else {
  432                                ""
  433                            };
  434                            format!("  {title}{worktree}{live}{status_str}{notified}{selected}")
  435                        }
  436                    }
  437                    ListEntry::ViewMore {
  438                        is_fully_expanded, ..
  439                    } => {
  440                        if *is_fully_expanded {
  441                            format!("  - Collapse{}", selected)
  442                        } else {
  443                            format!("  + View More{}", selected)
  444                        }
  445                    }
  446                }
  447            })
  448            .collect()
  449    })
  450}
  451
  452#[gpui::test]
  453async fn test_serialization_round_trip(cx: &mut TestAppContext) {
  454    let project = init_test_project("/my-project", cx).await;
  455    let (multi_workspace, cx) =
  456        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  457    let sidebar = setup_sidebar(&multi_workspace, cx);
  458
  459    save_n_test_threads(3, &project, cx).await;
  460
  461    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  462
  463    // Set a custom width, collapse the group, and expand "View More".
  464    sidebar.update_in(cx, |sidebar, window, cx| {
  465        sidebar.set_width(Some(px(420.0)), cx);
  466        sidebar.toggle_collapse(&project_group_key, window, cx);
  467    });
  468    cx.run_until_parked();
  469
  470    // Capture the serialized state from the first sidebar.
  471    let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
  472    let serialized = serialized.expect("serialized_state should return Some");
  473
  474    // Create a fresh sidebar and restore into it.
  475    let sidebar2 =
  476        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  477    cx.run_until_parked();
  478
  479    sidebar2.update_in(cx, |sidebar, window, cx| {
  480        sidebar.restore_serialized_state(&serialized, window, cx);
  481    });
  482    cx.run_until_parked();
  483
  484    // Assert all serialized fields match.
  485    let width1 = sidebar.read_with(cx, |s, _| s.width);
  486    let width2 = sidebar2.read_with(cx, |s, _| s.width);
  487
  488    assert_eq!(width1, width2);
  489    assert_eq!(width1, px(420.0));
  490}
  491
  492#[gpui::test]
  493async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
  494    // A regression test to ensure that restoring a serialized archive view does not panic.
  495    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  496    let (multi_workspace, cx) =
  497        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  498    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  499    cx.update(|_window, cx| {
  500        AgentRegistryStore::init_test_global(cx, vec![]);
  501    });
  502
  503    let serialized = serde_json::to_string(&SerializedSidebar {
  504        width: Some(400.0),
  505        active_view: SerializedSidebarView::Archive,
  506    })
  507    .expect("serialization should succeed");
  508
  509    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
  510        if let Some(sidebar) = multi_workspace.sidebar() {
  511            sidebar.restore_serialized_state(&serialized, window, cx);
  512        }
  513    });
  514    cx.run_until_parked();
  515
  516    // After the deferred `show_archive` runs, the view should be Archive.
  517    sidebar.read_with(cx, |sidebar, _cx| {
  518        assert!(
  519            matches!(sidebar.view, SidebarView::Archive(_)),
  520            "expected sidebar view to be Archive after restore, got ThreadList"
  521        );
  522    });
  523}
  524
  525#[test]
  526fn test_clean_mention_links() {
  527    // Simple mention link
  528    assert_eq!(
  529        Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
  530        "check @Button.tsx"
  531    );
  532
  533    // Multiple mention links
  534    assert_eq!(
  535        Sidebar::clean_mention_links(
  536            "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
  537        ),
  538        "look at @foo.rs and @bar.rs"
  539    );
  540
  541    // No mention links — passthrough
  542    assert_eq!(
  543        Sidebar::clean_mention_links("plain text with no mentions"),
  544        "plain text with no mentions"
  545    );
  546
  547    // Incomplete link syntax — preserved as-is
  548    assert_eq!(
  549        Sidebar::clean_mention_links("broken [@mention without closing"),
  550        "broken [@mention without closing"
  551    );
  552
  553    // Regular markdown link (no @) — not touched
  554    assert_eq!(
  555        Sidebar::clean_mention_links("see [docs](https://example.com)"),
  556        "see [docs](https://example.com)"
  557    );
  558
  559    // Empty input
  560    assert_eq!(Sidebar::clean_mention_links(""), "");
  561}
  562
  563#[gpui::test]
  564async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
  565    let project = init_test_project("/my-project", cx).await;
  566    let (multi_workspace, cx) =
  567        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  568    let sidebar = setup_sidebar(&multi_workspace, cx);
  569
  570    let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
  571    let weak_sidebar = sidebar.downgrade();
  572    let weak_multi_workspace = multi_workspace.downgrade();
  573
  574    drop(sidebar);
  575    drop(multi_workspace);
  576    cx.update(|window, _cx| window.remove_window());
  577    cx.run_until_parked();
  578
  579    weak_multi_workspace.assert_released();
  580    weak_sidebar.assert_released();
  581    weak_workspace.assert_released();
  582}
  583
  584#[gpui::test]
  585async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
  586    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  587    let (multi_workspace, cx) =
  588        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  589    let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  590
  591    assert_eq!(
  592        visible_entries_as_strings(&_sidebar, cx),
  593        vec!["v [my-project]", "  [~ Draft]"]
  594    );
  595}
  596
  597#[gpui::test]
  598async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
  599    let project = init_test_project("/my-project", cx).await;
  600    let (multi_workspace, cx) =
  601        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  602    let sidebar = setup_sidebar(&multi_workspace, cx);
  603
  604    save_thread_metadata(
  605        acp::SessionId::new(Arc::from("thread-1")),
  606        Some("Fix crash in project panel".into()),
  607        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
  608        None,
  609        &project,
  610        cx,
  611    );
  612
  613    save_thread_metadata(
  614        acp::SessionId::new(Arc::from("thread-2")),
  615        Some("Add inline diff view".into()),
  616        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
  617        None,
  618        &project,
  619        cx,
  620    );
  621    cx.run_until_parked();
  622
  623    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  624    cx.run_until_parked();
  625
  626    assert_eq!(
  627        visible_entries_as_strings(&sidebar, cx),
  628        vec![
  629            //
  630            "v [my-project]",
  631            "  Fix crash in project panel",
  632            "  Add inline diff view",
  633        ]
  634    );
  635}
  636
  637#[gpui::test]
  638async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
  639    let project = init_test_project("/project-a", cx).await;
  640    let (multi_workspace, cx) =
  641        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  642    let sidebar = setup_sidebar(&multi_workspace, cx);
  643
  644    // Single workspace with a thread
  645    save_thread_metadata(
  646        acp::SessionId::new(Arc::from("thread-a1")),
  647        Some("Thread A1".into()),
  648        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  649        None,
  650        &project,
  651        cx,
  652    );
  653    cx.run_until_parked();
  654
  655    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  656    cx.run_until_parked();
  657
  658    assert_eq!(
  659        visible_entries_as_strings(&sidebar, cx),
  660        vec![
  661            //
  662            "v [project-a]",
  663            "  Thread A1",
  664        ]
  665    );
  666
  667    // Add a second workspace
  668    multi_workspace.update_in(cx, |mw, window, cx| {
  669        mw.create_test_workspace(window, cx).detach();
  670    });
  671    cx.run_until_parked();
  672
  673    assert_eq!(
  674        visible_entries_as_strings(&sidebar, cx),
  675        vec![
  676            //
  677            "v [project-a]",
  678            "  Thread A1",
  679        ]
  680    );
  681}
  682
  683#[gpui::test]
  684async fn test_view_more_pagination(cx: &mut TestAppContext) {
  685    let project = init_test_project("/my-project", cx).await;
  686    let (multi_workspace, cx) =
  687        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  688    let sidebar = setup_sidebar(&multi_workspace, cx);
  689
  690    save_n_test_threads(12, &project, cx).await;
  691
  692    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  693    cx.run_until_parked();
  694
  695    assert_eq!(
  696        visible_entries_as_strings(&sidebar, cx),
  697        vec![
  698            //
  699            "v [my-project]",
  700            "  Thread 12",
  701            "  Thread 11",
  702            "  Thread 10",
  703            "  Thread 9",
  704            "  Thread 8",
  705            "  + View More",
  706        ]
  707    );
  708}
  709
  710#[gpui::test]
  711async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
  712    let project = init_test_project("/my-project", cx).await;
  713    let (multi_workspace, cx) =
  714        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  715    let sidebar = setup_sidebar(&multi_workspace, cx);
  716
  717    // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
  718    save_n_test_threads(17, &project, cx).await;
  719
  720    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  721
  722    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  723    cx.run_until_parked();
  724
  725    // Initially shows 5 threads + View More
  726    let entries = visible_entries_as_strings(&sidebar, cx);
  727    assert_eq!(entries.len(), 7); // header + 5 threads + View More
  728    assert!(entries.iter().any(|e| e.contains("View More")));
  729
  730    // Focus and navigate to View More, then confirm to expand by one batch
  731    focus_sidebar(&sidebar, cx);
  732    for _ in 0..7 {
  733        cx.dispatch_action(SelectNext);
  734    }
  735    cx.dispatch_action(Confirm);
  736    cx.run_until_parked();
  737
  738    // Now shows 10 threads + View More
  739    let entries = visible_entries_as_strings(&sidebar, cx);
  740    assert_eq!(entries.len(), 12); // header + 10 threads + View More
  741    assert!(entries.iter().any(|e| e.contains("View More")));
  742
  743    // Expand again by one batch
  744    sidebar.update_in(cx, |s, _window, cx| {
  745        s.expand_thread_group(&project_group_key, cx);
  746    });
  747    cx.run_until_parked();
  748
  749    // Now shows 15 threads + View More
  750    let entries = visible_entries_as_strings(&sidebar, cx);
  751    assert_eq!(entries.len(), 17); // header + 15 threads + View More
  752    assert!(entries.iter().any(|e| e.contains("View More")));
  753
  754    // Expand one more time - should show all 17 threads with Collapse button
  755    sidebar.update_in(cx, |s, _window, cx| {
  756        s.expand_thread_group(&project_group_key, cx);
  757    });
  758    cx.run_until_parked();
  759
  760    // All 17 threads shown with Collapse button
  761    let entries = visible_entries_as_strings(&sidebar, cx);
  762    assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
  763    assert!(!entries.iter().any(|e| e.contains("View More")));
  764    assert!(entries.iter().any(|e| e.contains("Collapse")));
  765
  766    // Click collapse - should go back to showing 5 threads
  767    sidebar.update_in(cx, |s, _window, cx| {
  768        s.reset_thread_group_expansion(&project_group_key, cx);
  769    });
  770    cx.run_until_parked();
  771
  772    // Back to initial state: 5 threads + View More
  773    let entries = visible_entries_as_strings(&sidebar, cx);
  774    assert_eq!(entries.len(), 7); // header + 5 threads + View More
  775    assert!(entries.iter().any(|e| e.contains("View More")));
  776}
  777
  778#[gpui::test]
  779async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
  780    let project = init_test_project("/my-project", cx).await;
  781    let (multi_workspace, cx) =
  782        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  783    let sidebar = setup_sidebar(&multi_workspace, cx);
  784
  785    save_n_test_threads(1, &project, cx).await;
  786
  787    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  788
  789    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  790    cx.run_until_parked();
  791
  792    assert_eq!(
  793        visible_entries_as_strings(&sidebar, cx),
  794        vec![
  795            //
  796            "v [my-project]",
  797            "  Thread 1",
  798        ]
  799    );
  800
  801    // Collapse
  802    sidebar.update_in(cx, |s, window, cx| {
  803        s.toggle_collapse(&project_group_key, window, cx);
  804    });
  805    cx.run_until_parked();
  806
  807    assert_eq!(
  808        visible_entries_as_strings(&sidebar, cx),
  809        vec![
  810            //
  811            "> [my-project]",
  812        ]
  813    );
  814
  815    // Expand
  816    sidebar.update_in(cx, |s, window, cx| {
  817        s.toggle_collapse(&project_group_key, window, cx);
  818    });
  819    cx.run_until_parked();
  820
  821    assert_eq!(
  822        visible_entries_as_strings(&sidebar, cx),
  823        vec![
  824            //
  825            "v [my-project]",
  826            "  Thread 1",
  827        ]
  828    );
  829}
  830
  831#[gpui::test]
  832async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
  833    // When a worktree is added to a project, the project group key changes.
  834    // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
  835    // UI state must survive the key change.
  836    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
  837    let (multi_workspace, cx) =
  838        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  839    let sidebar = setup_sidebar(&multi_workspace, cx);
  840
  841    save_n_test_threads(2, &project, cx).await;
  842    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  843    cx.run_until_parked();
  844
  845    assert_eq!(
  846        visible_entries_as_strings(&sidebar, cx),
  847        vec!["v [project-a]", "  Thread 2", "  Thread 1",]
  848    );
  849
  850    // Collapse the group.
  851    let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  852    sidebar.update_in(cx, |sidebar, window, cx| {
  853        sidebar.toggle_collapse(&old_key, window, cx);
  854    });
  855    cx.run_until_parked();
  856
  857    assert_eq!(
  858        visible_entries_as_strings(&sidebar, cx),
  859        vec!["> [project-a]"]
  860    );
  861
  862    // Add a second worktree — the key changes from [/project-a] to
  863    // [/project-a, /project-b].
  864    project
  865        .update(cx, |project, cx| {
  866            project.find_or_create_worktree("/project-b", true, cx)
  867        })
  868        .await
  869        .expect("should add worktree");
  870    cx.run_until_parked();
  871
  872    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  873    cx.run_until_parked();
  874
  875    // The group should still be collapsed under the new key.
  876    assert_eq!(
  877        visible_entries_as_strings(&sidebar, cx),
  878        vec!["> [project-a, project-b]"]
  879    );
  880}
  881
  882#[gpui::test]
  883async fn test_adding_folder_to_non_backed_group_migrates_threads(cx: &mut TestAppContext) {
  884    use workspace::ProjectGroup;
  885    // When a project group has no backing workspace (e.g. the workspace was
  886    // closed but the group and its threads remain), adding a folder via
  887    // `add_folders_to_project_group` should still migrate thread metadata
  888    // to the new key and cause the sidebar to rerender.
  889    let (_fs, project) =
  890        init_multi_project_test(&["/active-project", "/orphan-a", "/orphan-b"], cx).await;
  891    let (multi_workspace, cx) =
  892        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  893    let sidebar = setup_sidebar(&multi_workspace, cx);
  894
  895    // Insert a standalone project group for [/orphan-a] with no backing
  896    // workspace — simulating a group that persisted after its workspace
  897    // was closed.
  898    let group_key = ProjectGroupKey::new(None, PathList::new(&[PathBuf::from("/orphan-a")]));
  899    multi_workspace.update(cx, |mw, _cx| {
  900        mw.test_add_project_group(ProjectGroup {
  901            key: group_key.clone(),
  902            workspaces: Vec::new(),
  903            expanded: true,
  904            visible_thread_count: None,
  905        });
  906    });
  907
  908    // Verify the group has no backing workspaces.
  909    multi_workspace.read_with(cx, |mw, cx| {
  910        let group = mw
  911            .project_groups(cx)
  912            .into_iter()
  913            .find(|g| g.key == group_key)
  914            .expect("group should exist");
  915        assert!(
  916            group.workspaces.is_empty(),
  917            "group should have no backing workspaces"
  918        );
  919    });
  920
  921    // Save threads directly into the metadata store under [/orphan-a].
  922    save_thread_metadata_with_main_paths(
  923        "t-1",
  924        "Thread One",
  925        PathList::new(&[PathBuf::from("/orphan-a")]),
  926        PathList::new(&[PathBuf::from("/orphan-a")]),
  927        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  928        cx,
  929    );
  930    save_thread_metadata_with_main_paths(
  931        "t-2",
  932        "Thread Two",
  933        PathList::new(&[PathBuf::from("/orphan-a")]),
  934        PathList::new(&[PathBuf::from("/orphan-a")]),
  935        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
  936        cx,
  937    );
  938    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  939    cx.run_until_parked();
  940
  941    // Verify threads show under the standalone group.
  942    assert_eq!(
  943        visible_entries_as_strings(&sidebar, cx),
  944        vec![
  945            "v [active-project]",
  946            "v [orphan-a]",
  947            "  Thread Two",
  948            "  Thread One",
  949        ]
  950    );
  951
  952    // Add /orphan-b to the non-backed group.
  953    multi_workspace.update(cx, |mw, cx| {
  954        mw.add_folders_to_project_group(&group_key, vec![PathBuf::from("/orphan-b")], cx);
  955    });
  956    cx.run_until_parked();
  957
  958    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  959    cx.run_until_parked();
  960
  961    // Threads should now appear under the combined key.
  962    assert_eq!(
  963        visible_entries_as_strings(&sidebar, cx),
  964        vec![
  965            "v [active-project]",
  966            "v [orphan-a, orphan-b]",
  967            "  Thread Two",
  968            "  Thread One",
  969        ]
  970    );
  971}
  972
  973#[gpui::test]
  974async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
  975    use workspace::ProjectGroup;
  976
  977    let project = init_test_project("/my-project", cx).await;
  978    let (multi_workspace, cx) =
  979        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  980    let sidebar = setup_sidebar(&multi_workspace, cx);
  981
  982    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
  983    let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
  984    let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
  985
  986    // Set the collapsed group state through multi_workspace
  987    multi_workspace.update(cx, |mw, _cx| {
  988        mw.test_add_project_group(ProjectGroup {
  989            key: ProjectGroupKey::new(None, collapsed_path.clone()),
  990            workspaces: Vec::new(),
  991            expanded: false,
  992            visible_thread_count: None,
  993        });
  994    });
  995
  996    sidebar.update_in(cx, |s, _window, _cx| {
  997        let notified_thread_id = ThreadId::new();
  998        s.contents.notified_threads.insert(notified_thread_id);
  999        s.contents.entries = vec![
 1000            // Expanded project header
 1001            ListEntry::ProjectHeader {
 1002                key: ProjectGroupKey::new(None, expanded_path.clone()),
 1003                label: "expanded-project".into(),
 1004                highlight_positions: Vec::new(),
 1005                has_running_threads: false,
 1006                waiting_thread_count: 0,
 1007                is_active: true,
 1008                has_threads: true,
 1009            },
 1010            ListEntry::Thread(ThreadEntry {
 1011                metadata: ThreadMetadata {
 1012                    thread_id: ThreadId::new(),
 1013                    session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
 1014                    agent_id: AgentId::new("zed-agent"),
 1015                    worktree_paths: WorktreePaths::default(),
 1016                    title: Some("Completed thread".into()),
 1017                    updated_at: Utc::now(),
 1018                    created_at: Some(Utc::now()),
 1019                    archived: false,
 1020                    remote_connection: None,
 1021                },
 1022                icon: IconName::ZedAgent,
 1023                icon_from_external_svg: None,
 1024                status: AgentThreadStatus::Completed,
 1025                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 1026                is_live: false,
 1027                is_background: false,
 1028                is_title_generating: false,
 1029                is_draft: false,
 1030                highlight_positions: Vec::new(),
 1031                worktrees: Vec::new(),
 1032                diff_stats: DiffStats::default(),
 1033            }),
 1034            // Active thread with Running status
 1035            ListEntry::Thread(ThreadEntry {
 1036                metadata: ThreadMetadata {
 1037                    thread_id: ThreadId::new(),
 1038                    session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
 1039                    agent_id: AgentId::new("zed-agent"),
 1040                    worktree_paths: WorktreePaths::default(),
 1041                    title: Some("Running thread".into()),
 1042                    updated_at: Utc::now(),
 1043                    created_at: Some(Utc::now()),
 1044                    archived: false,
 1045                    remote_connection: None,
 1046                },
 1047                icon: IconName::ZedAgent,
 1048                icon_from_external_svg: None,
 1049                status: AgentThreadStatus::Running,
 1050                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 1051                is_live: true,
 1052                is_background: false,
 1053                is_title_generating: false,
 1054                is_draft: false,
 1055                highlight_positions: Vec::new(),
 1056                worktrees: Vec::new(),
 1057                diff_stats: DiffStats::default(),
 1058            }),
 1059            // Active thread with Error status
 1060            ListEntry::Thread(ThreadEntry {
 1061                metadata: ThreadMetadata {
 1062                    thread_id: ThreadId::new(),
 1063                    session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
 1064                    agent_id: AgentId::new("zed-agent"),
 1065                    worktree_paths: WorktreePaths::default(),
 1066                    title: Some("Error thread".into()),
 1067                    updated_at: Utc::now(),
 1068                    created_at: Some(Utc::now()),
 1069                    archived: false,
 1070                    remote_connection: None,
 1071                },
 1072                icon: IconName::ZedAgent,
 1073                icon_from_external_svg: None,
 1074                status: AgentThreadStatus::Error,
 1075                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 1076                is_live: true,
 1077                is_background: false,
 1078                is_title_generating: false,
 1079                is_draft: false,
 1080                highlight_positions: Vec::new(),
 1081                worktrees: Vec::new(),
 1082                diff_stats: DiffStats::default(),
 1083            }),
 1084            // Thread with WaitingForConfirmation status, not active
 1085            // remote_connection: None,
 1086            ListEntry::Thread(ThreadEntry {
 1087                metadata: ThreadMetadata {
 1088                    thread_id: ThreadId::new(),
 1089                    session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
 1090                    agent_id: AgentId::new("zed-agent"),
 1091                    worktree_paths: WorktreePaths::default(),
 1092                    title: Some("Waiting thread".into()),
 1093                    updated_at: Utc::now(),
 1094                    created_at: Some(Utc::now()),
 1095                    archived: false,
 1096                    remote_connection: None,
 1097                },
 1098                icon: IconName::ZedAgent,
 1099                icon_from_external_svg: None,
 1100                status: AgentThreadStatus::WaitingForConfirmation,
 1101                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 1102                is_live: false,
 1103                is_background: false,
 1104                is_title_generating: false,
 1105                is_draft: false,
 1106                highlight_positions: Vec::new(),
 1107                worktrees: Vec::new(),
 1108                diff_stats: DiffStats::default(),
 1109            }),
 1110            // Background thread that completed (should show notification)
 1111            // remote_connection: None,
 1112            ListEntry::Thread(ThreadEntry {
 1113                metadata: ThreadMetadata {
 1114                    thread_id: notified_thread_id,
 1115                    session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
 1116                    agent_id: AgentId::new("zed-agent"),
 1117                    worktree_paths: WorktreePaths::default(),
 1118                    title: Some("Notified thread".into()),
 1119                    updated_at: Utc::now(),
 1120                    created_at: Some(Utc::now()),
 1121                    archived: false,
 1122                    remote_connection: None,
 1123                },
 1124                icon: IconName::ZedAgent,
 1125                icon_from_external_svg: None,
 1126                status: AgentThreadStatus::Completed,
 1127                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 1128                is_live: true,
 1129                is_background: true,
 1130                is_title_generating: false,
 1131                is_draft: false,
 1132                highlight_positions: Vec::new(),
 1133                worktrees: Vec::new(),
 1134                diff_stats: DiffStats::default(),
 1135            }),
 1136            // View More entry
 1137            ListEntry::ViewMore {
 1138                key: ProjectGroupKey::new(None, expanded_path.clone()),
 1139                is_fully_expanded: false,
 1140            },
 1141            // Collapsed project header
 1142            ListEntry::ProjectHeader {
 1143                key: ProjectGroupKey::new(None, collapsed_path.clone()),
 1144                label: "collapsed-project".into(),
 1145                highlight_positions: Vec::new(),
 1146                has_running_threads: false,
 1147                waiting_thread_count: 0,
 1148                is_active: false,
 1149                has_threads: false,
 1150            },
 1151        ];
 1152
 1153        // Select the Running thread (index 2)
 1154        s.selection = Some(2);
 1155    });
 1156
 1157    assert_eq!(
 1158        visible_entries_as_strings(&sidebar, cx),
 1159        vec![
 1160            //
 1161            "v [expanded-project]",
 1162            "  Completed thread",
 1163            "  Running thread * (running)  <== selected",
 1164            "  Error thread * (error)",
 1165            "  Waiting thread (waiting)",
 1166            "  Notified thread * (!)",
 1167            "  + View More",
 1168            "> [collapsed-project]",
 1169        ]
 1170    );
 1171
 1172    // Move selection to the collapsed header
 1173    sidebar.update_in(cx, |s, _window, _cx| {
 1174        s.selection = Some(7);
 1175    });
 1176
 1177    assert_eq!(
 1178        visible_entries_as_strings(&sidebar, cx).last().cloned(),
 1179        Some("> [collapsed-project]  <== selected".to_string()),
 1180    );
 1181
 1182    // Clear selection
 1183    sidebar.update_in(cx, |s, _window, _cx| {
 1184        s.selection = None;
 1185    });
 1186
 1187    // No entry should have the selected marker
 1188    let entries = visible_entries_as_strings(&sidebar, cx);
 1189    for entry in &entries {
 1190        assert!(
 1191            !entry.contains("<== selected"),
 1192            "unexpected selection marker in: {}",
 1193            entry
 1194        );
 1195    }
 1196}
 1197
 1198#[gpui::test]
 1199async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
 1200    let project = init_test_project("/my-project", cx).await;
 1201    let (multi_workspace, cx) =
 1202        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1203    let sidebar = setup_sidebar(&multi_workspace, cx);
 1204
 1205    save_n_test_threads(3, &project, cx).await;
 1206
 1207    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1208    cx.run_until_parked();
 1209
 1210    // Entries: [header, thread3, thread2, thread1]
 1211    // Focusing the sidebar does not set a selection; select_next/select_previous
 1212    // handle None gracefully by starting from the first or last entry.
 1213    focus_sidebar(&sidebar, cx);
 1214    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1215
 1216    // First SelectNext from None starts at index 0
 1217    cx.dispatch_action(SelectNext);
 1218    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1219
 1220    // Move down through remaining entries
 1221    cx.dispatch_action(SelectNext);
 1222    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1223
 1224    cx.dispatch_action(SelectNext);
 1225    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1226
 1227    cx.dispatch_action(SelectNext);
 1228    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1229
 1230    // At the end, wraps back to first entry
 1231    cx.dispatch_action(SelectNext);
 1232    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1233
 1234    // Navigate back to the end
 1235    cx.dispatch_action(SelectNext);
 1236    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1237    cx.dispatch_action(SelectNext);
 1238    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1239    cx.dispatch_action(SelectNext);
 1240    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1241
 1242    // Move back up
 1243    cx.dispatch_action(SelectPrevious);
 1244    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1245
 1246    cx.dispatch_action(SelectPrevious);
 1247    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1248
 1249    cx.dispatch_action(SelectPrevious);
 1250    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1251
 1252    // At the top, selection clears (focus returns to editor)
 1253    cx.dispatch_action(SelectPrevious);
 1254    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1255}
 1256
 1257#[gpui::test]
 1258async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
 1259    let project = init_test_project("/my-project", cx).await;
 1260    let (multi_workspace, cx) =
 1261        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1262    let sidebar = setup_sidebar(&multi_workspace, cx);
 1263
 1264    save_n_test_threads(3, &project, cx).await;
 1265    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1266    cx.run_until_parked();
 1267
 1268    focus_sidebar(&sidebar, cx);
 1269
 1270    // SelectLast jumps to the end
 1271    cx.dispatch_action(SelectLast);
 1272    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1273
 1274    // SelectFirst jumps to the beginning
 1275    cx.dispatch_action(SelectFirst);
 1276    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1277}
 1278
 1279#[gpui::test]
 1280async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
 1281    let project = init_test_project("/my-project", cx).await;
 1282    let (multi_workspace, cx) =
 1283        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1284    let sidebar = setup_sidebar(&multi_workspace, cx);
 1285
 1286    // Initially no selection
 1287    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1288
 1289    // Open the sidebar so it's rendered, then focus it to trigger focus_in.
 1290    // focus_in no longer sets a default selection.
 1291    focus_sidebar(&sidebar, cx);
 1292    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1293
 1294    // Manually set a selection, blur, then refocus — selection should be preserved
 1295    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1296        sidebar.selection = Some(0);
 1297    });
 1298
 1299    cx.update(|window, _cx| {
 1300        window.blur();
 1301    });
 1302    cx.run_until_parked();
 1303
 1304    sidebar.update_in(cx, |_, window, cx| {
 1305        cx.focus_self(window);
 1306    });
 1307    cx.run_until_parked();
 1308    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1309}
 1310
 1311#[gpui::test]
 1312async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
 1313    let project = init_test_project("/my-project", cx).await;
 1314    let (multi_workspace, cx) =
 1315        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1316    let sidebar = setup_sidebar(&multi_workspace, cx);
 1317
 1318    save_n_test_threads(1, &project, cx).await;
 1319    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1320    cx.run_until_parked();
 1321
 1322    assert_eq!(
 1323        visible_entries_as_strings(&sidebar, cx),
 1324        vec![
 1325            //
 1326            "v [my-project]",
 1327            "  Thread 1",
 1328        ]
 1329    );
 1330
 1331    // Focus the sidebar and select the header
 1332    focus_sidebar(&sidebar, cx);
 1333    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1334        sidebar.selection = Some(0);
 1335    });
 1336
 1337    // Confirm on project header collapses the group
 1338    cx.dispatch_action(Confirm);
 1339    cx.run_until_parked();
 1340
 1341    assert_eq!(
 1342        visible_entries_as_strings(&sidebar, cx),
 1343        vec![
 1344            //
 1345            "> [my-project]  <== selected",
 1346        ]
 1347    );
 1348
 1349    // Confirm again expands the group
 1350    cx.dispatch_action(Confirm);
 1351    cx.run_until_parked();
 1352
 1353    assert_eq!(
 1354        visible_entries_as_strings(&sidebar, cx),
 1355        vec![
 1356            //
 1357            "v [my-project]  <== selected",
 1358            "  Thread 1",
 1359        ]
 1360    );
 1361}
 1362
 1363#[gpui::test]
 1364async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
 1365    let project = init_test_project("/my-project", cx).await;
 1366    let (multi_workspace, cx) =
 1367        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1368    let sidebar = setup_sidebar(&multi_workspace, cx);
 1369
 1370    save_n_test_threads(8, &project, cx).await;
 1371    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1372    cx.run_until_parked();
 1373
 1374    // Should show header + 5 threads + "View More"
 1375    let entries = visible_entries_as_strings(&sidebar, cx);
 1376    assert_eq!(entries.len(), 7);
 1377    assert!(entries.iter().any(|e| e.contains("View More")));
 1378
 1379    // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
 1380    focus_sidebar(&sidebar, cx);
 1381    for _ in 0..7 {
 1382        cx.dispatch_action(SelectNext);
 1383    }
 1384    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
 1385
 1386    // Confirm on "View More" to expand
 1387    cx.dispatch_action(Confirm);
 1388    cx.run_until_parked();
 1389
 1390    // All 8 threads should now be visible with a "Collapse" button
 1391    let entries = visible_entries_as_strings(&sidebar, cx);
 1392    assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
 1393    assert!(!entries.iter().any(|e| e.contains("View More")));
 1394    assert!(entries.iter().any(|e| e.contains("Collapse")));
 1395}
 1396
 1397#[gpui::test]
 1398async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
 1399    let project = init_test_project("/my-project", cx).await;
 1400    let (multi_workspace, cx) =
 1401        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1402    let sidebar = setup_sidebar(&multi_workspace, cx);
 1403
 1404    save_n_test_threads(1, &project, cx).await;
 1405    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1406    cx.run_until_parked();
 1407
 1408    assert_eq!(
 1409        visible_entries_as_strings(&sidebar, cx),
 1410        vec![
 1411            //
 1412            "v [my-project]",
 1413            "  Thread 1",
 1414        ]
 1415    );
 1416
 1417    // Focus sidebar and manually select the header (index 0). Press left to collapse.
 1418    focus_sidebar(&sidebar, cx);
 1419    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1420        sidebar.selection = Some(0);
 1421    });
 1422
 1423    cx.dispatch_action(SelectParent);
 1424    cx.run_until_parked();
 1425
 1426    assert_eq!(
 1427        visible_entries_as_strings(&sidebar, cx),
 1428        vec![
 1429            //
 1430            "> [my-project]  <== selected",
 1431        ]
 1432    );
 1433
 1434    // Press right to expand
 1435    cx.dispatch_action(SelectChild);
 1436    cx.run_until_parked();
 1437
 1438    assert_eq!(
 1439        visible_entries_as_strings(&sidebar, cx),
 1440        vec![
 1441            //
 1442            "v [my-project]  <== selected",
 1443            "  Thread 1",
 1444        ]
 1445    );
 1446
 1447    // Press right again on already-expanded header moves selection down
 1448    cx.dispatch_action(SelectChild);
 1449    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1450}
 1451
 1452#[gpui::test]
 1453async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
 1454    let project = init_test_project("/my-project", cx).await;
 1455    let (multi_workspace, cx) =
 1456        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1457    let sidebar = setup_sidebar(&multi_workspace, cx);
 1458
 1459    save_n_test_threads(1, &project, cx).await;
 1460    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1461    cx.run_until_parked();
 1462
 1463    // Focus sidebar (selection starts at None), then navigate down to the thread (child)
 1464    focus_sidebar(&sidebar, cx);
 1465    cx.dispatch_action(SelectNext);
 1466    cx.dispatch_action(SelectNext);
 1467    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1468
 1469    assert_eq!(
 1470        visible_entries_as_strings(&sidebar, cx),
 1471        vec![
 1472            //
 1473            "v [my-project]",
 1474            "  Thread 1  <== selected",
 1475        ]
 1476    );
 1477
 1478    // Pressing left on a child collapses the parent group and selects it
 1479    cx.dispatch_action(SelectParent);
 1480    cx.run_until_parked();
 1481
 1482    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1483    assert_eq!(
 1484        visible_entries_as_strings(&sidebar, cx),
 1485        vec![
 1486            //
 1487            "> [my-project]  <== selected",
 1488        ]
 1489    );
 1490}
 1491
 1492#[gpui::test]
 1493async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
 1494    let project = init_test_project_with_agent_panel("/empty-project", cx).await;
 1495    let (multi_workspace, cx) =
 1496        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1497    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1498
 1499    // An empty project has the header and an auto-created draft.
 1500    assert_eq!(
 1501        visible_entries_as_strings(&sidebar, cx),
 1502        vec!["v [empty-project]", "  [~ Draft]"]
 1503    );
 1504
 1505    // Focus sidebar — focus_in does not set a selection
 1506    focus_sidebar(&sidebar, cx);
 1507    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1508
 1509    // First SelectNext from None starts at index 0 (header)
 1510    cx.dispatch_action(SelectNext);
 1511    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1512
 1513    // SelectNext advances to index 1 (draft entry)
 1514    cx.dispatch_action(SelectNext);
 1515    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1516
 1517    // At the end (two entries), wraps back to first entry
 1518    cx.dispatch_action(SelectNext);
 1519    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1520
 1521    // SelectPrevious from first entry clears selection (returns to editor)
 1522    cx.dispatch_action(SelectPrevious);
 1523    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1524}
 1525
 1526#[gpui::test]
 1527async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
 1528    let project = init_test_project("/my-project", cx).await;
 1529    let (multi_workspace, cx) =
 1530        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1531    let sidebar = setup_sidebar(&multi_workspace, cx);
 1532
 1533    save_n_test_threads(1, &project, cx).await;
 1534    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1535    cx.run_until_parked();
 1536
 1537    // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
 1538    focus_sidebar(&sidebar, cx);
 1539    cx.dispatch_action(SelectNext);
 1540    cx.dispatch_action(SelectNext);
 1541    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1542
 1543    // Collapse the group, which removes the thread from the list
 1544    cx.dispatch_action(SelectParent);
 1545    cx.run_until_parked();
 1546
 1547    // Selection should be clamped to the last valid index (0 = header)
 1548    let selection = sidebar.read_with(cx, |s, _| s.selection);
 1549    let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
 1550    assert!(
 1551        selection.unwrap_or(0) < entry_count,
 1552        "selection {} should be within bounds (entries: {})",
 1553        selection.unwrap_or(0),
 1554        entry_count,
 1555    );
 1556}
 1557
 1558async fn init_test_project_with_agent_panel(
 1559    worktree_path: &str,
 1560    cx: &mut TestAppContext,
 1561) -> Entity<project::Project> {
 1562    agent_ui::test_support::init_test(cx);
 1563    cx.update(|cx| {
 1564        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 1565        ThreadStore::init_global(cx);
 1566        ThreadMetadataStore::init_global(cx);
 1567        language_model::LanguageModelRegistry::test(cx);
 1568        prompt_store::init(cx);
 1569    });
 1570
 1571    let fs = FakeFs::new(cx.executor());
 1572    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
 1573        .await;
 1574    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 1575    project::Project::test(fs, [worktree_path.as_ref()], cx).await
 1576}
 1577
 1578fn add_agent_panel(
 1579    workspace: &Entity<Workspace>,
 1580    cx: &mut gpui::VisualTestContext,
 1581) -> Entity<AgentPanel> {
 1582    workspace.update_in(cx, |workspace, window, cx| {
 1583        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 1584        workspace.add_panel(panel.clone(), window, cx);
 1585        panel
 1586    })
 1587}
 1588
 1589fn setup_sidebar_with_agent_panel(
 1590    multi_workspace: &Entity<MultiWorkspace>,
 1591    cx: &mut gpui::VisualTestContext,
 1592) -> (Entity<Sidebar>, Entity<AgentPanel>) {
 1593    let sidebar = setup_sidebar(multi_workspace, cx);
 1594    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 1595    let panel = add_agent_panel(&workspace, cx);
 1596    (sidebar, panel)
 1597}
 1598
 1599#[gpui::test]
 1600async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
 1601    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1602    let (multi_workspace, cx) =
 1603        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1604    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1605
 1606    // Open thread A and keep it generating.
 1607    let connection = StubAgentConnection::new();
 1608    open_thread_with_connection(&panel, connection.clone(), cx);
 1609    send_message(&panel, cx);
 1610
 1611    let session_id_a = active_session_id(&panel, cx);
 1612    save_test_thread_metadata(&session_id_a, &project, cx).await;
 1613
 1614    cx.update(|_, cx| {
 1615        connection.send_update(
 1616            session_id_a.clone(),
 1617            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 1618            cx,
 1619        );
 1620    });
 1621    cx.run_until_parked();
 1622
 1623    // Open thread B (idle, default response) — thread A goes to background.
 1624    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1625        acp::ContentChunk::new("Done".into()),
 1626    )]);
 1627    open_thread_with_connection(&panel, connection, cx);
 1628    send_message(&panel, cx);
 1629
 1630    let session_id_b = active_session_id(&panel, cx);
 1631    save_test_thread_metadata(&session_id_b, &project, cx).await;
 1632
 1633    cx.run_until_parked();
 1634
 1635    let mut entries = visible_entries_as_strings(&sidebar, cx);
 1636    entries[1..].sort();
 1637    assert_eq!(
 1638        entries,
 1639        vec![
 1640            //
 1641            "v [my-project]",
 1642            "  Hello *",
 1643            "  Hello * (running)",
 1644        ]
 1645    );
 1646}
 1647
 1648#[gpui::test]
 1649async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
 1650    cx: &mut TestAppContext,
 1651) {
 1652    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1653    let (multi_workspace, cx) =
 1654        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1655    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1656
 1657    let connection = StubAgentConnection::new().with_supports_load_session(true);
 1658    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1659        acp::ContentChunk::new("Done".into()),
 1660    )]);
 1661    open_thread_with_connection(&panel, connection, cx);
 1662    send_message(&panel, cx);
 1663
 1664    let parent_session_id = active_session_id(&panel, cx);
 1665    save_test_thread_metadata(&parent_session_id, &project, cx).await;
 1666
 1667    let subagent_session_id = acp::SessionId::new("subagent-session");
 1668    cx.update(|_, cx| {
 1669        let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
 1670        parent_thread.update(cx, |thread: &mut AcpThread, cx| {
 1671            thread.subagent_spawned(subagent_session_id.clone(), cx);
 1672        });
 1673    });
 1674    cx.run_until_parked();
 1675
 1676    let subagent_thread = panel.read_with(cx, |panel, cx| {
 1677        panel
 1678            .active_conversation_view()
 1679            .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id, cx))
 1680            .map(|thread_view| thread_view.read(cx).thread.clone())
 1681            .expect("Expected subagent thread to be loaded into the conversation")
 1682    });
 1683    request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
 1684
 1685    let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
 1686        sidebar
 1687            .contents
 1688            .entries
 1689            .iter()
 1690            .find_map(|entry| match entry {
 1691                ListEntry::Thread(thread)
 1692                    if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
 1693                {
 1694                    Some(thread.status)
 1695                }
 1696                _ => None,
 1697            })
 1698            .expect("Expected parent thread entry in sidebar")
 1699    });
 1700
 1701    assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
 1702}
 1703
 1704#[gpui::test]
 1705async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
 1706    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 1707    let (multi_workspace, cx) =
 1708        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1709    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1710
 1711    // Open thread on workspace A and keep it generating.
 1712    let connection_a = StubAgentConnection::new();
 1713    open_thread_with_connection(&panel_a, connection_a.clone(), cx);
 1714    send_message(&panel_a, cx);
 1715
 1716    let session_id_a = active_session_id(&panel_a, cx);
 1717    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 1718
 1719    cx.update(|_, cx| {
 1720        connection_a.send_update(
 1721            session_id_a.clone(),
 1722            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
 1723            cx,
 1724        );
 1725    });
 1726    cx.run_until_parked();
 1727
 1728    // Add a second workspace and activate it (making workspace A the background).
 1729    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 1730    let project_b = project::Project::test(fs, [], cx).await;
 1731    multi_workspace.update_in(cx, |mw, window, cx| {
 1732        mw.test_add_workspace(project_b, window, cx);
 1733    });
 1734    cx.run_until_parked();
 1735
 1736    // Thread A is still running; no notification yet.
 1737    assert_eq!(
 1738        visible_entries_as_strings(&sidebar, cx),
 1739        vec![
 1740            //
 1741            "v [project-a]",
 1742            "  Hello * (running)",
 1743        ]
 1744    );
 1745
 1746    // Complete thread A's turn (transition Running → Completed).
 1747    connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
 1748    cx.run_until_parked();
 1749
 1750    // The completed background thread shows a notification indicator.
 1751    assert_eq!(
 1752        visible_entries_as_strings(&sidebar, cx),
 1753        vec![
 1754            //
 1755            "v [project-a]",
 1756            "  Hello * (!)",
 1757        ]
 1758    );
 1759}
 1760
 1761fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
 1762    sidebar.update_in(cx, |sidebar, window, cx| {
 1763        window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
 1764        sidebar.filter_editor.update(cx, |editor, cx| {
 1765            editor.set_text(query, window, cx);
 1766        });
 1767    });
 1768    cx.run_until_parked();
 1769}
 1770
 1771#[gpui::test]
 1772async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
 1773    let project = init_test_project("/my-project", cx).await;
 1774    let (multi_workspace, cx) =
 1775        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1776    let sidebar = setup_sidebar(&multi_workspace, cx);
 1777
 1778    for (id, title, hour) in [
 1779        ("t-1", "Fix crash in project panel", 3),
 1780        ("t-2", "Add inline diff view", 2),
 1781        ("t-3", "Refactor settings module", 1),
 1782    ] {
 1783        save_thread_metadata(
 1784            acp::SessionId::new(Arc::from(id)),
 1785            Some(title.into()),
 1786            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1787            None,
 1788            &project,
 1789            cx,
 1790        );
 1791    }
 1792    cx.run_until_parked();
 1793
 1794    assert_eq!(
 1795        visible_entries_as_strings(&sidebar, cx),
 1796        vec![
 1797            //
 1798            "v [my-project]",
 1799            "  Fix crash in project panel",
 1800            "  Add inline diff view",
 1801            "  Refactor settings module",
 1802        ]
 1803    );
 1804
 1805    // User types "diff" in the search box — only the matching thread remains,
 1806    // with its workspace header preserved for context.
 1807    type_in_search(&sidebar, "diff", cx);
 1808    assert_eq!(
 1809        visible_entries_as_strings(&sidebar, cx),
 1810        vec![
 1811            //
 1812            "v [my-project]",
 1813            "  Add inline diff view  <== selected",
 1814        ]
 1815    );
 1816
 1817    // User changes query to something with no matches — list is empty.
 1818    type_in_search(&sidebar, "nonexistent", cx);
 1819    assert_eq!(
 1820        visible_entries_as_strings(&sidebar, cx),
 1821        Vec::<String>::new()
 1822    );
 1823}
 1824
 1825#[gpui::test]
 1826async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
 1827    // Scenario: A user remembers a thread title but not the exact casing.
 1828    // Search should match case-insensitively so they can still find it.
 1829    let project = init_test_project("/my-project", cx).await;
 1830    let (multi_workspace, cx) =
 1831        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1832    let sidebar = setup_sidebar(&multi_workspace, cx);
 1833
 1834    save_thread_metadata(
 1835        acp::SessionId::new(Arc::from("thread-1")),
 1836        Some("Fix Crash In Project Panel".into()),
 1837        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 1838        None,
 1839        &project,
 1840        cx,
 1841    );
 1842    cx.run_until_parked();
 1843
 1844    // Lowercase query matches mixed-case title.
 1845    type_in_search(&sidebar, "fix crash", cx);
 1846    assert_eq!(
 1847        visible_entries_as_strings(&sidebar, cx),
 1848        vec![
 1849            //
 1850            "v [my-project]",
 1851            "  Fix Crash In Project Panel  <== selected",
 1852        ]
 1853    );
 1854
 1855    // Uppercase query also matches the same title.
 1856    type_in_search(&sidebar, "FIX CRASH", cx);
 1857    assert_eq!(
 1858        visible_entries_as_strings(&sidebar, cx),
 1859        vec![
 1860            //
 1861            "v [my-project]",
 1862            "  Fix Crash In Project Panel  <== selected",
 1863        ]
 1864    );
 1865}
 1866
 1867#[gpui::test]
 1868async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
 1869    // Scenario: A user searches, finds what they need, then presses Escape
 1870    // to dismiss the filter and see the full list again.
 1871    let project = init_test_project("/my-project", cx).await;
 1872    let (multi_workspace, cx) =
 1873        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1874    let sidebar = setup_sidebar(&multi_workspace, cx);
 1875
 1876    for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
 1877        save_thread_metadata(
 1878            acp::SessionId::new(Arc::from(id)),
 1879            Some(title.into()),
 1880            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1881            None,
 1882            &project,
 1883            cx,
 1884        )
 1885    }
 1886    cx.run_until_parked();
 1887
 1888    // Confirm the full list is showing.
 1889    assert_eq!(
 1890        visible_entries_as_strings(&sidebar, cx),
 1891        vec![
 1892            //
 1893            "v [my-project]",
 1894            "  Alpha thread",
 1895            "  Beta thread",
 1896        ]
 1897    );
 1898
 1899    // User types a search query to filter down.
 1900    focus_sidebar(&sidebar, cx);
 1901    type_in_search(&sidebar, "alpha", cx);
 1902    assert_eq!(
 1903        visible_entries_as_strings(&sidebar, cx),
 1904        vec![
 1905            //
 1906            "v [my-project]",
 1907            "  Alpha thread  <== selected",
 1908        ]
 1909    );
 1910
 1911    // User presses Escape — filter clears, full list is restored.
 1912    // The selection index (1) now points at the first thread entry.
 1913    cx.dispatch_action(Cancel);
 1914    cx.run_until_parked();
 1915    assert_eq!(
 1916        visible_entries_as_strings(&sidebar, cx),
 1917        vec![
 1918            //
 1919            "v [my-project]",
 1920            "  Alpha thread  <== selected",
 1921            "  Beta thread",
 1922        ]
 1923    );
 1924}
 1925
 1926#[gpui::test]
 1927async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
 1928    let project_a = init_test_project("/project-a", cx).await;
 1929    let (multi_workspace, cx) =
 1930        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1931    let sidebar = setup_sidebar(&multi_workspace, cx);
 1932
 1933    for (id, title, hour) in [
 1934        ("a1", "Fix bug in sidebar", 2),
 1935        ("a2", "Add tests for editor", 1),
 1936    ] {
 1937        save_thread_metadata(
 1938            acp::SessionId::new(Arc::from(id)),
 1939            Some(title.into()),
 1940            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1941            None,
 1942            &project_a,
 1943            cx,
 1944        )
 1945    }
 1946
 1947    // Add a second workspace.
 1948    multi_workspace.update_in(cx, |mw, window, cx| {
 1949        mw.create_test_workspace(window, cx).detach();
 1950    });
 1951    cx.run_until_parked();
 1952
 1953    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 1954        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 1955    });
 1956
 1957    for (id, title, hour) in [
 1958        ("b1", "Refactor sidebar layout", 3),
 1959        ("b2", "Fix typo in README", 1),
 1960    ] {
 1961        save_thread_metadata(
 1962            acp::SessionId::new(Arc::from(id)),
 1963            Some(title.into()),
 1964            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1965            None,
 1966            &project_b,
 1967            cx,
 1968        )
 1969    }
 1970    cx.run_until_parked();
 1971
 1972    assert_eq!(
 1973        visible_entries_as_strings(&sidebar, cx),
 1974        vec![
 1975            //
 1976            "v [project-a]",
 1977            "  Fix bug in sidebar",
 1978            "  Add tests for editor",
 1979        ]
 1980    );
 1981
 1982    // "sidebar" matches a thread in each workspace — both headers stay visible.
 1983    type_in_search(&sidebar, "sidebar", cx);
 1984    assert_eq!(
 1985        visible_entries_as_strings(&sidebar, cx),
 1986        vec![
 1987            //
 1988            "v [project-a]",
 1989            "  Fix bug in sidebar  <== selected",
 1990        ]
 1991    );
 1992
 1993    // "typo" only matches in the second workspace — the first header disappears.
 1994    type_in_search(&sidebar, "typo", cx);
 1995    assert_eq!(
 1996        visible_entries_as_strings(&sidebar, cx),
 1997        Vec::<String>::new()
 1998    );
 1999
 2000    // "project-a" matches the first workspace name — the header appears
 2001    // with all child threads included.
 2002    type_in_search(&sidebar, "project-a", cx);
 2003    assert_eq!(
 2004        visible_entries_as_strings(&sidebar, cx),
 2005        vec![
 2006            //
 2007            "v [project-a]",
 2008            "  Fix bug in sidebar  <== selected",
 2009            "  Add tests for editor",
 2010        ]
 2011    );
 2012}
 2013
 2014#[gpui::test]
 2015async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
 2016    let project_a = init_test_project("/alpha-project", cx).await;
 2017    let (multi_workspace, cx) =
 2018        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2019    let sidebar = setup_sidebar(&multi_workspace, cx);
 2020
 2021    for (id, title, hour) in [
 2022        ("a1", "Fix bug in sidebar", 2),
 2023        ("a2", "Add tests for editor", 1),
 2024    ] {
 2025        save_thread_metadata(
 2026            acp::SessionId::new(Arc::from(id)),
 2027            Some(title.into()),
 2028            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2029            None,
 2030            &project_a,
 2031            cx,
 2032        )
 2033    }
 2034
 2035    // Add a second workspace.
 2036    multi_workspace.update_in(cx, |mw, window, cx| {
 2037        mw.create_test_workspace(window, cx).detach();
 2038    });
 2039    cx.run_until_parked();
 2040
 2041    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 2042        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 2043    });
 2044
 2045    for (id, title, hour) in [
 2046        ("b1", "Refactor sidebar layout", 3),
 2047        ("b2", "Fix typo in README", 1),
 2048    ] {
 2049        save_thread_metadata(
 2050            acp::SessionId::new(Arc::from(id)),
 2051            Some(title.into()),
 2052            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2053            None,
 2054            &project_b,
 2055            cx,
 2056        )
 2057    }
 2058    cx.run_until_parked();
 2059
 2060    // "alpha" matches the workspace name "alpha-project" but no thread titles.
 2061    // The workspace header should appear with all child threads included.
 2062    type_in_search(&sidebar, "alpha", cx);
 2063    assert_eq!(
 2064        visible_entries_as_strings(&sidebar, cx),
 2065        vec![
 2066            //
 2067            "v [alpha-project]",
 2068            "  Fix bug in sidebar  <== selected",
 2069            "  Add tests for editor",
 2070        ]
 2071    );
 2072
 2073    // "sidebar" matches thread titles in both workspaces but not workspace names.
 2074    // Both headers appear with their matching threads.
 2075    type_in_search(&sidebar, "sidebar", cx);
 2076    assert_eq!(
 2077        visible_entries_as_strings(&sidebar, cx),
 2078        vec![
 2079            //
 2080            "v [alpha-project]",
 2081            "  Fix bug in sidebar  <== selected",
 2082        ]
 2083    );
 2084
 2085    // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
 2086    // doesn't match) — but does not match either workspace name or any thread.
 2087    // Actually let's test something simpler: a query that matches both a workspace
 2088    // name AND some threads in that workspace. Matching threads should still appear.
 2089    type_in_search(&sidebar, "fix", cx);
 2090    assert_eq!(
 2091        visible_entries_as_strings(&sidebar, cx),
 2092        vec![
 2093            //
 2094            "v [alpha-project]",
 2095            "  Fix bug in sidebar  <== selected",
 2096        ]
 2097    );
 2098
 2099    // A query that matches a workspace name AND a thread in that same workspace.
 2100    // Both the header (highlighted) and all child threads should appear.
 2101    type_in_search(&sidebar, "alpha", cx);
 2102    assert_eq!(
 2103        visible_entries_as_strings(&sidebar, cx),
 2104        vec![
 2105            //
 2106            "v [alpha-project]",
 2107            "  Fix bug in sidebar  <== selected",
 2108            "  Add tests for editor",
 2109        ]
 2110    );
 2111
 2112    // Now search for something that matches only a workspace name when there
 2113    // are also threads with matching titles — the non-matching workspace's
 2114    // threads should still appear if their titles match.
 2115    type_in_search(&sidebar, "alp", cx);
 2116    assert_eq!(
 2117        visible_entries_as_strings(&sidebar, cx),
 2118        vec![
 2119            //
 2120            "v [alpha-project]",
 2121            "  Fix bug in sidebar  <== selected",
 2122            "  Add tests for editor",
 2123        ]
 2124    );
 2125}
 2126
 2127#[gpui::test]
 2128async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
 2129    let project = init_test_project("/my-project", cx).await;
 2130    let (multi_workspace, cx) =
 2131        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2132    let sidebar = setup_sidebar(&multi_workspace, cx);
 2133
 2134    // Create 8 threads. The oldest one has a unique name and will be
 2135    // behind View More (only 5 shown by default).
 2136    for i in 0..8u32 {
 2137        let title = if i == 0 {
 2138            "Hidden gem thread".to_string()
 2139        } else {
 2140            format!("Thread {}", i + 1)
 2141        };
 2142        save_thread_metadata(
 2143            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
 2144            Some(title.into()),
 2145            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
 2146            None,
 2147            &project,
 2148            cx,
 2149        )
 2150    }
 2151    cx.run_until_parked();
 2152
 2153    // Confirm the thread is not visible and View More is shown.
 2154    let entries = visible_entries_as_strings(&sidebar, cx);
 2155    assert!(
 2156        entries.iter().any(|e| e.contains("View More")),
 2157        "should have View More button"
 2158    );
 2159    assert!(
 2160        !entries.iter().any(|e| e.contains("Hidden gem")),
 2161        "Hidden gem should be behind View More"
 2162    );
 2163
 2164    // User searches for the hidden thread — it appears, and View More is gone.
 2165    type_in_search(&sidebar, "hidden gem", cx);
 2166    let filtered = visible_entries_as_strings(&sidebar, cx);
 2167    assert_eq!(
 2168        filtered,
 2169        vec![
 2170            //
 2171            "v [my-project]",
 2172            "  Hidden gem thread  <== selected",
 2173        ]
 2174    );
 2175    assert!(
 2176        !filtered.iter().any(|e| e.contains("View More")),
 2177        "View More should not appear when filtering"
 2178    );
 2179}
 2180
 2181#[gpui::test]
 2182async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
 2183    let project = init_test_project("/my-project", cx).await;
 2184    let (multi_workspace, cx) =
 2185        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2186    let sidebar = setup_sidebar(&multi_workspace, cx);
 2187
 2188    save_thread_metadata(
 2189        acp::SessionId::new(Arc::from("thread-1")),
 2190        Some("Important thread".into()),
 2191        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 2192        None,
 2193        &project,
 2194        cx,
 2195    );
 2196    cx.run_until_parked();
 2197
 2198    // User focuses the sidebar and collapses the group using keyboard:
 2199    // manually select the header, then press SelectParent to collapse.
 2200    focus_sidebar(&sidebar, cx);
 2201    sidebar.update_in(cx, |sidebar, _window, _cx| {
 2202        sidebar.selection = Some(0);
 2203    });
 2204    cx.dispatch_action(SelectParent);
 2205    cx.run_until_parked();
 2206
 2207    assert_eq!(
 2208        visible_entries_as_strings(&sidebar, cx),
 2209        vec![
 2210            //
 2211            "> [my-project]  <== selected",
 2212        ]
 2213    );
 2214
 2215    // User types a search — the thread appears even though its group is collapsed.
 2216    type_in_search(&sidebar, "important", cx);
 2217    assert_eq!(
 2218        visible_entries_as_strings(&sidebar, cx),
 2219        vec![
 2220            //
 2221            "> [my-project]",
 2222            "  Important thread  <== selected",
 2223        ]
 2224    );
 2225}
 2226
 2227#[gpui::test]
 2228async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
 2229    let project = init_test_project("/my-project", cx).await;
 2230    let (multi_workspace, cx) =
 2231        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2232    let sidebar = setup_sidebar(&multi_workspace, cx);
 2233
 2234    for (id, title, hour) in [
 2235        ("t-1", "Fix crash in panel", 3),
 2236        ("t-2", "Fix lint warnings", 2),
 2237        ("t-3", "Add new feature", 1),
 2238    ] {
 2239        save_thread_metadata(
 2240            acp::SessionId::new(Arc::from(id)),
 2241            Some(title.into()),
 2242            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2243            None,
 2244            &project,
 2245            cx,
 2246        )
 2247    }
 2248    cx.run_until_parked();
 2249
 2250    focus_sidebar(&sidebar, cx);
 2251
 2252    // User types "fix" — two threads match.
 2253    type_in_search(&sidebar, "fix", cx);
 2254    assert_eq!(
 2255        visible_entries_as_strings(&sidebar, cx),
 2256        vec![
 2257            //
 2258            "v [my-project]",
 2259            "  Fix crash in panel  <== selected",
 2260            "  Fix lint warnings",
 2261        ]
 2262    );
 2263
 2264    // Selection starts on the first matching thread. User presses
 2265    // SelectNext to move to the second match.
 2266    cx.dispatch_action(SelectNext);
 2267    assert_eq!(
 2268        visible_entries_as_strings(&sidebar, cx),
 2269        vec![
 2270            //
 2271            "v [my-project]",
 2272            "  Fix crash in panel",
 2273            "  Fix lint warnings  <== selected",
 2274        ]
 2275    );
 2276
 2277    // User can also jump back with SelectPrevious.
 2278    cx.dispatch_action(SelectPrevious);
 2279    assert_eq!(
 2280        visible_entries_as_strings(&sidebar, cx),
 2281        vec![
 2282            //
 2283            "v [my-project]",
 2284            "  Fix crash in panel  <== selected",
 2285            "  Fix lint warnings",
 2286        ]
 2287    );
 2288}
 2289
 2290#[gpui::test]
 2291async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
 2292    let project = init_test_project("/my-project", cx).await;
 2293    let (multi_workspace, cx) =
 2294        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2295    let sidebar = setup_sidebar(&multi_workspace, cx);
 2296
 2297    multi_workspace.update_in(cx, |mw, window, cx| {
 2298        mw.create_test_workspace(window, cx).detach();
 2299    });
 2300    cx.run_until_parked();
 2301
 2302    let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
 2303        (
 2304            mw.workspaces().next().unwrap().clone(),
 2305            mw.workspaces().nth(1).unwrap().clone(),
 2306        )
 2307    });
 2308
 2309    save_thread_metadata(
 2310        acp::SessionId::new(Arc::from("hist-1")),
 2311        Some("Historical Thread".into()),
 2312        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2313        None,
 2314        &project,
 2315        cx,
 2316    );
 2317    cx.run_until_parked();
 2318    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2319    cx.run_until_parked();
 2320
 2321    assert_eq!(
 2322        visible_entries_as_strings(&sidebar, cx),
 2323        vec![
 2324            //
 2325            "v [my-project]",
 2326            "  Historical Thread",
 2327        ]
 2328    );
 2329
 2330    // Switch to workspace 1 so we can verify the confirm switches back.
 2331    multi_workspace.update_in(cx, |mw, window, cx| {
 2332        let workspace = mw.workspaces().nth(1).unwrap().clone();
 2333        mw.activate(workspace, window, cx);
 2334    });
 2335    cx.run_until_parked();
 2336    assert_eq!(
 2337        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2338        workspace_1
 2339    );
 2340
 2341    // Confirm on the historical (non-live) thread at index 1.
 2342    // Before a previous fix, the workspace field was Option<usize> and
 2343    // historical threads had None, so activate_thread early-returned
 2344    // without switching the workspace.
 2345    sidebar.update_in(cx, |sidebar, window, cx| {
 2346        sidebar.selection = Some(1);
 2347        sidebar.confirm(&Confirm, window, cx);
 2348    });
 2349    cx.run_until_parked();
 2350
 2351    assert_eq!(
 2352        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2353        workspace_0
 2354    );
 2355}
 2356
 2357#[gpui::test]
 2358async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
 2359    cx: &mut TestAppContext,
 2360) {
 2361    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2362    let (multi_workspace, cx) =
 2363        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2364    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2365
 2366    let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
 2367    let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
 2368    save_thread_metadata(
 2369        newer_session_id,
 2370        Some("Newer Historical Thread".into()),
 2371        newer_timestamp,
 2372        Some(newer_timestamp),
 2373        &project,
 2374        cx,
 2375    );
 2376
 2377    let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
 2378    let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
 2379    save_thread_metadata(
 2380        older_session_id.clone(),
 2381        Some("Older Historical Thread".into()),
 2382        older_timestamp,
 2383        Some(older_timestamp),
 2384        &project,
 2385        cx,
 2386    );
 2387
 2388    cx.run_until_parked();
 2389    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2390    cx.run_until_parked();
 2391
 2392    let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2393        .into_iter()
 2394        .filter(|entry| entry.contains("Historical Thread"))
 2395        .collect();
 2396    assert_eq!(
 2397        historical_entries_before,
 2398        vec![
 2399            "  Newer Historical Thread".to_string(),
 2400            "  Older Historical Thread".to_string(),
 2401        ],
 2402        "expected the sidebar to sort historical threads by their saved timestamp before activation"
 2403    );
 2404
 2405    let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
 2406        sidebar
 2407            .contents
 2408            .entries
 2409            .iter()
 2410            .position(|entry| {
 2411                matches!(entry, ListEntry::Thread(thread)
 2412                    if thread.metadata.session_id.as_ref() == Some(&older_session_id))
 2413            })
 2414            .expect("expected Older Historical Thread to appear in the sidebar")
 2415    });
 2416
 2417    sidebar.update_in(cx, |sidebar, window, cx| {
 2418        sidebar.selection = Some(older_entry_index);
 2419        sidebar.confirm(&Confirm, window, cx);
 2420    });
 2421    cx.run_until_parked();
 2422    cx.run_until_parked();
 2423    cx.run_until_parked();
 2424
 2425    let older_metadata = cx.update(|_, cx| {
 2426        ThreadMetadataStore::global(cx)
 2427            .read(cx)
 2428            .entry_by_session(&older_session_id)
 2429            .cloned()
 2430            .expect("expected metadata for Older Historical Thread after activation")
 2431    });
 2432    assert_eq!(
 2433        older_metadata.created_at,
 2434        Some(older_timestamp),
 2435        "activating a historical thread should not rewrite its saved created_at timestamp"
 2436    );
 2437
 2438    let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2439        .into_iter()
 2440        .filter(|entry| entry.contains("Historical Thread"))
 2441        .collect();
 2442    assert_eq!(
 2443        historical_entries_after,
 2444        vec![
 2445            "  Newer Historical Thread".to_string(),
 2446            "  Older Historical Thread".to_string(),
 2447        ],
 2448        "activating an older historical thread should not reorder it ahead of a newer historical thread"
 2449    );
 2450}
 2451
 2452#[gpui::test]
 2453async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
 2454    cx: &mut TestAppContext,
 2455) {
 2456    use workspace::ProjectGroup;
 2457
 2458    agent_ui::test_support::init_test(cx);
 2459    cx.update(|cx| {
 2460        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 2461        ThreadStore::init_global(cx);
 2462        ThreadMetadataStore::init_global(cx);
 2463        language_model::LanguageModelRegistry::test(cx);
 2464        prompt_store::init(cx);
 2465    });
 2466
 2467    let fs = FakeFs::new(cx.executor());
 2468    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 2469        .await;
 2470    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2471        .await;
 2472    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 2473
 2474    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 2475    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 2476
 2477    let (multi_workspace, cx) =
 2478        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2479    let sidebar = setup_sidebar(&multi_workspace, cx);
 2480
 2481    let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
 2482    multi_workspace.update(cx, |mw, _cx| {
 2483        mw.test_add_project_group(ProjectGroup {
 2484            key: project_b_key.clone(),
 2485            workspaces: Vec::new(),
 2486            expanded: true,
 2487            visible_thread_count: None,
 2488        });
 2489    });
 2490
 2491    let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
 2492    save_thread_metadata(
 2493        session_id.clone(),
 2494        Some("Historical Thread in New Group".into()),
 2495        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2496        None,
 2497        &project_b,
 2498        cx,
 2499    );
 2500    cx.run_until_parked();
 2501
 2502    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2503    cx.run_until_parked();
 2504
 2505    let entries_before = visible_entries_as_strings(&sidebar, cx);
 2506    assert_eq!(
 2507        entries_before,
 2508        vec![
 2509            "v [project-a]",
 2510            "v [project-b]",
 2511            "  Historical Thread in New Group",
 2512        ],
 2513        "expected the closed project group to show the historical thread before first open"
 2514    );
 2515
 2516    assert_eq!(
 2517        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2518        1,
 2519        "should start without an open workspace for the new project group"
 2520    );
 2521
 2522    sidebar.update_in(cx, |sidebar, window, cx| {
 2523        sidebar.selection = Some(2);
 2524        sidebar.confirm(&Confirm, window, cx);
 2525    });
 2526    cx.run_until_parked();
 2527    cx.run_until_parked();
 2528    cx.run_until_parked();
 2529
 2530    assert_eq!(
 2531        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2532        2,
 2533        "confirming the historical thread should open a workspace for the new project group"
 2534    );
 2535
 2536    let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
 2537        mw.workspaces()
 2538            .find(|workspace| {
 2539                PathList::new(&workspace.read(cx).root_paths(cx))
 2540                    == project_b_key.path_list().clone()
 2541            })
 2542            .cloned()
 2543            .expect("expected workspace for project-b after opening the historical thread")
 2544    });
 2545
 2546    assert_eq!(
 2547        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2548        workspace_b,
 2549        "opening the historical thread should activate the new project's workspace"
 2550    );
 2551
 2552    let panel = workspace_b.read_with(cx, |workspace, cx| {
 2553        workspace
 2554            .panel::<AgentPanel>(cx)
 2555            .expect("expected first-open activation to bootstrap the agent panel")
 2556    });
 2557
 2558    let expected_thread_id = cx.update(|_, cx| {
 2559        ThreadMetadataStore::global(cx)
 2560            .read(cx)
 2561            .entries()
 2562            .find(|e| e.session_id.as_ref() == Some(&session_id))
 2563            .map(|e| e.thread_id)
 2564            .expect("metadata should still map session id to thread id")
 2565    });
 2566
 2567    assert_eq!(
 2568        panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 2569        Some(expected_thread_id),
 2570        "expected the agent panel to activate the real historical thread rather than a draft"
 2571    );
 2572
 2573    let entries_after = visible_entries_as_strings(&sidebar, cx);
 2574    let matching_rows: Vec<_> = entries_after
 2575        .iter()
 2576        .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
 2577        .cloned()
 2578        .collect();
 2579    assert_eq!(
 2580        matching_rows.len(),
 2581        1,
 2582        "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
 2583    );
 2584    assert!(
 2585        matching_rows[0].contains("Historical Thread in New Group"),
 2586        "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
 2587    );
 2588    assert!(
 2589        !matching_rows[0].contains("Draft"),
 2590        "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
 2591    );
 2592}
 2593
 2594#[gpui::test]
 2595async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
 2596    let project = init_test_project("/my-project", cx).await;
 2597    let (multi_workspace, cx) =
 2598        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2599    let sidebar = setup_sidebar(&multi_workspace, cx);
 2600
 2601    save_thread_metadata(
 2602        acp::SessionId::new(Arc::from("t-1")),
 2603        Some("Thread A".into()),
 2604        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 2605        None,
 2606        &project,
 2607        cx,
 2608    );
 2609
 2610    save_thread_metadata(
 2611        acp::SessionId::new(Arc::from("t-2")),
 2612        Some("Thread B".into()),
 2613        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 2614        None,
 2615        &project,
 2616        cx,
 2617    );
 2618
 2619    cx.run_until_parked();
 2620    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2621    cx.run_until_parked();
 2622
 2623    assert_eq!(
 2624        visible_entries_as_strings(&sidebar, cx),
 2625        vec![
 2626            //
 2627            "v [my-project]",
 2628            "  Thread A",
 2629            "  Thread B",
 2630        ]
 2631    );
 2632
 2633    // Keyboard confirm preserves selection.
 2634    sidebar.update_in(cx, |sidebar, window, cx| {
 2635        sidebar.selection = Some(1);
 2636        sidebar.confirm(&Confirm, window, cx);
 2637    });
 2638    assert_eq!(
 2639        sidebar.read_with(cx, |sidebar, _| sidebar.selection),
 2640        Some(1)
 2641    );
 2642
 2643    // Click handlers clear selection to None so no highlight lingers
 2644    // after a click regardless of focus state. The hover style provides
 2645    // visual feedback during mouse interaction instead.
 2646    sidebar.update_in(cx, |sidebar, window, cx| {
 2647        sidebar.selection = None;
 2648        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 2649        let project_group_key = ProjectGroupKey::new(None, path_list);
 2650        sidebar.toggle_collapse(&project_group_key, window, cx);
 2651    });
 2652    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2653
 2654    // When the user tabs back into the sidebar, focus_in no longer
 2655    // restores selection — it stays None.
 2656    sidebar.update_in(cx, |sidebar, window, cx| {
 2657        sidebar.focus_in(window, cx);
 2658    });
 2659    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2660}
 2661
 2662#[gpui::test]
 2663async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
 2664    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2665    let (multi_workspace, cx) =
 2666        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2667    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2668
 2669    let connection = StubAgentConnection::new();
 2670    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2671        acp::ContentChunk::new("Hi there!".into()),
 2672    )]);
 2673    open_thread_with_connection(&panel, connection, cx);
 2674    send_message(&panel, cx);
 2675
 2676    let session_id = active_session_id(&panel, cx);
 2677    save_test_thread_metadata(&session_id, &project, cx).await;
 2678    cx.run_until_parked();
 2679
 2680    assert_eq!(
 2681        visible_entries_as_strings(&sidebar, cx),
 2682        vec![
 2683            //
 2684            "v [my-project]",
 2685            "  Hello *",
 2686        ]
 2687    );
 2688
 2689    // Simulate the agent generating a title. The notification chain is:
 2690    // AcpThread::set_title emits TitleUpdated →
 2691    // ConnectionView::handle_thread_event calls cx.notify() →
 2692    // AgentPanel observer fires and emits AgentPanelEvent →
 2693    // Sidebar subscription calls update_entries / rebuild_contents.
 2694    //
 2695    // Before the fix, handle_thread_event did NOT call cx.notify() for
 2696    // TitleUpdated, so the AgentPanel observer never fired and the
 2697    // sidebar kept showing the old title.
 2698    let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
 2699    thread.update(cx, |thread, cx| {
 2700        thread
 2701            .set_title("Friendly Greeting with AI".into(), cx)
 2702            .detach();
 2703    });
 2704    cx.run_until_parked();
 2705
 2706    assert_eq!(
 2707        visible_entries_as_strings(&sidebar, cx),
 2708        vec![
 2709            //
 2710            "v [my-project]",
 2711            "  Friendly Greeting with AI *",
 2712        ]
 2713    );
 2714}
 2715
 2716#[gpui::test]
 2717async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
 2718    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 2719    let (multi_workspace, cx) =
 2720        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2721    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2722
 2723    // Save a thread so it appears in the list.
 2724    let connection_a = StubAgentConnection::new();
 2725    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2726        acp::ContentChunk::new("Done".into()),
 2727    )]);
 2728    open_thread_with_connection(&panel_a, connection_a, cx);
 2729    send_message(&panel_a, cx);
 2730    let session_id_a = active_session_id(&panel_a, cx);
 2731    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 2732
 2733    // Add a second workspace with its own agent panel.
 2734    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 2735    fs.as_fake()
 2736        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2737        .await;
 2738    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
 2739    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 2740        mw.test_add_workspace(project_b.clone(), window, cx)
 2741    });
 2742    let panel_b = add_agent_panel(&workspace_b, cx);
 2743    cx.run_until_parked();
 2744
 2745    let workspace_a =
 2746        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 2747
 2748    // ── 1. Initial state: focused thread derived from active panel ─────
 2749    sidebar.read_with(cx, |sidebar, _cx| {
 2750        assert_active_thread(
 2751            sidebar,
 2752            &session_id_a,
 2753            "The active panel's thread should be focused on startup",
 2754        );
 2755    });
 2756
 2757    let thread_metadata_a = cx.update(|_window, cx| {
 2758        ThreadMetadataStore::global(cx)
 2759            .read(cx)
 2760            .entry_by_session(&session_id_a)
 2761            .cloned()
 2762            .expect("session_id_a should exist in metadata store")
 2763    });
 2764    sidebar.update_in(cx, |sidebar, window, cx| {
 2765        sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
 2766    });
 2767    cx.run_until_parked();
 2768
 2769    sidebar.read_with(cx, |sidebar, _cx| {
 2770        assert_active_thread(
 2771            sidebar,
 2772            &session_id_a,
 2773            "After clicking a thread, it should be the focused thread",
 2774        );
 2775        assert!(
 2776            has_thread_entry(sidebar, &session_id_a),
 2777            "The clicked thread should be present in the entries"
 2778        );
 2779    });
 2780
 2781    workspace_a.read_with(cx, |workspace, cx| {
 2782        assert!(
 2783            workspace.panel::<AgentPanel>(cx).is_some(),
 2784            "Agent panel should exist"
 2785        );
 2786        let dock = workspace.left_dock().read(cx);
 2787        assert!(
 2788            dock.is_open(),
 2789            "Clicking a thread should open the agent panel dock"
 2790        );
 2791    });
 2792
 2793    let connection_b = StubAgentConnection::new();
 2794    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2795        acp::ContentChunk::new("Thread B".into()),
 2796    )]);
 2797    open_thread_with_connection(&panel_b, connection_b, cx);
 2798    send_message(&panel_b, cx);
 2799    let session_id_b = active_session_id(&panel_b, cx);
 2800    save_test_thread_metadata(&session_id_b, &project_b, cx).await;
 2801    cx.run_until_parked();
 2802
 2803    // Workspace A is currently active. Click a thread in workspace B,
 2804    // which also triggers a workspace switch.
 2805    let thread_metadata_b = cx.update(|_window, cx| {
 2806        ThreadMetadataStore::global(cx)
 2807            .read(cx)
 2808            .entry_by_session(&session_id_b)
 2809            .cloned()
 2810            .expect("session_id_b should exist in metadata store")
 2811    });
 2812    sidebar.update_in(cx, |sidebar, window, cx| {
 2813        sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
 2814    });
 2815    cx.run_until_parked();
 2816
 2817    sidebar.read_with(cx, |sidebar, _cx| {
 2818        assert_active_thread(
 2819            sidebar,
 2820            &session_id_b,
 2821            "Clicking a thread in another workspace should focus that thread",
 2822        );
 2823        assert!(
 2824            has_thread_entry(sidebar, &session_id_b),
 2825            "The cross-workspace thread should be present in the entries"
 2826        );
 2827    });
 2828
 2829    multi_workspace.update_in(cx, |mw, window, cx| {
 2830        let workspace = mw.workspaces().next().unwrap().clone();
 2831        mw.activate(workspace, window, cx);
 2832    });
 2833    cx.run_until_parked();
 2834
 2835    sidebar.read_with(cx, |sidebar, _cx| {
 2836        assert_active_thread(
 2837            sidebar,
 2838            &session_id_a,
 2839            "Switching workspace should seed focused_thread from the new active panel",
 2840        );
 2841        assert!(
 2842            has_thread_entry(sidebar, &session_id_a),
 2843            "The seeded thread should be present in the entries"
 2844        );
 2845    });
 2846
 2847    let connection_b2 = StubAgentConnection::new();
 2848    connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2849        acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
 2850    )]);
 2851    open_thread_with_connection(&panel_b, connection_b2, cx);
 2852    send_message(&panel_b, cx);
 2853    let session_id_b2 = active_session_id(&panel_b, cx);
 2854    save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
 2855    cx.run_until_parked();
 2856
 2857    // Panel B is not the active workspace's panel (workspace A is
 2858    // active), so opening a thread there should not change focused_thread.
 2859    // This prevents running threads in background workspaces from causing
 2860    // the selection highlight to jump around.
 2861    sidebar.read_with(cx, |sidebar, _cx| {
 2862        assert_active_thread(
 2863            sidebar,
 2864            &session_id_a,
 2865            "Opening a thread in a non-active panel should not change focused_thread",
 2866        );
 2867    });
 2868
 2869    workspace_b.update_in(cx, |workspace, window, cx| {
 2870        workspace.focus_handle(cx).focus(window, cx);
 2871    });
 2872    cx.run_until_parked();
 2873
 2874    sidebar.read_with(cx, |sidebar, _cx| {
 2875        assert_active_thread(
 2876            sidebar,
 2877            &session_id_a,
 2878            "Defocusing the sidebar should not change focused_thread",
 2879        );
 2880    });
 2881
 2882    // Switching workspaces via the multi_workspace (simulates clicking
 2883    // a workspace header) should clear focused_thread.
 2884    multi_workspace.update_in(cx, |mw, window, cx| {
 2885        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
 2886        if let Some(workspace) = workspace {
 2887            mw.activate(workspace, window, cx);
 2888        }
 2889    });
 2890    cx.run_until_parked();
 2891
 2892    sidebar.read_with(cx, |sidebar, _cx| {
 2893        assert_active_thread(
 2894            sidebar,
 2895            &session_id_b2,
 2896            "Switching workspace should seed focused_thread from the new active panel",
 2897        );
 2898        assert!(
 2899            has_thread_entry(sidebar, &session_id_b2),
 2900            "The seeded thread should be present in the entries"
 2901        );
 2902    });
 2903
 2904    // ── 8. Focusing the agent panel thread keeps focused_thread ────
 2905    // Workspace B still has session_id_b2 loaded in the agent panel.
 2906    // Clicking into the thread (simulated by focusing its view) should
 2907    // keep focused_thread since it was already seeded on workspace switch.
 2908    panel_b.update_in(cx, |panel, window, cx| {
 2909        if let Some(thread_view) = panel.active_conversation_view() {
 2910            thread_view.read(cx).focus_handle(cx).focus(window, cx);
 2911        }
 2912    });
 2913    cx.run_until_parked();
 2914
 2915    sidebar.read_with(cx, |sidebar, _cx| {
 2916        assert_active_thread(
 2917            sidebar,
 2918            &session_id_b2,
 2919            "Focusing the agent panel thread should set focused_thread",
 2920        );
 2921        assert!(
 2922            has_thread_entry(sidebar, &session_id_b2),
 2923            "The focused thread should be present in the entries"
 2924        );
 2925    });
 2926}
 2927
 2928#[gpui::test]
 2929async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
 2930    let project = init_test_project_with_agent_panel("/project-a", cx).await;
 2931    let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
 2932    let (multi_workspace, cx) =
 2933        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2934    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2935
 2936    // Start a thread and send a message so it has history.
 2937    let connection = StubAgentConnection::new();
 2938    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2939        acp::ContentChunk::new("Done".into()),
 2940    )]);
 2941    open_thread_with_connection(&panel, connection, cx);
 2942    send_message(&panel, cx);
 2943    let session_id = active_session_id(&panel, cx);
 2944    save_test_thread_metadata(&session_id, &project, cx).await;
 2945    cx.run_until_parked();
 2946
 2947    // Verify the thread appears in the sidebar.
 2948    assert_eq!(
 2949        visible_entries_as_strings(&sidebar, cx),
 2950        vec![
 2951            //
 2952            "v [project-a]",
 2953            "  Hello *",
 2954        ]
 2955    );
 2956
 2957    // The "New Thread" button should NOT be in "active/draft" state
 2958    // because the panel has a thread with messages.
 2959    sidebar.read_with(cx, |sidebar, _cx| {
 2960        assert!(
 2961            matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
 2962            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
 2963            sidebar.active_entry,
 2964        );
 2965    });
 2966
 2967    // Now add a second folder to the workspace, changing the path_list.
 2968    fs.as_fake()
 2969        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2970        .await;
 2971    project
 2972        .update(cx, |project, cx| {
 2973            project.find_or_create_worktree("/project-b", true, cx)
 2974        })
 2975        .await
 2976        .expect("should add worktree");
 2977    cx.run_until_parked();
 2978
 2979    // The workspace path_list is now [project-a, project-b]. The active
 2980    // thread's metadata was re-saved with the new paths by the agent panel's
 2981    // project subscription. The old [project-a] key is replaced by the new
 2982    // key since no other workspace claims it.
 2983    let entries = visible_entries_as_strings(&sidebar, cx);
 2984    // After adding a worktree, the thread migrates to the new group key.
 2985    // A reconciliation draft may appear during the transition.
 2986    assert!(
 2987        entries.contains(&"  Hello *".to_string()),
 2988        "thread should still be present after adding folder: {entries:?}"
 2989    );
 2990    assert_eq!(entries[0], "v [project-a, project-b]");
 2991
 2992    // The "New Thread" button must still be clickable (not stuck in
 2993    // "active/draft" state). Verify that `active_thread_is_draft` is
 2994    // false — the panel still has the old thread with messages.
 2995    sidebar.read_with(cx, |sidebar, _cx| {
 2996        assert!(
 2997            matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
 2998            "After adding a folder the panel still has a thread with messages, \
 2999                 so active_entry should be Thread, got {:?}",
 3000            sidebar.active_entry,
 3001        );
 3002    });
 3003
 3004    // Actually click "New Thread" by calling create_new_thread and
 3005    // verify a new draft is created.
 3006    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 3007    sidebar.update_in(cx, |sidebar, window, cx| {
 3008        sidebar.create_new_thread(&workspace, window, cx);
 3009    });
 3010    cx.run_until_parked();
 3011
 3012    // After creating a new thread, the panel should now be in draft
 3013    // state (no messages on the new thread).
 3014    sidebar.read_with(cx, |sidebar, _cx| {
 3015        assert_active_draft(
 3016            sidebar,
 3017            &workspace,
 3018            "After creating a new thread active_entry should be Draft",
 3019        );
 3020    });
 3021}
 3022#[gpui::test]
 3023async fn test_group_level_folder_add_syncs_siblings_but_individual_add_splits(
 3024    cx: &mut TestAppContext,
 3025) {
 3026    // Group-level operations (via the "..." menu) should keep all workspaces
 3027    // in the group in sync. Individual worktree additions should let a
 3028    // workspace diverge from its group.
 3029    init_test(cx);
 3030    let fs = FakeFs::new(cx.executor());
 3031    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 3032        .await;
 3033    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 3034        .await;
 3035    fs.insert_tree("/project-c", serde_json::json!({ "src": {} }))
 3036        .await;
 3037    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3038
 3039    let project_a = project::Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
 3040    let (multi_workspace, cx) =
 3041        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3042    let _sidebar = setup_sidebar(&multi_workspace, cx);
 3043
 3044    // Add a second workspace in the same group by adding it with the same
 3045    // project so they share a project group key.
 3046    let project_a2 = project::Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
 3047    multi_workspace.update_in(cx, |mw, window, cx| {
 3048        mw.test_add_workspace(project_a2.clone(), window, cx);
 3049    });
 3050    cx.run_until_parked();
 3051
 3052    // Both workspaces should be in the same group with key [/project-a].
 3053    multi_workspace.read_with(cx, |mw, _cx| {
 3054        assert_eq!(mw.workspaces().count(), 2);
 3055        assert_eq!(mw.project_group_keys().len(), 1);
 3056    });
 3057
 3058    // --- Group-level add: add /project-b via the group API ---
 3059    let group_key = multi_workspace.read_with(cx, |mw, _cx| mw.project_group_keys()[0].clone());
 3060    multi_workspace.update(cx, |mw, cx| {
 3061        mw.add_folders_to_project_group(&group_key, vec![PathBuf::from("/project-b")], cx);
 3062    });
 3063    cx.run_until_parked();
 3064
 3065    // Both workspaces should now have /project-b as a worktree.
 3066    multi_workspace.read_with(cx, |mw, cx| {
 3067        for workspace in mw.workspaces() {
 3068            let paths = workspace.read(cx).root_paths(cx);
 3069            assert!(
 3070                paths.iter().any(|p| p.ends_with("project-b")),
 3071                "group-level add should propagate /project-b to all siblings, got {:?}",
 3072                paths,
 3073            );
 3074        }
 3075    });
 3076
 3077    // --- Individual add: add /project-c directly to one workspace ---
 3078    let first_workspace =
 3079        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 3080    let first_project = first_workspace.read_with(cx, |ws, _cx| ws.project().clone());
 3081    first_project
 3082        .update(cx, |project, cx| {
 3083            project.find_or_create_worktree("/project-c", true, cx)
 3084        })
 3085        .await
 3086        .expect("should add worktree");
 3087    cx.run_until_parked();
 3088
 3089    // The first workspace should now have /project-c but the second should not.
 3090    let second_workspace =
 3091        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().nth(1).unwrap().clone());
 3092    first_workspace.read_with(cx, |ws, cx| {
 3093        let paths = ws.root_paths(cx);
 3094        assert!(
 3095            paths.iter().any(|p| p.ends_with("project-c")),
 3096            "individual add should give /project-c to this workspace, got {:?}",
 3097            paths,
 3098        );
 3099    });
 3100    second_workspace.read_with(cx, |ws, cx| {
 3101        let paths = ws.root_paths(cx);
 3102        assert!(
 3103            !paths.iter().any(|p| p.ends_with("project-c")),
 3104            "individual add should NOT propagate /project-c to sibling, got {:?}",
 3105            paths,
 3106        );
 3107    });
 3108}
 3109
 3110#[gpui::test]
 3111async fn test_draft_title_updates_from_editor_text(cx: &mut TestAppContext) {
 3112    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3113    let (multi_workspace, cx) =
 3114        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3115    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3116
 3117    // The reconciliation-created draft should show the default title.
 3118    let draft_title = sidebar.read_with(cx, |sidebar, _cx| {
 3119        sidebar
 3120            .contents
 3121            .entries
 3122            .iter()
 3123            .find_map(|entry| match entry {
 3124                ListEntry::Thread(thread) if thread.is_draft => {
 3125                    Some(thread.metadata.display_title())
 3126                }
 3127                _ => None,
 3128            })
 3129            .expect("should have a draft entry")
 3130    });
 3131    assert_eq!(
 3132        draft_title.as_ref(),
 3133        "New Agent Thread",
 3134        "draft should start with default title"
 3135    );
 3136
 3137    // Create a new thread (activates the draft as base view and connects).
 3138    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 3139    let panel = workspace.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
 3140    let connection = StubAgentConnection::new();
 3141    open_thread_with_connection(&panel, connection, cx);
 3142    cx.run_until_parked();
 3143
 3144    // Type into the draft's message editor.
 3145    let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3146    let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
 3147    message_editor.update_in(cx, |editor, window, cx| {
 3148        editor.set_text("Fix the login bug", window, cx);
 3149    });
 3150    cx.run_until_parked();
 3151
 3152    // The sidebar draft title should now reflect the editor text.
 3153    let draft_title = sidebar.read_with(cx, |sidebar, _cx| {
 3154        sidebar
 3155            .contents
 3156            .entries
 3157            .iter()
 3158            .find_map(|entry| match entry {
 3159                ListEntry::Thread(thread) if thread.is_draft => {
 3160                    Some(thread.metadata.display_title())
 3161                }
 3162                _ => None,
 3163            })
 3164            .expect("should still have a draft entry")
 3165    });
 3166    assert_eq!(
 3167        draft_title.as_ref(),
 3168        "Fix the login bug",
 3169        "draft title should update to match editor text"
 3170    );
 3171}
 3172
 3173#[gpui::test]
 3174async fn test_draft_title_updates_across_two_groups(cx: &mut TestAppContext) {
 3175    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 3176    let (multi_workspace, cx) =
 3177        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3178    let (sidebar, _panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3179
 3180    // Add a second project group.
 3181    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 3182    fs.as_fake()
 3183        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 3184        .await;
 3185    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
 3186    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 3187        mw.test_add_workspace(project_b.clone(), window, cx)
 3188    });
 3189    let panel_b = add_agent_panel(&workspace_b, cx);
 3190    cx.run_until_parked();
 3191
 3192    // Both groups should have reconciliation drafts.
 3193    let draft_titles: Vec<(SharedString, bool)> = sidebar.read_with(cx, |sidebar, _cx| {
 3194        sidebar
 3195            .contents
 3196            .entries
 3197            .iter()
 3198            .filter_map(|entry| match entry {
 3199                ListEntry::Thread(thread) if thread.is_draft => {
 3200                    Some((thread.metadata.display_title(), false))
 3201                }
 3202                _ => None,
 3203            })
 3204            .collect()
 3205    });
 3206    assert_eq!(
 3207        draft_titles.len(),
 3208        2,
 3209        "should have two drafts, one per group"
 3210    );
 3211
 3212    // Open a thread in each group's panel to get Connected state.
 3213    let workspace_a =
 3214        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 3215    let panel_a = workspace_a.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
 3216
 3217    let connection_a = StubAgentConnection::new();
 3218    open_thread_with_connection(&panel_a, connection_a, cx);
 3219    cx.run_until_parked();
 3220
 3221    let connection_b = StubAgentConnection::new();
 3222    open_thread_with_connection(&panel_b, connection_b, cx);
 3223    cx.run_until_parked();
 3224
 3225    // Type into group A's draft editor.
 3226    let thread_view_a = panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3227    let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone());
 3228    editor_a.update_in(cx, |editor, window, cx| {
 3229        editor.set_text("Fix the login bug", window, cx);
 3230    });
 3231    cx.run_until_parked();
 3232
 3233    // Type into group B's draft editor.
 3234    let thread_view_b = panel_b.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3235    let editor_b = thread_view_b.read_with(cx, |view, _cx| view.message_editor.clone());
 3236    editor_b.update_in(cx, |editor, window, cx| {
 3237        editor.set_text("Refactor the database", window, cx);
 3238    });
 3239    cx.run_until_parked();
 3240
 3241    // Both draft titles should reflect their respective editor text.
 3242    let draft_titles: Vec<SharedString> = sidebar.read_with(cx, |sidebar, _cx| {
 3243        sidebar
 3244            .contents
 3245            .entries
 3246            .iter()
 3247            .filter_map(|entry| match entry {
 3248                ListEntry::Thread(thread) if thread.is_draft => {
 3249                    Some(thread.metadata.display_title())
 3250                }
 3251                _ => None,
 3252            })
 3253            .collect()
 3254    });
 3255    assert_eq!(draft_titles.len(), 2, "should still have two drafts");
 3256    assert!(
 3257        draft_titles.contains(&SharedString::from("Fix the login bug")),
 3258        "group A draft should show editor text, got: {:?}",
 3259        draft_titles
 3260    );
 3261    assert!(
 3262        draft_titles.contains(&SharedString::from("Refactor the database")),
 3263        "group B draft should show editor text, got: {:?}",
 3264        draft_titles
 3265    );
 3266}
 3267
 3268#[gpui::test]
 3269async fn test_draft_title_survives_folder_addition(cx: &mut TestAppContext) {
 3270    // When a folder is added to the project, the group key changes.
 3271    // The draft's editor observation should still work and the title
 3272    // should update when the user types.
 3273    init_test(cx);
 3274    let fs = FakeFs::new(cx.executor());
 3275    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 3276        .await;
 3277    fs.insert_tree("/project-b", serde_json::json!({ "lib": {} }))
 3278        .await;
 3279    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3280
 3281    let project = project::Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
 3282    let (multi_workspace, cx) =
 3283        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3284    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3285
 3286    // Create a thread with a connection (has a session_id, considered
 3287    // a draft by the panel until messages are sent).
 3288    let connection = StubAgentConnection::new();
 3289    open_thread_with_connection(&panel, connection, cx);
 3290    cx.run_until_parked();
 3291
 3292    // Type into the editor.
 3293    let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3294    let editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
 3295    editor.update_in(cx, |editor, window, cx| {
 3296        editor.set_text("Initial text", window, cx);
 3297    });
 3298    let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
 3299    cx.run_until_parked();
 3300
 3301    // The thread without a title should show the editor text via
 3302    // the draft title override.
 3303    sidebar.read_with(cx, |sidebar, _cx| {
 3304        let thread = sidebar
 3305            .contents
 3306            .entries
 3307            .iter()
 3308            .find_map(|entry| match entry {
 3309                ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
 3310                _ => None,
 3311            });
 3312        assert_eq!(
 3313            thread.and_then(|t| t.metadata.title.as_ref().map(|s| s.as_ref())),
 3314            Some("Initial text"),
 3315            "draft title should show editor text before folder add"
 3316        );
 3317    });
 3318
 3319    // Add a second folder to the project — this changes the group key.
 3320    project
 3321        .update(cx, |project, cx| {
 3322            project.find_or_create_worktree("/project-b", true, cx)
 3323        })
 3324        .await
 3325        .expect("should add worktree");
 3326    cx.run_until_parked();
 3327
 3328    // Update editor text.
 3329    editor.update_in(cx, |editor, window, cx| {
 3330        editor.set_text("Updated after folder add", window, cx);
 3331    });
 3332    cx.run_until_parked();
 3333
 3334    // The draft title should still update. After adding a folder the
 3335    // group key changes, so the thread may not appear in the sidebar
 3336    // if its metadata was saved under the old path list. If it IS
 3337    // found, verify the title was overridden.
 3338    sidebar.read_with(cx, |sidebar, _cx| {
 3339        let thread = sidebar
 3340            .contents
 3341            .entries
 3342            .iter()
 3343            .find_map(|entry| match entry {
 3344                ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
 3345                _ => None,
 3346            });
 3347        if let Some(thread) = thread {
 3348            assert_eq!(
 3349                thread.metadata.title.as_ref().map(|s| s.as_ref()),
 3350                Some("Updated after folder add"),
 3351                "draft title should update even after adding a folder"
 3352            );
 3353        }
 3354    });
 3355}
 3356
 3357#[gpui::test]
 3358async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
 3359    // When the user presses Cmd-N (NewThread action) while viewing a
 3360    // non-empty thread, the sidebar should show the "New Thread" entry.
 3361    // This exercises the same code path as the workspace action handler
 3362    // (which bypasses the sidebar's create_new_thread method).
 3363    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3364    let (multi_workspace, cx) =
 3365        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3366    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3367
 3368    // Create a non-empty thread (has messages).
 3369    let connection = StubAgentConnection::new();
 3370    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3371        acp::ContentChunk::new("Done".into()),
 3372    )]);
 3373    open_thread_with_connection(&panel, connection, cx);
 3374    send_message(&panel, cx);
 3375
 3376    let session_id = active_session_id(&panel, cx);
 3377    save_test_thread_metadata(&session_id, &project, cx).await;
 3378    cx.run_until_parked();
 3379
 3380    assert_eq!(
 3381        visible_entries_as_strings(&sidebar, cx),
 3382        vec![
 3383            //
 3384            "v [my-project]",
 3385            "  Hello *",
 3386        ]
 3387    );
 3388
 3389    // Simulate cmd-n
 3390    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 3391    panel.update_in(cx, |panel, window, cx| {
 3392        panel.new_thread(&NewThread, window, cx);
 3393    });
 3394    workspace.update_in(cx, |workspace, window, cx| {
 3395        workspace.focus_panel::<AgentPanel>(window, cx);
 3396    });
 3397    cx.run_until_parked();
 3398
 3399    assert_eq!(
 3400        visible_entries_as_strings(&sidebar, cx),
 3401        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3402        "After Cmd-N the sidebar should show a highlighted Draft entry"
 3403    );
 3404
 3405    sidebar.read_with(cx, |sidebar, _cx| {
 3406        assert_active_draft(
 3407            sidebar,
 3408            &workspace,
 3409            "active_entry should be Draft after Cmd-N",
 3410        );
 3411    });
 3412}
 3413
 3414#[gpui::test]
 3415async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
 3416    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3417    let (multi_workspace, cx) =
 3418        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3419    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3420
 3421    // Create a saved thread so the workspace has history.
 3422    let connection = StubAgentConnection::new();
 3423    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3424        acp::ContentChunk::new("Done".into()),
 3425    )]);
 3426    open_thread_with_connection(&panel, connection, cx);
 3427    send_message(&panel, cx);
 3428    let saved_session_id = active_session_id(&panel, cx);
 3429    save_test_thread_metadata(&saved_session_id, &project, cx).await;
 3430    cx.run_until_parked();
 3431
 3432    assert_eq!(
 3433        visible_entries_as_strings(&sidebar, cx),
 3434        vec![
 3435            //
 3436            "v [my-project]",
 3437            "  Hello *",
 3438        ]
 3439    );
 3440
 3441    // Create a new draft via Cmd-N. Since new_thread() now creates a
 3442    // tracked draft in the AgentPanel, it appears in the sidebar.
 3443    panel.update_in(cx, |panel, window, cx| {
 3444        panel.new_thread(&NewThread, window, cx);
 3445    });
 3446    cx.run_until_parked();
 3447
 3448    assert_eq!(
 3449        visible_entries_as_strings(&sidebar, cx),
 3450        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3451    );
 3452
 3453    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 3454    sidebar.read_with(cx, |sidebar, _cx| {
 3455        assert_active_draft(
 3456            sidebar,
 3457            &workspace,
 3458            "Draft with server session should be Draft, not Thread",
 3459        );
 3460    });
 3461}
 3462
 3463#[gpui::test]
 3464async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext) {
 3465    // When the user sends a message from a draft thread, the draft
 3466    // should be removed from the sidebar and the active_entry should
 3467    // transition to a Thread pointing at the new session.
 3468    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3469    let (multi_workspace, cx) =
 3470        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3471    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3472
 3473    // Create a saved thread so the group isn't empty.
 3474    let connection = StubAgentConnection::new();
 3475    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3476        acp::ContentChunk::new("Done".into()),
 3477    )]);
 3478    open_thread_with_connection(&panel, connection, cx);
 3479    send_message(&panel, cx);
 3480    let existing_session_id = active_session_id(&panel, cx);
 3481    save_test_thread_metadata(&existing_session_id, &project, cx).await;
 3482    cx.run_until_parked();
 3483
 3484    // Create a draft via Cmd-N.
 3485    panel.update_in(cx, |panel, window, cx| {
 3486        panel.new_thread(&NewThread, window, cx);
 3487    });
 3488    cx.run_until_parked();
 3489
 3490    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 3491    assert_eq!(
 3492        visible_entries_as_strings(&sidebar, cx),
 3493        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3494        "draft should be visible before sending",
 3495    );
 3496    sidebar.read_with(cx, |sidebar, _| {
 3497        assert_active_draft(sidebar, &workspace, "should be on draft before sending");
 3498    });
 3499
 3500    // Simulate what happens when a draft sends its first message:
 3501    // the AgentPanel's MessageSentOrQueued handler removes the draft
 3502    // from `draft_threads`, then the sidebar rebuilds. We can't use
 3503    // the NativeAgentServer in tests, so replicate the key steps:
 3504    // remove the draft, open a real thread with a stub connection,
 3505    // and send.
 3506    let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
 3507    panel.update_in(cx, |panel, _window, cx| {
 3508        panel.remove_thread(thread_id, cx);
 3509    });
 3510    let draft_connection = StubAgentConnection::new();
 3511    draft_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3512        acp::ContentChunk::new("World".into()),
 3513    )]);
 3514    open_thread_with_connection(&panel, draft_connection, cx);
 3515    send_message(&panel, cx);
 3516    let new_session_id = active_session_id(&panel, cx);
 3517    save_test_thread_metadata(&new_session_id, &project, cx).await;
 3518    cx.run_until_parked();
 3519
 3520    // The draft should be gone and the new thread should be active.
 3521    let entries = visible_entries_as_strings(&sidebar, cx);
 3522    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 3523    assert_eq!(
 3524        draft_count, 0,
 3525        "draft should be removed after sending a message"
 3526    );
 3527
 3528    sidebar.read_with(cx, |sidebar, _| {
 3529        assert_active_thread(
 3530            sidebar,
 3531            &new_session_id,
 3532            "active_entry should transition to the new thread after sending",
 3533        );
 3534    });
 3535}
 3536
 3537#[gpui::test]
 3538async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
 3539    // When the active workspace is an absorbed git worktree, cmd-n
 3540    // should still show the "New Thread" entry under the main repo's
 3541    // header and highlight it as active.
 3542    agent_ui::test_support::init_test(cx);
 3543    cx.update(|cx| {
 3544        ThreadStore::init_global(cx);
 3545        ThreadMetadataStore::init_global(cx);
 3546        language_model::LanguageModelRegistry::test(cx);
 3547        prompt_store::init(cx);
 3548    });
 3549
 3550    let fs = FakeFs::new(cx.executor());
 3551
 3552    // Main repo with a linked worktree.
 3553    fs.insert_tree(
 3554        "/project",
 3555        serde_json::json!({
 3556            ".git": {},
 3557            "src": {},
 3558        }),
 3559    )
 3560    .await;
 3561
 3562    // Worktree checkout pointing back to the main repo.
 3563    fs.add_linked_worktree_for_repo(
 3564        Path::new("/project/.git"),
 3565        false,
 3566        git::repository::Worktree {
 3567            path: std::path::PathBuf::from("/wt-feature-a"),
 3568            ref_name: Some("refs/heads/feature-a".into()),
 3569            sha: "aaa".into(),
 3570            is_main: false,
 3571        },
 3572    )
 3573    .await;
 3574
 3575    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3576
 3577    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3578    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3579
 3580    main_project
 3581        .update(cx, |p, cx| p.git_scans_complete(cx))
 3582        .await;
 3583    worktree_project
 3584        .update(cx, |p, cx| p.git_scans_complete(cx))
 3585        .await;
 3586
 3587    let (multi_workspace, cx) =
 3588        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3589
 3590    let sidebar = setup_sidebar(&multi_workspace, cx);
 3591
 3592    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3593        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3594    });
 3595
 3596    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3597
 3598    // Switch to the worktree workspace.
 3599    multi_workspace.update_in(cx, |mw, window, cx| {
 3600        let workspace = mw.workspaces().nth(1).unwrap().clone();
 3601        mw.activate(workspace, window, cx);
 3602    });
 3603
 3604    // Create a non-empty thread in the worktree workspace.
 3605    let connection = StubAgentConnection::new();
 3606    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3607        acp::ContentChunk::new("Done".into()),
 3608    )]);
 3609    open_thread_with_connection(&worktree_panel, connection, cx);
 3610    send_message(&worktree_panel, cx);
 3611
 3612    let session_id = active_session_id(&worktree_panel, cx);
 3613    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3614    cx.run_until_parked();
 3615
 3616    assert_eq!(
 3617        visible_entries_as_strings(&sidebar, cx),
 3618        vec![
 3619            //
 3620            "v [project]",
 3621            "  Hello {wt-feature-a} *",
 3622        ]
 3623    );
 3624
 3625    // Simulate Cmd-N in the worktree workspace.
 3626    worktree_panel.update_in(cx, |panel, window, cx| {
 3627        panel.new_thread(&NewThread, window, cx);
 3628    });
 3629    worktree_workspace.update_in(cx, |workspace, window, cx| {
 3630        workspace.focus_panel::<AgentPanel>(window, cx);
 3631    });
 3632    cx.run_until_parked();
 3633
 3634    assert_eq!(
 3635        visible_entries_as_strings(&sidebar, cx),
 3636        vec![
 3637            //
 3638            "v [project]",
 3639            "  [~ Draft {wt-feature-a}] *",
 3640            "  Hello {wt-feature-a} *"
 3641        ],
 3642        "After Cmd-N in an absorbed worktree, the sidebar should show \
 3643             a highlighted Draft entry under the main repo header"
 3644    );
 3645
 3646    sidebar.read_with(cx, |sidebar, _cx| {
 3647        assert_active_draft(
 3648            sidebar,
 3649            &worktree_workspace,
 3650            "active_entry should be Draft after Cmd-N",
 3651        );
 3652    });
 3653}
 3654
 3655async fn init_test_project_with_git(
 3656    worktree_path: &str,
 3657    cx: &mut TestAppContext,
 3658) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
 3659    init_test(cx);
 3660    let fs = FakeFs::new(cx.executor());
 3661    fs.insert_tree(
 3662        worktree_path,
 3663        serde_json::json!({
 3664            ".git": {},
 3665            "src": {},
 3666        }),
 3667    )
 3668    .await;
 3669    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3670    let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
 3671    (project, fs)
 3672}
 3673
 3674#[gpui::test]
 3675async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
 3676    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3677
 3678    fs.as_fake()
 3679        .add_linked_worktree_for_repo(
 3680            Path::new("/project/.git"),
 3681            false,
 3682            git::repository::Worktree {
 3683                path: std::path::PathBuf::from("/wt/rosewood"),
 3684                ref_name: Some("refs/heads/rosewood".into()),
 3685                sha: "abc".into(),
 3686                is_main: false,
 3687            },
 3688        )
 3689        .await;
 3690
 3691    project
 3692        .update(cx, |project, cx| project.git_scans_complete(cx))
 3693        .await;
 3694
 3695    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3696    worktree_project
 3697        .update(cx, |p, cx| p.git_scans_complete(cx))
 3698        .await;
 3699
 3700    let (multi_workspace, cx) =
 3701        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3702    let sidebar = setup_sidebar(&multi_workspace, cx);
 3703
 3704    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
 3705    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
 3706
 3707    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3708    cx.run_until_parked();
 3709
 3710    // Search for "rosewood" — should match the worktree name, not the title.
 3711    type_in_search(&sidebar, "rosewood", cx);
 3712
 3713    assert_eq!(
 3714        visible_entries_as_strings(&sidebar, cx),
 3715        vec![
 3716            //
 3717            "v [project]",
 3718            "  Fix Bug {rosewood}  <== selected",
 3719        ],
 3720    );
 3721}
 3722
 3723#[gpui::test]
 3724async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
 3725    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3726
 3727    project
 3728        .update(cx, |project, cx| project.git_scans_complete(cx))
 3729        .await;
 3730
 3731    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3732    worktree_project
 3733        .update(cx, |p, cx| p.git_scans_complete(cx))
 3734        .await;
 3735
 3736    let (multi_workspace, cx) =
 3737        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3738    let sidebar = setup_sidebar(&multi_workspace, cx);
 3739
 3740    // Save a thread against a worktree path with the correct main
 3741    // worktree association (as if the git state had been resolved).
 3742    save_thread_metadata_with_main_paths(
 3743        "wt-thread",
 3744        "Worktree Thread",
 3745        PathList::new(&[PathBuf::from("/wt/rosewood")]),
 3746        PathList::new(&[PathBuf::from("/project")]),
 3747        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3748        cx,
 3749    );
 3750
 3751    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3752    cx.run_until_parked();
 3753
 3754    // Thread is visible because its main_worktree_paths match the group.
 3755    // The chip name is derived from the path even before git discovery.
 3756    assert_eq!(
 3757        visible_entries_as_strings(&sidebar, cx),
 3758        vec!["v [project]", "  Worktree Thread {rosewood}"]
 3759    );
 3760
 3761    // Now add the worktree to the git state and trigger a rescan.
 3762    fs.as_fake()
 3763        .add_linked_worktree_for_repo(
 3764            Path::new("/project/.git"),
 3765            true,
 3766            git::repository::Worktree {
 3767                path: std::path::PathBuf::from("/wt/rosewood"),
 3768                ref_name: Some("refs/heads/rosewood".into()),
 3769                sha: "abc".into(),
 3770                is_main: false,
 3771            },
 3772        )
 3773        .await;
 3774
 3775    cx.run_until_parked();
 3776
 3777    assert_eq!(
 3778        visible_entries_as_strings(&sidebar, cx),
 3779        vec![
 3780            //
 3781            "v [project]",
 3782            "  Worktree Thread {rosewood}",
 3783        ]
 3784    );
 3785}
 3786
 3787#[gpui::test]
 3788async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
 3789    init_test(cx);
 3790    let fs = FakeFs::new(cx.executor());
 3791
 3792    // Create the main repo directory (not opened as a workspace yet).
 3793    fs.insert_tree(
 3794        "/project",
 3795        serde_json::json!({
 3796            ".git": {
 3797            },
 3798            "src": {},
 3799        }),
 3800    )
 3801    .await;
 3802
 3803    // Two worktree checkouts whose .git files point back to the main repo.
 3804    fs.add_linked_worktree_for_repo(
 3805        Path::new("/project/.git"),
 3806        false,
 3807        git::repository::Worktree {
 3808            path: std::path::PathBuf::from("/wt-feature-a"),
 3809            ref_name: Some("refs/heads/feature-a".into()),
 3810            sha: "aaa".into(),
 3811            is_main: false,
 3812        },
 3813    )
 3814    .await;
 3815    fs.add_linked_worktree_for_repo(
 3816        Path::new("/project/.git"),
 3817        false,
 3818        git::repository::Worktree {
 3819            path: std::path::PathBuf::from("/wt-feature-b"),
 3820            ref_name: Some("refs/heads/feature-b".into()),
 3821            sha: "bbb".into(),
 3822            is_main: false,
 3823        },
 3824    )
 3825    .await;
 3826
 3827    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3828
 3829    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3830    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3831
 3832    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3833    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3834
 3835    // Open both worktrees as workspaces — no main repo yet.
 3836    let (multi_workspace, cx) =
 3837        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3838    multi_workspace.update_in(cx, |mw, window, cx| {
 3839        mw.test_add_workspace(project_b.clone(), window, cx);
 3840    });
 3841    let sidebar = setup_sidebar(&multi_workspace, cx);
 3842
 3843    save_thread_metadata(
 3844        acp::SessionId::new(Arc::from("thread-a")),
 3845        Some("Thread A".into()),
 3846        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3847        None,
 3848        &project_a,
 3849        cx,
 3850    );
 3851    save_thread_metadata(
 3852        acp::SessionId::new(Arc::from("thread-b")),
 3853        Some("Thread B".into()),
 3854        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 3855        None,
 3856        &project_b,
 3857        cx,
 3858    );
 3859
 3860    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3861    cx.run_until_parked();
 3862
 3863    // Without the main repo, each worktree has its own header.
 3864    assert_eq!(
 3865        visible_entries_as_strings(&sidebar, cx),
 3866        vec![
 3867            //
 3868            "v [project]",
 3869            "  Thread B {wt-feature-b}",
 3870            "  Thread A {wt-feature-a}",
 3871        ]
 3872    );
 3873
 3874    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3875    main_project
 3876        .update(cx, |p, cx| p.git_scans_complete(cx))
 3877        .await;
 3878
 3879    multi_workspace.update_in(cx, |mw, window, cx| {
 3880        mw.test_add_workspace(main_project.clone(), window, cx);
 3881    });
 3882    cx.run_until_parked();
 3883
 3884    // Both worktree workspaces should now be absorbed under the main
 3885    // repo header, with worktree chips.
 3886    assert_eq!(
 3887        visible_entries_as_strings(&sidebar, cx),
 3888        vec![
 3889            //
 3890            "v [project]",
 3891            "  Thread B {wt-feature-b}",
 3892            "  Thread A {wt-feature-a}",
 3893        ]
 3894    );
 3895}
 3896
 3897#[gpui::test]
 3898async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
 3899    // When a group has two workspaces — one with threads and one
 3900    // without — the threadless workspace should appear as a
 3901    // "New Thread" button with its worktree chip.
 3902    init_test(cx);
 3903    let fs = FakeFs::new(cx.executor());
 3904
 3905    // Main repo with two linked worktrees.
 3906    fs.insert_tree(
 3907        "/project",
 3908        serde_json::json!({
 3909            ".git": {},
 3910            "src": {},
 3911        }),
 3912    )
 3913    .await;
 3914    fs.add_linked_worktree_for_repo(
 3915        Path::new("/project/.git"),
 3916        false,
 3917        git::repository::Worktree {
 3918            path: std::path::PathBuf::from("/wt-feature-a"),
 3919            ref_name: Some("refs/heads/feature-a".into()),
 3920            sha: "aaa".into(),
 3921            is_main: false,
 3922        },
 3923    )
 3924    .await;
 3925    fs.add_linked_worktree_for_repo(
 3926        Path::new("/project/.git"),
 3927        false,
 3928        git::repository::Worktree {
 3929            path: std::path::PathBuf::from("/wt-feature-b"),
 3930            ref_name: Some("refs/heads/feature-b".into()),
 3931            sha: "bbb".into(),
 3932            is_main: false,
 3933        },
 3934    )
 3935    .await;
 3936
 3937    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3938
 3939    // Workspace A: worktree feature-a (has threads).
 3940    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3941    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3942
 3943    // Workspace B: worktree feature-b (no threads).
 3944    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3945    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3946
 3947    let (multi_workspace, cx) =
 3948        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3949    multi_workspace.update_in(cx, |mw, window, cx| {
 3950        mw.test_add_workspace(project_b.clone(), window, cx);
 3951    });
 3952    let sidebar = setup_sidebar(&multi_workspace, cx);
 3953
 3954    // Only save a thread for workspace A.
 3955    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
 3956
 3957    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3958    cx.run_until_parked();
 3959
 3960    // Workspace A's thread appears normally. Workspace B (threadless)
 3961    // appears as a "New Thread" button with its worktree chip.
 3962    assert_eq!(
 3963        visible_entries_as_strings(&sidebar, cx),
 3964        vec!["v [project]", "  Thread A {wt-feature-a}",]
 3965    );
 3966}
 3967
 3968#[gpui::test]
 3969async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
 3970    // A thread created in a workspace with roots from different git
 3971    // worktrees should show a chip for each distinct worktree name.
 3972    init_test(cx);
 3973    let fs = FakeFs::new(cx.executor());
 3974
 3975    // Two main repos.
 3976    fs.insert_tree(
 3977        "/project_a",
 3978        serde_json::json!({
 3979            ".git": {},
 3980            "src": {},
 3981        }),
 3982    )
 3983    .await;
 3984    fs.insert_tree(
 3985        "/project_b",
 3986        serde_json::json!({
 3987            ".git": {},
 3988            "src": {},
 3989        }),
 3990    )
 3991    .await;
 3992
 3993    // Worktree checkouts.
 3994    for repo in &["project_a", "project_b"] {
 3995        let git_path = format!("/{repo}/.git");
 3996        for branch in &["olivetti", "selectric"] {
 3997            fs.add_linked_worktree_for_repo(
 3998                Path::new(&git_path),
 3999                false,
 4000                git::repository::Worktree {
 4001                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
 4002                    ref_name: Some(format!("refs/heads/{branch}").into()),
 4003                    sha: "aaa".into(),
 4004                    is_main: false,
 4005                },
 4006            )
 4007            .await;
 4008        }
 4009    }
 4010
 4011    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4012
 4013    // Open a workspace with the worktree checkout paths as roots
 4014    // (this is the workspace the thread was created in).
 4015    let project = project::Project::test(
 4016        fs.clone(),
 4017        [
 4018            "/worktrees/project_a/olivetti/project_a".as_ref(),
 4019            "/worktrees/project_b/selectric/project_b".as_ref(),
 4020        ],
 4021        cx,
 4022    )
 4023    .await;
 4024    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 4025
 4026    let (multi_workspace, cx) =
 4027        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 4028    let sidebar = setup_sidebar(&multi_workspace, cx);
 4029
 4030    // Save a thread under the same paths as the workspace roots.
 4031    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
 4032
 4033    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4034    cx.run_until_parked();
 4035
 4036    // Should show two distinct worktree chips.
 4037    assert_eq!(
 4038        visible_entries_as_strings(&sidebar, cx),
 4039        vec![
 4040            //
 4041            "v [project_a, project_b]",
 4042            "  Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
 4043        ]
 4044    );
 4045}
 4046
 4047#[gpui::test]
 4048async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
 4049    // When a thread's roots span multiple repos but share the same
 4050    // worktree name (e.g. both in "olivetti"), only one chip should
 4051    // appear.
 4052    init_test(cx);
 4053    let fs = FakeFs::new(cx.executor());
 4054
 4055    fs.insert_tree(
 4056        "/project_a",
 4057        serde_json::json!({
 4058            ".git": {},
 4059            "src": {},
 4060        }),
 4061    )
 4062    .await;
 4063    fs.insert_tree(
 4064        "/project_b",
 4065        serde_json::json!({
 4066            ".git": {},
 4067            "src": {},
 4068        }),
 4069    )
 4070    .await;
 4071
 4072    for repo in &["project_a", "project_b"] {
 4073        let git_path = format!("/{repo}/.git");
 4074        fs.add_linked_worktree_for_repo(
 4075            Path::new(&git_path),
 4076            false,
 4077            git::repository::Worktree {
 4078                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
 4079                ref_name: Some("refs/heads/olivetti".into()),
 4080                sha: "aaa".into(),
 4081                is_main: false,
 4082            },
 4083        )
 4084        .await;
 4085    }
 4086
 4087    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4088
 4089    let project = project::Project::test(
 4090        fs.clone(),
 4091        [
 4092            "/worktrees/project_a/olivetti/project_a".as_ref(),
 4093            "/worktrees/project_b/olivetti/project_b".as_ref(),
 4094        ],
 4095        cx,
 4096    )
 4097    .await;
 4098    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 4099
 4100    let (multi_workspace, cx) =
 4101        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 4102    let sidebar = setup_sidebar(&multi_workspace, cx);
 4103
 4104    // Thread with roots in both repos' "olivetti" worktrees.
 4105    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
 4106
 4107    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4108    cx.run_until_parked();
 4109
 4110    // Both worktree paths have the name "olivetti", so only one chip.
 4111    assert_eq!(
 4112        visible_entries_as_strings(&sidebar, cx),
 4113        vec![
 4114            //
 4115            "v [project_a, project_b]",
 4116            "  Same Branch Thread {olivetti}",
 4117        ]
 4118    );
 4119}
 4120
 4121#[gpui::test]
 4122async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
 4123    // When a worktree workspace is absorbed under the main repo, a
 4124    // running thread in the worktree's agent panel should still show
 4125    // live status (spinner + "(running)") in the sidebar.
 4126    agent_ui::test_support::init_test(cx);
 4127    cx.update(|cx| {
 4128        ThreadStore::init_global(cx);
 4129        ThreadMetadataStore::init_global(cx);
 4130        language_model::LanguageModelRegistry::test(cx);
 4131        prompt_store::init(cx);
 4132    });
 4133
 4134    let fs = FakeFs::new(cx.executor());
 4135
 4136    // Main repo with a linked worktree.
 4137    fs.insert_tree(
 4138        "/project",
 4139        serde_json::json!({
 4140            ".git": {},
 4141            "src": {},
 4142        }),
 4143    )
 4144    .await;
 4145
 4146    // Worktree checkout pointing back to the main repo.
 4147    fs.add_linked_worktree_for_repo(
 4148        Path::new("/project/.git"),
 4149        false,
 4150        git::repository::Worktree {
 4151            path: std::path::PathBuf::from("/wt-feature-a"),
 4152            ref_name: Some("refs/heads/feature-a".into()),
 4153            sha: "aaa".into(),
 4154            is_main: false,
 4155        },
 4156    )
 4157    .await;
 4158
 4159    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4160
 4161    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4162    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4163
 4164    main_project
 4165        .update(cx, |p, cx| p.git_scans_complete(cx))
 4166        .await;
 4167    worktree_project
 4168        .update(cx, |p, cx| p.git_scans_complete(cx))
 4169        .await;
 4170
 4171    // Create the MultiWorkspace with both projects.
 4172    let (multi_workspace, cx) =
 4173        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4174
 4175    let sidebar = setup_sidebar(&multi_workspace, cx);
 4176
 4177    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4178        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4179    });
 4180
 4181    // Add an agent panel to the worktree workspace so we can run a
 4182    // thread inside it.
 4183    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 4184
 4185    // Switch back to the main workspace before setting up the sidebar.
 4186    multi_workspace.update_in(cx, |mw, window, cx| {
 4187        let workspace = mw.workspaces().next().unwrap().clone();
 4188        mw.activate(workspace, window, cx);
 4189    });
 4190
 4191    // Start a thread in the worktree workspace's panel and keep it
 4192    // generating (don't resolve it).
 4193    let connection = StubAgentConnection::new();
 4194    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 4195    send_message(&worktree_panel, cx);
 4196
 4197    let session_id = active_session_id(&worktree_panel, cx);
 4198
 4199    // Save metadata so the sidebar knows about this thread.
 4200    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 4201
 4202    // Keep the thread generating by sending a chunk without ending
 4203    // the turn.
 4204    cx.update(|_, cx| {
 4205        connection.send_update(
 4206            session_id.clone(),
 4207            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 4208            cx,
 4209        );
 4210    });
 4211    cx.run_until_parked();
 4212
 4213    // The worktree thread should be absorbed under the main project
 4214    // and show live running status.
 4215    let entries = visible_entries_as_strings(&sidebar, cx);
 4216    assert_eq!(
 4217        entries,
 4218        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 4219    );
 4220}
 4221
 4222#[gpui::test]
 4223async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
 4224    agent_ui::test_support::init_test(cx);
 4225    cx.update(|cx| {
 4226        ThreadStore::init_global(cx);
 4227        ThreadMetadataStore::init_global(cx);
 4228        language_model::LanguageModelRegistry::test(cx);
 4229        prompt_store::init(cx);
 4230    });
 4231
 4232    let fs = FakeFs::new(cx.executor());
 4233
 4234    fs.insert_tree(
 4235        "/project",
 4236        serde_json::json!({
 4237            ".git": {},
 4238            "src": {},
 4239        }),
 4240    )
 4241    .await;
 4242
 4243    fs.add_linked_worktree_for_repo(
 4244        Path::new("/project/.git"),
 4245        false,
 4246        git::repository::Worktree {
 4247            path: std::path::PathBuf::from("/wt-feature-a"),
 4248            ref_name: Some("refs/heads/feature-a".into()),
 4249            sha: "aaa".into(),
 4250            is_main: false,
 4251        },
 4252    )
 4253    .await;
 4254
 4255    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4256
 4257    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4258    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4259
 4260    main_project
 4261        .update(cx, |p, cx| p.git_scans_complete(cx))
 4262        .await;
 4263    worktree_project
 4264        .update(cx, |p, cx| p.git_scans_complete(cx))
 4265        .await;
 4266
 4267    let (multi_workspace, cx) =
 4268        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4269
 4270    let sidebar = setup_sidebar(&multi_workspace, cx);
 4271
 4272    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4273        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4274    });
 4275
 4276    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 4277
 4278    multi_workspace.update_in(cx, |mw, window, cx| {
 4279        let workspace = mw.workspaces().next().unwrap().clone();
 4280        mw.activate(workspace, window, cx);
 4281    });
 4282
 4283    let connection = StubAgentConnection::new();
 4284    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 4285    send_message(&worktree_panel, cx);
 4286
 4287    let session_id = active_session_id(&worktree_panel, cx);
 4288    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 4289
 4290    cx.update(|_, cx| {
 4291        connection.send_update(
 4292            session_id.clone(),
 4293            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 4294            cx,
 4295        );
 4296    });
 4297    cx.run_until_parked();
 4298
 4299    assert_eq!(
 4300        visible_entries_as_strings(&sidebar, cx),
 4301        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 4302    );
 4303
 4304    connection.end_turn(session_id, acp::StopReason::EndTurn);
 4305    cx.run_until_parked();
 4306
 4307    assert_eq!(
 4308        visible_entries_as_strings(&sidebar, cx),
 4309        vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
 4310    );
 4311}
 4312
 4313#[gpui::test]
 4314async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
 4315    init_test(cx);
 4316    let fs = FakeFs::new(cx.executor());
 4317
 4318    fs.insert_tree(
 4319        "/project",
 4320        serde_json::json!({
 4321            ".git": {},
 4322            "src": {},
 4323        }),
 4324    )
 4325    .await;
 4326
 4327    fs.add_linked_worktree_for_repo(
 4328        Path::new("/project/.git"),
 4329        false,
 4330        git::repository::Worktree {
 4331            path: std::path::PathBuf::from("/wt-feature-a"),
 4332            ref_name: Some("refs/heads/feature-a".into()),
 4333            sha: "aaa".into(),
 4334            is_main: false,
 4335        },
 4336    )
 4337    .await;
 4338
 4339    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4340
 4341    // Only open the main repo — no workspace for the worktree.
 4342    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4343    main_project
 4344        .update(cx, |p, cx| p.git_scans_complete(cx))
 4345        .await;
 4346
 4347    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4348    worktree_project
 4349        .update(cx, |p, cx| p.git_scans_complete(cx))
 4350        .await;
 4351
 4352    let (multi_workspace, cx) =
 4353        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4354    let sidebar = setup_sidebar(&multi_workspace, cx);
 4355
 4356    // Save a thread for the worktree path (no workspace for it).
 4357    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4358
 4359    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4360    cx.run_until_parked();
 4361
 4362    // Thread should appear under the main repo with a worktree chip.
 4363    assert_eq!(
 4364        visible_entries_as_strings(&sidebar, cx),
 4365        vec![
 4366            //
 4367            "v [project]",
 4368            "  WT Thread {wt-feature-a}",
 4369        ],
 4370    );
 4371
 4372    // Only 1 workspace should exist.
 4373    assert_eq!(
 4374        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4375        1,
 4376    );
 4377
 4378    // Focus the sidebar and select the worktree thread.
 4379    focus_sidebar(&sidebar, cx);
 4380    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4381        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 4382    });
 4383
 4384    // Confirm to open the worktree thread.
 4385    cx.dispatch_action(Confirm);
 4386    cx.run_until_parked();
 4387
 4388    // A new workspace should have been created for the worktree path.
 4389    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
 4390        assert_eq!(
 4391            mw.workspaces().count(),
 4392            2,
 4393            "confirming a worktree thread without a workspace should open one",
 4394        );
 4395        mw.workspaces().nth(1).unwrap().clone()
 4396    });
 4397
 4398    let new_path_list =
 4399        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 4400    assert_eq!(
 4401        new_path_list,
 4402        PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
 4403        "the new workspace should have been opened for the worktree path",
 4404    );
 4405}
 4406
 4407#[gpui::test]
 4408async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
 4409    cx: &mut TestAppContext,
 4410) {
 4411    init_test(cx);
 4412    let fs = FakeFs::new(cx.executor());
 4413
 4414    fs.insert_tree(
 4415        "/project",
 4416        serde_json::json!({
 4417            ".git": {},
 4418            "src": {},
 4419        }),
 4420    )
 4421    .await;
 4422
 4423    fs.add_linked_worktree_for_repo(
 4424        Path::new("/project/.git"),
 4425        false,
 4426        git::repository::Worktree {
 4427            path: std::path::PathBuf::from("/wt-feature-a"),
 4428            ref_name: Some("refs/heads/feature-a".into()),
 4429            sha: "aaa".into(),
 4430            is_main: false,
 4431        },
 4432    )
 4433    .await;
 4434
 4435    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4436
 4437    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4438    main_project
 4439        .update(cx, |p, cx| p.git_scans_complete(cx))
 4440        .await;
 4441
 4442    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4443    worktree_project
 4444        .update(cx, |p, cx| p.git_scans_complete(cx))
 4445        .await;
 4446
 4447    let (multi_workspace, cx) =
 4448        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4449    let sidebar = setup_sidebar(&multi_workspace, cx);
 4450
 4451    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4452
 4453    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4454    cx.run_until_parked();
 4455
 4456    assert_eq!(
 4457        visible_entries_as_strings(&sidebar, cx),
 4458        vec![
 4459            //
 4460            "v [project]",
 4461            "  WT Thread {wt-feature-a}",
 4462        ],
 4463    );
 4464
 4465    focus_sidebar(&sidebar, cx);
 4466    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4467        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 4468    });
 4469
 4470    let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
 4471        let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
 4472            if let ListEntry::ProjectHeader { label, .. } = entry {
 4473                Some(label.as_ref())
 4474            } else {
 4475                None
 4476            }
 4477        });
 4478
 4479        let Some(project_header) = project_headers.next() else {
 4480            panic!("expected exactly one sidebar project header named `project`, found none");
 4481        };
 4482        assert_eq!(
 4483            project_header, "project",
 4484            "expected the only sidebar project header to be `project`"
 4485        );
 4486        if let Some(unexpected_header) = project_headers.next() {
 4487            panic!(
 4488                "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
 4489            );
 4490        }
 4491
 4492        let mut saw_expected_thread = false;
 4493        for entry in &sidebar.contents.entries {
 4494            match entry {
 4495                ListEntry::ProjectHeader { label, .. } => {
 4496                    assert_eq!(
 4497                        label.as_ref(),
 4498                        "project",
 4499                        "expected the only sidebar project header to be `project`"
 4500                    );
 4501                }
 4502                ListEntry::Thread(thread)
 4503                    if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
 4504                        && thread.worktrees.first().map(|wt| wt.name.as_ref())
 4505                            == Some("wt-feature-a") =>
 4506                {
 4507                    saw_expected_thread = true;
 4508                }
 4509                ListEntry::Thread(thread) if thread.is_draft => {}
 4510                ListEntry::Thread(thread) => {
 4511                    let title = thread.metadata.display_title();
 4512                    let worktree_name = thread
 4513                        .worktrees
 4514                        .first()
 4515                        .map(|wt| wt.name.as_ref())
 4516                        .unwrap_or("<none>");
 4517                    panic!(
 4518                        "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
 4519                        title, worktree_name
 4520                    );
 4521                }
 4522                ListEntry::ViewMore { .. } => {
 4523                    panic!("unexpected `View More` entry while opening linked worktree thread");
 4524                }
 4525            }
 4526        }
 4527
 4528        assert!(
 4529            saw_expected_thread,
 4530            "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
 4531        );
 4532    };
 4533
 4534    sidebar
 4535        .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
 4536        .detach();
 4537
 4538    let window = cx.windows()[0];
 4539    cx.update_window(window, |_, window, cx| {
 4540        window.dispatch_action(Confirm.boxed_clone(), cx);
 4541    })
 4542    .unwrap();
 4543
 4544    cx.run_until_parked();
 4545
 4546    sidebar.update(cx, assert_sidebar_state);
 4547}
 4548
 4549#[gpui::test]
 4550async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
 4551    cx: &mut TestAppContext,
 4552) {
 4553    init_test(cx);
 4554    let fs = FakeFs::new(cx.executor());
 4555
 4556    fs.insert_tree(
 4557        "/project",
 4558        serde_json::json!({
 4559            ".git": {},
 4560            "src": {},
 4561        }),
 4562    )
 4563    .await;
 4564
 4565    fs.add_linked_worktree_for_repo(
 4566        Path::new("/project/.git"),
 4567        false,
 4568        git::repository::Worktree {
 4569            path: std::path::PathBuf::from("/wt-feature-a"),
 4570            ref_name: Some("refs/heads/feature-a".into()),
 4571            sha: "aaa".into(),
 4572            is_main: false,
 4573        },
 4574    )
 4575    .await;
 4576
 4577    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4578
 4579    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4580    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4581
 4582    main_project
 4583        .update(cx, |p, cx| p.git_scans_complete(cx))
 4584        .await;
 4585    worktree_project
 4586        .update(cx, |p, cx| p.git_scans_complete(cx))
 4587        .await;
 4588
 4589    let (multi_workspace, cx) =
 4590        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4591
 4592    let sidebar = setup_sidebar(&multi_workspace, cx);
 4593
 4594    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4595        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4596    });
 4597
 4598    // Activate the main workspace before setting up the sidebar.
 4599    let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4600        let workspace = mw.workspaces().next().unwrap().clone();
 4601        mw.activate(workspace.clone(), window, cx);
 4602        workspace
 4603    });
 4604
 4605    save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
 4606    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4607
 4608    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4609    cx.run_until_parked();
 4610
 4611    // The worktree workspace should be absorbed under the main repo.
 4612    let entries = visible_entries_as_strings(&sidebar, cx);
 4613    assert_eq!(entries.len(), 3);
 4614    assert_eq!(entries[0], "v [project]");
 4615    assert!(entries.contains(&"  Main Thread".to_string()));
 4616    assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
 4617
 4618    let wt_thread_index = entries
 4619        .iter()
 4620        .position(|e| e.contains("WT Thread"))
 4621        .expect("should find the worktree thread entry");
 4622
 4623    assert_eq!(
 4624        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4625        main_workspace,
 4626        "main workspace should be active initially"
 4627    );
 4628
 4629    // Focus the sidebar and select the absorbed worktree thread.
 4630    focus_sidebar(&sidebar, cx);
 4631    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4632        sidebar.selection = Some(wt_thread_index);
 4633    });
 4634
 4635    // Confirm to activate the worktree thread.
 4636    cx.dispatch_action(Confirm);
 4637    cx.run_until_parked();
 4638
 4639    // The worktree workspace should now be active, not the main one.
 4640    let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 4641    assert_eq!(
 4642        active_workspace, worktree_workspace,
 4643        "clicking an absorbed worktree thread should activate the worktree workspace"
 4644    );
 4645}
 4646
 4647#[gpui::test]
 4648async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
 4649    cx: &mut TestAppContext,
 4650) {
 4651    // Thread has saved metadata in ThreadStore. A matching workspace is
 4652    // already open. Expected: activates the matching workspace.
 4653    init_test(cx);
 4654    let fs = FakeFs::new(cx.executor());
 4655    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4656        .await;
 4657    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4658        .await;
 4659    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4660
 4661    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4662    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4663
 4664    let (multi_workspace, cx) =
 4665        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 4666
 4667    let sidebar = setup_sidebar(&multi_workspace, cx);
 4668
 4669    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4670        mw.test_add_workspace(project_b.clone(), window, cx)
 4671    });
 4672    let workspace_a =
 4673        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4674
 4675    // Save a thread with path_list pointing to project-b.
 4676    let session_id = acp::SessionId::new(Arc::from("archived-1"));
 4677    save_test_thread_metadata(&session_id, &project_b, cx).await;
 4678
 4679    // Ensure workspace A is active.
 4680    multi_workspace.update_in(cx, |mw, window, cx| {
 4681        let workspace = mw.workspaces().next().unwrap().clone();
 4682        mw.activate(workspace, window, cx);
 4683    });
 4684    cx.run_until_parked();
 4685    assert_eq!(
 4686        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4687        workspace_a
 4688    );
 4689
 4690    // Call activate_archived_thread – should resolve saved paths and
 4691    // switch to the workspace for project-b.
 4692    sidebar.update_in(cx, |sidebar, window, cx| {
 4693        sidebar.activate_archived_thread(
 4694            ThreadMetadata {
 4695                thread_id: ThreadId::new(),
 4696                session_id: Some(session_id.clone()),
 4697                agent_id: agent::ZED_AGENT_ID.clone(),
 4698                title: Some("Archived Thread".into()),
 4699                updated_at: Utc::now(),
 4700                created_at: None,
 4701                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4702                    "/project-b",
 4703                )])),
 4704                archived: false,
 4705                remote_connection: None,
 4706            },
 4707            window,
 4708            cx,
 4709        );
 4710    });
 4711    cx.run_until_parked();
 4712
 4713    assert_eq!(
 4714        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4715        workspace_b,
 4716        "should have switched to the workspace matching the saved paths"
 4717    );
 4718}
 4719
 4720#[gpui::test]
 4721async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
 4722    cx: &mut TestAppContext,
 4723) {
 4724    // Thread has no saved metadata but session_info has cwd. A matching
 4725    // workspace is open. Expected: uses cwd to find and activate it.
 4726    init_test(cx);
 4727    let fs = FakeFs::new(cx.executor());
 4728    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4729        .await;
 4730    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4731        .await;
 4732    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4733
 4734    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4735    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4736
 4737    let (multi_workspace, cx) =
 4738        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4739
 4740    let sidebar = setup_sidebar(&multi_workspace, cx);
 4741
 4742    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4743        mw.test_add_workspace(project_b, window, cx)
 4744    });
 4745    let workspace_a =
 4746        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4747
 4748    // Start with workspace A active.
 4749    multi_workspace.update_in(cx, |mw, window, cx| {
 4750        let workspace = mw.workspaces().next().unwrap().clone();
 4751        mw.activate(workspace, window, cx);
 4752    });
 4753    cx.run_until_parked();
 4754    assert_eq!(
 4755        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4756        workspace_a
 4757    );
 4758
 4759    // No thread saved to the store – cwd is the only path hint.
 4760    sidebar.update_in(cx, |sidebar, window, cx| {
 4761        sidebar.activate_archived_thread(
 4762            ThreadMetadata {
 4763                thread_id: ThreadId::new(),
 4764                session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
 4765                agent_id: agent::ZED_AGENT_ID.clone(),
 4766                title: Some("CWD Thread".into()),
 4767                updated_at: Utc::now(),
 4768                created_at: None,
 4769                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 4770                    std::path::PathBuf::from("/project-b"),
 4771                ])),
 4772                archived: false,
 4773                remote_connection: None,
 4774            },
 4775            window,
 4776            cx,
 4777        );
 4778    });
 4779    cx.run_until_parked();
 4780
 4781    assert_eq!(
 4782        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4783        workspace_b,
 4784        "should have activated the workspace matching the cwd"
 4785    );
 4786}
 4787
 4788#[gpui::test]
 4789async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
 4790    cx: &mut TestAppContext,
 4791) {
 4792    // Thread has no saved metadata and no cwd. Expected: falls back to
 4793    // the currently active workspace.
 4794    init_test(cx);
 4795    let fs = FakeFs::new(cx.executor());
 4796    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4797        .await;
 4798    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4799        .await;
 4800    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4801
 4802    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4803    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4804
 4805    let (multi_workspace, cx) =
 4806        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4807
 4808    let sidebar = setup_sidebar(&multi_workspace, cx);
 4809
 4810    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4811        mw.test_add_workspace(project_b, window, cx)
 4812    });
 4813
 4814    // Activate workspace B (index 1) to make it the active one.
 4815    multi_workspace.update_in(cx, |mw, window, cx| {
 4816        let workspace = mw.workspaces().nth(1).unwrap().clone();
 4817        mw.activate(workspace, window, cx);
 4818    });
 4819    cx.run_until_parked();
 4820    assert_eq!(
 4821        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4822        workspace_b
 4823    );
 4824
 4825    // No saved thread, no cwd – should fall back to the active workspace.
 4826    sidebar.update_in(cx, |sidebar, window, cx| {
 4827        sidebar.activate_archived_thread(
 4828            ThreadMetadata {
 4829                thread_id: ThreadId::new(),
 4830                session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
 4831                agent_id: agent::ZED_AGENT_ID.clone(),
 4832                title: Some("Contextless Thread".into()),
 4833                updated_at: Utc::now(),
 4834                created_at: None,
 4835                worktree_paths: WorktreePaths::default(),
 4836                archived: false,
 4837                remote_connection: None,
 4838            },
 4839            window,
 4840            cx,
 4841        );
 4842    });
 4843    cx.run_until_parked();
 4844
 4845    assert_eq!(
 4846        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4847        workspace_b,
 4848        "should have stayed on the active workspace when no path info is available"
 4849    );
 4850}
 4851
 4852#[gpui::test]
 4853async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
 4854    // Thread has saved metadata pointing to a path with no open workspace.
 4855    // Expected: opens a new workspace for that path.
 4856    init_test(cx);
 4857    let fs = FakeFs::new(cx.executor());
 4858    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4859        .await;
 4860    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4861        .await;
 4862    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4863
 4864    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4865
 4866    let (multi_workspace, cx) =
 4867        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4868
 4869    let sidebar = setup_sidebar(&multi_workspace, cx);
 4870
 4871    // Save a thread with path_list pointing to project-b – which has no
 4872    // open workspace.
 4873    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 4874    let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
 4875
 4876    assert_eq!(
 4877        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4878        1,
 4879        "should start with one workspace"
 4880    );
 4881
 4882    sidebar.update_in(cx, |sidebar, window, cx| {
 4883        sidebar.activate_archived_thread(
 4884            ThreadMetadata {
 4885                thread_id: ThreadId::new(),
 4886                session_id: Some(session_id.clone()),
 4887                agent_id: agent::ZED_AGENT_ID.clone(),
 4888                title: Some("New WS Thread".into()),
 4889                updated_at: Utc::now(),
 4890                created_at: None,
 4891                worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 4892                archived: false,
 4893                remote_connection: None,
 4894            },
 4895            window,
 4896            cx,
 4897        );
 4898    });
 4899    cx.run_until_parked();
 4900
 4901    assert_eq!(
 4902        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4903        2,
 4904        "should have opened a second workspace for the archived thread's saved paths"
 4905    );
 4906}
 4907
 4908#[gpui::test]
 4909async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
 4910    init_test(cx);
 4911    let fs = FakeFs::new(cx.executor());
 4912    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4913        .await;
 4914    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4915        .await;
 4916    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4917
 4918    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4919    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4920
 4921    let multi_workspace_a =
 4922        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4923    let multi_workspace_b =
 4924        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4925
 4926    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4927    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4928
 4929    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4930    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4931
 4932    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4933    let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4934
 4935    let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
 4936
 4937    sidebar.update_in(cx_a, |sidebar, window, cx| {
 4938        sidebar.activate_archived_thread(
 4939            ThreadMetadata {
 4940                thread_id: ThreadId::new(),
 4941                session_id: Some(session_id.clone()),
 4942                agent_id: agent::ZED_AGENT_ID.clone(),
 4943                title: Some("Cross Window Thread".into()),
 4944                updated_at: Utc::now(),
 4945                created_at: None,
 4946                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4947                    "/project-b",
 4948                )])),
 4949                archived: false,
 4950                remote_connection: None,
 4951            },
 4952            window,
 4953            cx,
 4954        );
 4955    });
 4956    cx_a.run_until_parked();
 4957
 4958    assert_eq!(
 4959        multi_workspace_a
 4960            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4961            .unwrap(),
 4962        1,
 4963        "should not add the other window's workspace into the current window"
 4964    );
 4965    assert_eq!(
 4966        multi_workspace_b
 4967            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4968            .unwrap(),
 4969        1,
 4970        "should reuse the existing workspace in the other window"
 4971    );
 4972    assert!(
 4973        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4974        "should activate the window that already owns the matching workspace"
 4975    );
 4976    sidebar.read_with(cx_a, |sidebar, _| {
 4977            assert!(
 4978                !is_active_session(&sidebar, &session_id),
 4979                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4980            );
 4981        });
 4982}
 4983
 4984#[gpui::test]
 4985async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
 4986    cx: &mut TestAppContext,
 4987) {
 4988    init_test(cx);
 4989    let fs = FakeFs::new(cx.executor());
 4990    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4991        .await;
 4992    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4993        .await;
 4994    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4995
 4996    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4997    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4998
 4999    let multi_workspace_a =
 5000        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 5001    let multi_workspace_b =
 5002        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
 5003
 5004    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 5005    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 5006
 5007    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 5008    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 5009
 5010    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 5011    let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 5012    let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
 5013    let _panel_b = add_agent_panel(&workspace_b, cx_b);
 5014
 5015    let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
 5016
 5017    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 5018        sidebar.activate_archived_thread(
 5019            ThreadMetadata {
 5020                thread_id: ThreadId::new(),
 5021                session_id: Some(session_id.clone()),
 5022                agent_id: agent::ZED_AGENT_ID.clone(),
 5023                title: Some("Cross Window Thread".into()),
 5024                updated_at: Utc::now(),
 5025                created_at: None,
 5026                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 5027                    "/project-b",
 5028                )])),
 5029                archived: false,
 5030                remote_connection: None,
 5031            },
 5032            window,
 5033            cx,
 5034        );
 5035    });
 5036    cx_a.run_until_parked();
 5037
 5038    assert_eq!(
 5039        multi_workspace_a
 5040            .read_with(cx_a, |mw, _| mw.workspaces().count())
 5041            .unwrap(),
 5042        1,
 5043        "should not add the other window's workspace into the current window"
 5044    );
 5045    assert_eq!(
 5046        multi_workspace_b
 5047            .read_with(cx_a, |mw, _| mw.workspaces().count())
 5048            .unwrap(),
 5049        1,
 5050        "should reuse the existing workspace in the other window"
 5051    );
 5052    assert!(
 5053        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 5054        "should activate the window that already owns the matching workspace"
 5055    );
 5056    sidebar_a.read_with(cx_a, |sidebar, _| {
 5057            assert!(
 5058                !is_active_session(&sidebar, &session_id),
 5059                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 5060            );
 5061        });
 5062    sidebar_b.read_with(cx_b, |sidebar, _| {
 5063        assert_active_thread(
 5064            sidebar,
 5065            &session_id,
 5066            "target window's sidebar should eagerly focus the activated archived thread",
 5067        );
 5068    });
 5069}
 5070
 5071#[gpui::test]
 5072async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
 5073    cx: &mut TestAppContext,
 5074) {
 5075    init_test(cx);
 5076    let fs = FakeFs::new(cx.executor());
 5077    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 5078        .await;
 5079    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5080
 5081    let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 5082    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 5083
 5084    let multi_workspace_b =
 5085        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 5086    let multi_workspace_a =
 5087        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 5088
 5089    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 5090    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 5091
 5092    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 5093    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 5094
 5095    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 5096    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 5097
 5098    let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
 5099
 5100    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 5101        sidebar.activate_archived_thread(
 5102            ThreadMetadata {
 5103                thread_id: ThreadId::new(),
 5104                session_id: Some(session_id.clone()),
 5105                agent_id: agent::ZED_AGENT_ID.clone(),
 5106                title: Some("Current Window Thread".into()),
 5107                updated_at: Utc::now(),
 5108                created_at: None,
 5109                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 5110                    "/project-a",
 5111                )])),
 5112                archived: false,
 5113                remote_connection: None,
 5114            },
 5115            window,
 5116            cx,
 5117        );
 5118    });
 5119    cx_a.run_until_parked();
 5120
 5121    assert!(
 5122        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
 5123        "should keep activation in the current window when it already has a matching workspace"
 5124    );
 5125    sidebar_a.read_with(cx_a, |sidebar, _| {
 5126        assert_active_thread(
 5127            sidebar,
 5128            &session_id,
 5129            "current window's sidebar should eagerly focus the activated archived thread",
 5130        );
 5131    });
 5132    assert_eq!(
 5133        multi_workspace_a
 5134            .read_with(cx_a, |mw, _| mw.workspaces().count())
 5135            .unwrap(),
 5136        1,
 5137        "current window should continue reusing its existing workspace"
 5138    );
 5139    assert_eq!(
 5140        multi_workspace_b
 5141            .read_with(cx_a, |mw, _| mw.workspaces().count())
 5142            .unwrap(),
 5143        1,
 5144        "other windows should not be activated just because they also match the saved paths"
 5145    );
 5146}
 5147
 5148#[gpui::test]
 5149async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
 5150    // Regression test: archive_thread previously always loaded the next thread
 5151    // through group_workspace (the main workspace's ProjectHeader), even when
 5152    // the next thread belonged to an absorbed linked-worktree workspace. That
 5153    // caused the worktree thread to be loaded in the main panel, which bound it
 5154    // to the main project and corrupted its stored folder_paths.
 5155    //
 5156    // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
 5157    // falling back to group_workspace only for Closed workspaces.
 5158    agent_ui::test_support::init_test(cx);
 5159    cx.update(|cx| {
 5160        ThreadStore::init_global(cx);
 5161        ThreadMetadataStore::init_global(cx);
 5162        language_model::LanguageModelRegistry::test(cx);
 5163        prompt_store::init(cx);
 5164    });
 5165
 5166    let fs = FakeFs::new(cx.executor());
 5167
 5168    fs.insert_tree(
 5169        "/project",
 5170        serde_json::json!({
 5171            ".git": {},
 5172            "src": {},
 5173        }),
 5174    )
 5175    .await;
 5176
 5177    fs.add_linked_worktree_for_repo(
 5178        Path::new("/project/.git"),
 5179        false,
 5180        git::repository::Worktree {
 5181            path: std::path::PathBuf::from("/wt-feature-a"),
 5182            ref_name: Some("refs/heads/feature-a".into()),
 5183            sha: "aaa".into(),
 5184            is_main: false,
 5185        },
 5186    )
 5187    .await;
 5188
 5189    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5190
 5191    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5192    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5193
 5194    main_project
 5195        .update(cx, |p, cx| p.git_scans_complete(cx))
 5196        .await;
 5197    worktree_project
 5198        .update(cx, |p, cx| p.git_scans_complete(cx))
 5199        .await;
 5200
 5201    let (multi_workspace, cx) =
 5202        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5203
 5204    let sidebar = setup_sidebar(&multi_workspace, cx);
 5205
 5206    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5207        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5208    });
 5209
 5210    // Activate main workspace so the sidebar tracks the main panel.
 5211    multi_workspace.update_in(cx, |mw, window, cx| {
 5212        let workspace = mw.workspaces().next().unwrap().clone();
 5213        mw.activate(workspace, window, cx);
 5214    });
 5215
 5216    let main_workspace =
 5217        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 5218    let main_panel = add_agent_panel(&main_workspace, cx);
 5219    let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
 5220
 5221    // Open Thread 2 in the main panel and keep it running.
 5222    let connection = StubAgentConnection::new();
 5223    open_thread_with_connection(&main_panel, connection.clone(), cx);
 5224    send_message(&main_panel, cx);
 5225
 5226    let thread2_session_id = active_session_id(&main_panel, cx);
 5227
 5228    cx.update(|_, cx| {
 5229        connection.send_update(
 5230            thread2_session_id.clone(),
 5231            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 5232            cx,
 5233        );
 5234    });
 5235
 5236    // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
 5237    save_thread_metadata(
 5238        thread2_session_id.clone(),
 5239        Some("Thread 2".into()),
 5240        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5241        None,
 5242        &main_project,
 5243        cx,
 5244    );
 5245
 5246    // Save thread 1's metadata with the worktree path and an older timestamp so
 5247    // it sorts below thread 2. archive_thread will find it as the "next" candidate.
 5248    let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
 5249    save_thread_metadata(
 5250        thread1_session_id,
 5251        Some("Thread 1".into()),
 5252        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5253        None,
 5254        &worktree_project,
 5255        cx,
 5256    );
 5257
 5258    cx.run_until_parked();
 5259
 5260    // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
 5261    let entries_before = visible_entries_as_strings(&sidebar, cx);
 5262    assert!(
 5263        entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
 5264        "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
 5265        entries_before
 5266    );
 5267
 5268    // The sidebar should track T2 as the focused thread (derived from the
 5269    // main panel's active view).
 5270    sidebar.read_with(cx, |s, _| {
 5271        assert_active_thread(
 5272            s,
 5273            &thread2_session_id,
 5274            "focused thread should be Thread 2 before archiving",
 5275        );
 5276    });
 5277
 5278    // Archive thread 2.
 5279    sidebar.update_in(cx, |sidebar, window, cx| {
 5280        sidebar.archive_thread(&thread2_session_id, window, cx);
 5281    });
 5282
 5283    cx.run_until_parked();
 5284
 5285    // The main panel's active thread must still be thread 2.
 5286    let main_active = main_panel.read_with(cx, |panel, cx| {
 5287        panel
 5288            .active_agent_thread(cx)
 5289            .map(|t| t.read(cx).session_id().clone())
 5290    });
 5291    assert_eq!(
 5292        main_active,
 5293        Some(thread2_session_id.clone()),
 5294        "main panel should not have been taken over by loading the linked-worktree thread T1; \
 5295             before the fix, archive_thread used group_workspace instead of next.workspace, \
 5296             causing T1 to be loaded in the wrong panel"
 5297    );
 5298
 5299    // Thread 1 should still appear in the sidebar with its worktree chip
 5300    // (Thread 2 was archived so it is gone from the list).
 5301    let entries_after = visible_entries_as_strings(&sidebar, cx);
 5302    assert!(
 5303        entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
 5304        "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
 5305        entries_after
 5306    );
 5307}
 5308
 5309#[gpui::test]
 5310async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
 5311    // When the last non-archived thread for a linked worktree is archived,
 5312    // the linked worktree workspace should be removed from the multi-workspace.
 5313    // The main worktree workspace should remain (it's always reachable via
 5314    // the project header).
 5315    init_test(cx);
 5316    let fs = FakeFs::new(cx.executor());
 5317
 5318    fs.insert_tree(
 5319        "/project",
 5320        serde_json::json!({
 5321            ".git": {
 5322                "worktrees": {
 5323                    "feature-a": {
 5324                        "commondir": "../../",
 5325                        "HEAD": "ref: refs/heads/feature-a",
 5326                    },
 5327                },
 5328            },
 5329            "src": {},
 5330        }),
 5331    )
 5332    .await;
 5333
 5334    fs.insert_tree(
 5335        "/wt-feature-a",
 5336        serde_json::json!({
 5337            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5338            "src": {},
 5339        }),
 5340    )
 5341    .await;
 5342
 5343    fs.add_linked_worktree_for_repo(
 5344        Path::new("/project/.git"),
 5345        false,
 5346        git::repository::Worktree {
 5347            path: PathBuf::from("/wt-feature-a"),
 5348            ref_name: Some("refs/heads/feature-a".into()),
 5349            sha: "abc".into(),
 5350            is_main: false,
 5351        },
 5352    )
 5353    .await;
 5354
 5355    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5356
 5357    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5358    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5359
 5360    main_project
 5361        .update(cx, |p, cx| p.git_scans_complete(cx))
 5362        .await;
 5363    worktree_project
 5364        .update(cx, |p, cx| p.git_scans_complete(cx))
 5365        .await;
 5366
 5367    let (multi_workspace, cx) =
 5368        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5369    let sidebar = setup_sidebar(&multi_workspace, cx);
 5370
 5371    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5372        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5373    });
 5374
 5375    // Save a thread for the main project.
 5376    save_thread_metadata(
 5377        acp::SessionId::new(Arc::from("main-thread")),
 5378        Some("Main Thread".into()),
 5379        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5380        None,
 5381        &main_project,
 5382        cx,
 5383    );
 5384
 5385    // Save a thread for the linked worktree.
 5386    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 5387    save_thread_metadata(
 5388        wt_thread_id.clone(),
 5389        Some("Worktree Thread".into()),
 5390        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5391        None,
 5392        &worktree_project,
 5393        cx,
 5394    );
 5395    cx.run_until_parked();
 5396
 5397    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5398    cx.run_until_parked();
 5399
 5400    // Should have 2 workspaces.
 5401    assert_eq!(
 5402        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5403        2,
 5404        "should start with 2 workspaces (main + linked worktree)"
 5405    );
 5406
 5407    // Archive the worktree thread (the only thread for /wt-feature-a).
 5408    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 5409        sidebar.archive_thread(&wt_thread_id, window, cx);
 5410    });
 5411
 5412    // archive_thread spawns a multi-layered chain of tasks (workspace
 5413    // removal → git persist → disk removal), each of which may spawn
 5414    // further background work. Each run_until_parked() call drives one
 5415    // layer of pending work.
 5416    cx.run_until_parked();
 5417    cx.run_until_parked();
 5418    cx.run_until_parked();
 5419
 5420    // The linked worktree workspace should have been removed.
 5421    assert_eq!(
 5422        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5423        1,
 5424        "linked worktree workspace should be removed after archiving its last thread"
 5425    );
 5426
 5427    // The linked worktree checkout directory should also be removed from disk.
 5428    assert!(
 5429        !fs.is_dir(Path::new("/wt-feature-a")).await,
 5430        "linked worktree directory should be removed from disk after archiving its last thread"
 5431    );
 5432
 5433    // The main thread should still be visible.
 5434    let entries = visible_entries_as_strings(&sidebar, cx);
 5435    assert!(
 5436        entries.iter().any(|e| e.contains("Main Thread")),
 5437        "main thread should still be visible: {entries:?}"
 5438    );
 5439    assert!(
 5440        !entries.iter().any(|e| e.contains("Worktree Thread")),
 5441        "archived worktree thread should not be visible: {entries:?}"
 5442    );
 5443}
 5444
 5445#[gpui::test]
 5446async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
 5447    // When a multi-root workspace (e.g. [/other, /project]) shares a
 5448    // repo with a single-root workspace (e.g. [/project]), linked
 5449    // worktree threads from the shared repo should only appear under
 5450    // the dedicated group [project], not under [other, project].
 5451    agent_ui::test_support::init_test(cx);
 5452    cx.update(|cx| {
 5453        ThreadStore::init_global(cx);
 5454        ThreadMetadataStore::init_global(cx);
 5455        language_model::LanguageModelRegistry::test(cx);
 5456        prompt_store::init(cx);
 5457    });
 5458    let fs = FakeFs::new(cx.executor());
 5459
 5460    // Two independent repos, each with their own git history.
 5461    fs.insert_tree(
 5462        "/project",
 5463        serde_json::json!({
 5464            ".git": {},
 5465            "src": {},
 5466        }),
 5467    )
 5468    .await;
 5469    fs.insert_tree(
 5470        "/other",
 5471        serde_json::json!({
 5472            ".git": {},
 5473            "src": {},
 5474        }),
 5475    )
 5476    .await;
 5477
 5478    // Register the linked worktree in the main repo.
 5479    fs.add_linked_worktree_for_repo(
 5480        Path::new("/project/.git"),
 5481        false,
 5482        git::repository::Worktree {
 5483            path: std::path::PathBuf::from("/wt-feature-a"),
 5484            ref_name: Some("refs/heads/feature-a".into()),
 5485            sha: "aaa".into(),
 5486            is_main: false,
 5487        },
 5488    )
 5489    .await;
 5490
 5491    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5492
 5493    // Workspace 1: just /project.
 5494    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5495    project_only
 5496        .update(cx, |p, cx| p.git_scans_complete(cx))
 5497        .await;
 5498
 5499    // Workspace 2: /other and /project together (multi-root).
 5500    let multi_root =
 5501        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
 5502    multi_root
 5503        .update(cx, |p, cx| p.git_scans_complete(cx))
 5504        .await;
 5505
 5506    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5507    worktree_project
 5508        .update(cx, |p, cx| p.git_scans_complete(cx))
 5509        .await;
 5510
 5511    // Save a thread under the linked worktree path BEFORE setting up
 5512    // the sidebar and panels, so that reconciliation sees the [project]
 5513    // group as non-empty and doesn't create a spurious draft there.
 5514    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
 5515    save_thread_metadata(
 5516        wt_session_id,
 5517        Some("Worktree Thread".into()),
 5518        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5519        None,
 5520        &worktree_project,
 5521        cx,
 5522    );
 5523
 5524    let (multi_workspace, cx) =
 5525        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
 5526    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5527    let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5528        mw.test_add_workspace(multi_root.clone(), window, cx)
 5529    });
 5530    add_agent_panel(&multi_root_workspace, cx);
 5531    cx.run_until_parked();
 5532
 5533    // The thread should appear only under [project] (the dedicated
 5534    // group for the /project repo), not under [other, project].
 5535    assert_eq!(
 5536        visible_entries_as_strings(&sidebar, cx),
 5537        vec![
 5538            //
 5539            "v [other, project]",
 5540            "  [~ Draft]",
 5541            "v [project]",
 5542            "  Worktree Thread {wt-feature-a}",
 5543        ]
 5544    );
 5545}
 5546
 5547#[gpui::test]
 5548async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
 5549    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 5550    let (multi_workspace, cx) =
 5551        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 5552    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5553
 5554    let switcher_ids =
 5555        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
 5556            sidebar.read_with(cx, |sidebar, cx| {
 5557                let switcher = sidebar
 5558                    .thread_switcher
 5559                    .as_ref()
 5560                    .expect("switcher should be open");
 5561                switcher
 5562                    .read(cx)
 5563                    .entries()
 5564                    .iter()
 5565                    .map(|e| e.session_id.clone())
 5566                    .collect()
 5567            })
 5568        };
 5569
 5570    let switcher_selected_id =
 5571        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
 5572            sidebar.read_with(cx, |sidebar, cx| {
 5573                let switcher = sidebar
 5574                    .thread_switcher
 5575                    .as_ref()
 5576                    .expect("switcher should be open");
 5577                let s = switcher.read(cx);
 5578                s.selected_entry()
 5579                    .expect("should have selection")
 5580                    .session_id
 5581                    .clone()
 5582            })
 5583        };
 5584
 5585    // ── Setup: create three threads with distinct created_at times ──────
 5586    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
 5587    // We send messages in each so they also get last_message_sent_or_queued timestamps.
 5588    let connection_c = StubAgentConnection::new();
 5589    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5590        acp::ContentChunk::new("Done C".into()),
 5591    )]);
 5592    open_thread_with_connection(&panel, connection_c, cx);
 5593    send_message(&panel, cx);
 5594    let session_id_c = active_session_id(&panel, cx);
 5595    save_thread_metadata(
 5596        session_id_c.clone(),
 5597        Some("Thread C".into()),
 5598        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5599        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
 5600        &project,
 5601        cx,
 5602    );
 5603
 5604    let connection_b = StubAgentConnection::new();
 5605    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5606        acp::ContentChunk::new("Done B".into()),
 5607    )]);
 5608    open_thread_with_connection(&panel, connection_b, cx);
 5609    send_message(&panel, cx);
 5610    let session_id_b = active_session_id(&panel, cx);
 5611    save_thread_metadata(
 5612        session_id_b.clone(),
 5613        Some("Thread B".into()),
 5614        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5615        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
 5616        &project,
 5617        cx,
 5618    );
 5619
 5620    let connection_a = StubAgentConnection::new();
 5621    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5622        acp::ContentChunk::new("Done A".into()),
 5623    )]);
 5624    open_thread_with_connection(&panel, connection_a, cx);
 5625    send_message(&panel, cx);
 5626    let session_id_a = active_session_id(&panel, cx);
 5627    save_thread_metadata(
 5628        session_id_a.clone(),
 5629        Some("Thread A".into()),
 5630        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
 5631        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
 5632        &project,
 5633        cx,
 5634    );
 5635
 5636    // All three threads are now live. Thread A was opened last, so it's
 5637    // the one being viewed. Opening each thread called record_thread_access,
 5638    // so all three have last_accessed_at set.
 5639    // Access order is: A (most recent), B, C (oldest).
 5640
 5641    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
 5642    focus_sidebar(&sidebar, cx);
 5643    sidebar.update_in(cx, |sidebar, window, cx| {
 5644        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5645    });
 5646    cx.run_until_parked();
 5647
 5648    // All three have last_accessed_at, so they sort by access time.
 5649    // A was accessed most recently (it's the currently viewed thread),
 5650    // then B, then C.
 5651    assert_eq!(
 5652        switcher_ids(&sidebar, cx),
 5653        vec![
 5654            session_id_a.clone(),
 5655            session_id_b.clone(),
 5656            session_id_c.clone()
 5657        ],
 5658    );
 5659    // First ctrl-tab selects the second entry (B).
 5660    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
 5661
 5662    // Dismiss the switcher without confirming.
 5663    sidebar.update_in(cx, |sidebar, _window, cx| {
 5664        sidebar.dismiss_thread_switcher(cx);
 5665    });
 5666    cx.run_until_parked();
 5667
 5668    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
 5669    sidebar.update_in(cx, |sidebar, window, cx| {
 5670        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5671    });
 5672    cx.run_until_parked();
 5673
 5674    // Cycle twice to land on Thread C (index 2).
 5675    sidebar.read_with(cx, |sidebar, cx| {
 5676        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5677        assert_eq!(switcher.read(cx).selected_index(), 1);
 5678    });
 5679    sidebar.update_in(cx, |sidebar, _window, cx| {
 5680        sidebar
 5681            .thread_switcher
 5682            .as_ref()
 5683            .unwrap()
 5684            .update(cx, |s, cx| s.cycle_selection(cx));
 5685    });
 5686    cx.run_until_parked();
 5687    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
 5688
 5689    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
 5690
 5691    // Confirm on Thread C.
 5692    sidebar.update_in(cx, |sidebar, window, cx| {
 5693        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5694        let focus = switcher.focus_handle(cx);
 5695        focus.dispatch_action(&menu::Confirm, window, cx);
 5696    });
 5697    cx.run_until_parked();
 5698
 5699    // Switcher should be dismissed after confirm.
 5700    sidebar.read_with(cx, |sidebar, _cx| {
 5701        assert!(
 5702            sidebar.thread_switcher.is_none(),
 5703            "switcher should be dismissed"
 5704        );
 5705    });
 5706
 5707    sidebar.update(cx, |sidebar, _cx| {
 5708        let last_accessed = sidebar
 5709            .thread_last_accessed
 5710            .keys()
 5711            .cloned()
 5712            .collect::<Vec<_>>();
 5713        assert_eq!(last_accessed.len(), 1);
 5714        assert!(last_accessed.contains(&session_id_c));
 5715        assert!(
 5716            is_active_session(&sidebar, &session_id_c),
 5717            "active_entry should be Thread({session_id_c:?})"
 5718        );
 5719    });
 5720
 5721    sidebar.update_in(cx, |sidebar, window, cx| {
 5722        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5723    });
 5724    cx.run_until_parked();
 5725
 5726    assert_eq!(
 5727        switcher_ids(&sidebar, cx),
 5728        vec![
 5729            session_id_c.clone(),
 5730            session_id_a.clone(),
 5731            session_id_b.clone()
 5732        ],
 5733    );
 5734
 5735    // Confirm on Thread A.
 5736    sidebar.update_in(cx, |sidebar, window, cx| {
 5737        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5738        let focus = switcher.focus_handle(cx);
 5739        focus.dispatch_action(&menu::Confirm, window, cx);
 5740    });
 5741    cx.run_until_parked();
 5742
 5743    sidebar.update(cx, |sidebar, _cx| {
 5744        let last_accessed = sidebar
 5745            .thread_last_accessed
 5746            .keys()
 5747            .cloned()
 5748            .collect::<Vec<_>>();
 5749        assert_eq!(last_accessed.len(), 2);
 5750        assert!(last_accessed.contains(&session_id_c));
 5751        assert!(last_accessed.contains(&session_id_a));
 5752        assert!(
 5753            is_active_session(&sidebar, &session_id_a),
 5754            "active_entry should be Thread({session_id_a:?})"
 5755        );
 5756    });
 5757
 5758    sidebar.update_in(cx, |sidebar, window, cx| {
 5759        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5760    });
 5761    cx.run_until_parked();
 5762
 5763    assert_eq!(
 5764        switcher_ids(&sidebar, cx),
 5765        vec![
 5766            session_id_a.clone(),
 5767            session_id_c.clone(),
 5768            session_id_b.clone(),
 5769        ],
 5770    );
 5771
 5772    sidebar.update_in(cx, |sidebar, _window, cx| {
 5773        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5774        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
 5775    });
 5776    cx.run_until_parked();
 5777
 5778    // Confirm on Thread B.
 5779    sidebar.update_in(cx, |sidebar, window, cx| {
 5780        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5781        let focus = switcher.focus_handle(cx);
 5782        focus.dispatch_action(&menu::Confirm, window, cx);
 5783    });
 5784    cx.run_until_parked();
 5785
 5786    sidebar.update(cx, |sidebar, _cx| {
 5787        let last_accessed = sidebar
 5788            .thread_last_accessed
 5789            .keys()
 5790            .cloned()
 5791            .collect::<Vec<_>>();
 5792        assert_eq!(last_accessed.len(), 3);
 5793        assert!(last_accessed.contains(&session_id_c));
 5794        assert!(last_accessed.contains(&session_id_a));
 5795        assert!(last_accessed.contains(&session_id_b));
 5796        assert!(
 5797            is_active_session(&sidebar, &session_id_b),
 5798            "active_entry should be Thread({session_id_b:?})"
 5799        );
 5800    });
 5801
 5802    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
 5803    // This thread was never opened in a panel — it only exists in metadata.
 5804    save_thread_metadata(
 5805        acp::SessionId::new(Arc::from("thread-historical")),
 5806        Some("Historical Thread".into()),
 5807        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 5808        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
 5809        &project,
 5810        cx,
 5811    );
 5812
 5813    sidebar.update_in(cx, |sidebar, window, cx| {
 5814        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5815    });
 5816    cx.run_until_parked();
 5817
 5818    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
 5819    // so it falls to tier 3 (sorted by created_at). It should appear after all
 5820    // accessed threads, even though its created_at (June 2024) is much later
 5821    // than the others.
 5822    //
 5823    // But the live threads (A, B, C) each had send_message called which sets
 5824    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
 5825    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
 5826    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
 5827
 5828    let ids = switcher_ids(&sidebar, cx);
 5829    assert_eq!(
 5830        ids,
 5831        vec![
 5832            session_id_b.clone(),
 5833            session_id_a.clone(),
 5834            session_id_c.clone(),
 5835            session_id_hist.clone()
 5836        ],
 5837    );
 5838
 5839    sidebar.update_in(cx, |sidebar, _window, cx| {
 5840        sidebar.dismiss_thread_switcher(cx);
 5841    });
 5842    cx.run_until_parked();
 5843
 5844    // ── 4. Add another historical thread with older created_at ─────────
 5845    save_thread_metadata(
 5846        acp::SessionId::new(Arc::from("thread-old-historical")),
 5847        Some("Old Historical Thread".into()),
 5848        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
 5849        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
 5850        &project,
 5851        cx,
 5852    );
 5853
 5854    sidebar.update_in(cx, |sidebar, window, cx| {
 5855        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5856    });
 5857    cx.run_until_parked();
 5858
 5859    // Both historical threads have no access or message times. They should
 5860    // appear after accessed threads, sorted by created_at (newest first).
 5861    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
 5862    let ids = switcher_ids(&sidebar, cx);
 5863    assert_eq!(
 5864        ids,
 5865        vec![
 5866            session_id_b,
 5867            session_id_a,
 5868            session_id_c,
 5869            session_id_hist,
 5870            session_id_old_hist,
 5871        ],
 5872    );
 5873
 5874    sidebar.update_in(cx, |sidebar, _window, cx| {
 5875        sidebar.dismiss_thread_switcher(cx);
 5876    });
 5877    cx.run_until_parked();
 5878}
 5879
 5880#[gpui::test]
 5881async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
 5882    let project = init_test_project("/my-project", cx).await;
 5883    let (multi_workspace, cx) =
 5884        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 5885    let sidebar = setup_sidebar(&multi_workspace, cx);
 5886
 5887    save_thread_metadata(
 5888        acp::SessionId::new(Arc::from("thread-to-archive")),
 5889        Some("Thread To Archive".into()),
 5890        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5891        None,
 5892        &project,
 5893        cx,
 5894    );
 5895    cx.run_until_parked();
 5896
 5897    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5898    cx.run_until_parked();
 5899
 5900    let entries = visible_entries_as_strings(&sidebar, cx);
 5901    assert!(
 5902        entries.iter().any(|e| e.contains("Thread To Archive")),
 5903        "expected thread to be visible before archiving, got: {entries:?}"
 5904    );
 5905
 5906    sidebar.update_in(cx, |sidebar, window, cx| {
 5907        sidebar.archive_thread(
 5908            &acp::SessionId::new(Arc::from("thread-to-archive")),
 5909            window,
 5910            cx,
 5911        );
 5912    });
 5913    cx.run_until_parked();
 5914
 5915    let entries = visible_entries_as_strings(&sidebar, cx);
 5916    assert!(
 5917        !entries.iter().any(|e| e.contains("Thread To Archive")),
 5918        "expected thread to be hidden after archiving, got: {entries:?}"
 5919    );
 5920
 5921    cx.update(|_, cx| {
 5922        let store = ThreadMetadataStore::global(cx);
 5923        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 5924        assert_eq!(archived.len(), 1);
 5925        assert_eq!(
 5926            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 5927            "thread-to-archive"
 5928        );
 5929        assert!(archived[0].archived);
 5930    });
 5931}
 5932
 5933#[gpui::test]
 5934async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
 5935    // Tests two archive scenarios:
 5936    // 1. Archiving a thread in a non-active workspace leaves active_entry
 5937    //    as the current draft.
 5938    // 2. Archiving the thread the user is looking at falls back to a draft
 5939    //    on the same workspace.
 5940    agent_ui::test_support::init_test(cx);
 5941    cx.update(|cx| {
 5942        ThreadStore::init_global(cx);
 5943        ThreadMetadataStore::init_global(cx);
 5944        language_model::LanguageModelRegistry::test(cx);
 5945        prompt_store::init(cx);
 5946    });
 5947
 5948    let fs = FakeFs::new(cx.executor());
 5949    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 5950        .await;
 5951    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 5952        .await;
 5953    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5954
 5955    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 5956    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 5957
 5958    let (multi_workspace, cx) =
 5959        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 5960    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5961
 5962    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 5963        mw.test_add_workspace(project_b.clone(), window, cx)
 5964    });
 5965    let panel_b = add_agent_panel(&workspace_b, cx);
 5966    cx.run_until_parked();
 5967
 5968    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 5969    sidebar.update_in(cx, |sidebar, window, cx| {
 5970        sidebar.create_new_thread(&workspace_b, window, cx);
 5971    });
 5972    cx.run_until_parked();
 5973
 5974    // --- Scenario 1: archive a thread in the non-active workspace ---
 5975
 5976    // Create a thread in project-a (non-active — project-b is active).
 5977    let connection = acp_thread::StubAgentConnection::new();
 5978    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5979        acp::ContentChunk::new("Done".into()),
 5980    )]);
 5981    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 5982    agent_ui::test_support::send_message(&panel_a, cx);
 5983    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 5984    cx.run_until_parked();
 5985
 5986    sidebar.update_in(cx, |sidebar, window, cx| {
 5987        sidebar.archive_thread(&thread_a, window, cx);
 5988    });
 5989    cx.run_until_parked();
 5990
 5991    // active_entry should still be a draft on workspace_b (the active one).
 5992    sidebar.read_with(cx, |sidebar, _| {
 5993        assert!(
 5994            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
 5995            "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
 5996            sidebar.active_entry,
 5997        );
 5998    });
 5999
 6000    // --- Scenario 2: archive the thread the user is looking at ---
 6001
 6002    // Create a thread in project-b (the active workspace) and verify it
 6003    // becomes the active entry.
 6004    let connection = acp_thread::StubAgentConnection::new();
 6005    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6006        acp::ContentChunk::new("Done".into()),
 6007    )]);
 6008    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6009    agent_ui::test_support::send_message(&panel_b, cx);
 6010    let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
 6011    cx.run_until_parked();
 6012
 6013    sidebar.read_with(cx, |sidebar, _| {
 6014        assert!(
 6015            is_active_session(&sidebar, &thread_b),
 6016            "expected active_entry to be Thread({thread_b}), got: {:?}",
 6017            sidebar.active_entry,
 6018        );
 6019    });
 6020
 6021    sidebar.update_in(cx, |sidebar, window, cx| {
 6022        sidebar.archive_thread(&thread_b, window, cx);
 6023    });
 6024    cx.run_until_parked();
 6025
 6026    // Should fall back to a draft on the same workspace.
 6027    sidebar.read_with(cx, |sidebar, _| {
 6028        assert!(
 6029            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
 6030            "expected Draft(workspace_b) after archiving active thread, got: {:?}",
 6031            sidebar.active_entry,
 6032        );
 6033    });
 6034}
 6035
 6036#[gpui::test]
 6037async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
 6038    // Full flow: create a thread, archive it (removing the workspace),
 6039    // then unarchive. Only the restored thread should appear — no
 6040    // leftover drafts or previously-serialized threads.
 6041    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 6042    let (multi_workspace, cx) =
 6043        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6044    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6045    cx.run_until_parked();
 6046
 6047    // Create a thread and send a message so it's a real thread.
 6048    let connection = acp_thread::StubAgentConnection::new();
 6049    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6050        acp::ContentChunk::new("Hello".into()),
 6051    )]);
 6052    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6053    agent_ui::test_support::send_message(&panel, cx);
 6054    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6055    cx.run_until_parked();
 6056
 6057    // Archive it.
 6058    sidebar.update_in(cx, |sidebar, window, cx| {
 6059        sidebar.archive_thread(&session_id, window, cx);
 6060    });
 6061    cx.run_until_parked();
 6062
 6063    // Grab metadata for unarchive.
 6064    let thread_id = cx.update(|_, cx| {
 6065        ThreadMetadataStore::global(cx)
 6066            .read(cx)
 6067            .entries()
 6068            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6069            .map(|e| e.thread_id)
 6070            .expect("thread should exist")
 6071    });
 6072    let metadata = cx.update(|_, cx| {
 6073        ThreadMetadataStore::global(cx)
 6074            .read(cx)
 6075            .entry(thread_id)
 6076            .cloned()
 6077            .expect("metadata should exist")
 6078    });
 6079
 6080    // Unarchive it — the draft should be replaced by the restored thread.
 6081    sidebar.update_in(cx, |sidebar, window, cx| {
 6082        sidebar.activate_archived_thread(metadata, window, cx);
 6083    });
 6084    cx.run_until_parked();
 6085
 6086    // Only the unarchived thread should be visible — no drafts, no other threads.
 6087    let entries = visible_entries_as_strings(&sidebar, cx);
 6088    let thread_count = entries
 6089        .iter()
 6090        .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
 6091        .count();
 6092    assert_eq!(
 6093        thread_count, 1,
 6094        "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
 6095    );
 6096    assert!(
 6097        !entries.iter().any(|e| e.contains("Draft")),
 6098        "expected no drafts after restoring, got entries: {entries:?}"
 6099    );
 6100}
 6101
 6102#[gpui::test]
 6103async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
 6104    cx: &mut TestAppContext,
 6105) {
 6106    // When a thread is unarchived into a project group that has no open
 6107    // workspace, the sidebar opens a new workspace and loads the thread.
 6108    // No spurious draft should appear alongside the unarchived thread.
 6109    agent_ui::test_support::init_test(cx);
 6110    cx.update(|cx| {
 6111        ThreadStore::init_global(cx);
 6112        ThreadMetadataStore::init_global(cx);
 6113        language_model::LanguageModelRegistry::test(cx);
 6114        prompt_store::init(cx);
 6115    });
 6116
 6117    let fs = FakeFs::new(cx.executor());
 6118    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6119        .await;
 6120    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6121        .await;
 6122    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6123
 6124    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6125    let (multi_workspace, cx) =
 6126        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6127    let sidebar = setup_sidebar(&multi_workspace, cx);
 6128    cx.run_until_parked();
 6129
 6130    // Save an archived thread whose folder_paths point to project-b,
 6131    // which has no open workspace.
 6132    let session_id = acp::SessionId::new(Arc::from("archived-thread"));
 6133    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 6134    let thread_id = ThreadId::new();
 6135    cx.update(|_, cx| {
 6136        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6137            store.save(
 6138                ThreadMetadata {
 6139                    thread_id,
 6140                    session_id: Some(session_id.clone()),
 6141                    agent_id: agent::ZED_AGENT_ID.clone(),
 6142                    title: Some("Unarchived Thread".into()),
 6143                    updated_at: Utc::now(),
 6144                    created_at: None,
 6145                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6146                    archived: true,
 6147                    remote_connection: None,
 6148                },
 6149                cx,
 6150            )
 6151        });
 6152    });
 6153    cx.run_until_parked();
 6154
 6155    // Verify no workspace for project-b exists yet.
 6156    assert_eq!(
 6157        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6158        1,
 6159        "should start with only the project-a workspace"
 6160    );
 6161
 6162    // Un-archive the thread — should open project-b workspace and load it.
 6163    let metadata = cx.update(|_, cx| {
 6164        ThreadMetadataStore::global(cx)
 6165            .read(cx)
 6166            .entry(thread_id)
 6167            .cloned()
 6168            .expect("metadata should exist")
 6169    });
 6170
 6171    sidebar.update_in(cx, |sidebar, window, cx| {
 6172        sidebar.activate_archived_thread(metadata, window, cx);
 6173    });
 6174    cx.run_until_parked();
 6175
 6176    // A second workspace should have been created for project-b.
 6177    assert_eq!(
 6178        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6179        2,
 6180        "should have opened a workspace for the unarchived thread"
 6181    );
 6182
 6183    // The sidebar should show the unarchived thread without a spurious draft
 6184    // in the project-b group.
 6185    let entries = visible_entries_as_strings(&sidebar, cx);
 6186    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6187    // project-a gets a draft (it's the active workspace with no threads),
 6188    // but project-b should NOT have one — only the unarchived thread.
 6189    assert!(
 6190        draft_count <= 1,
 6191        "expected at most one draft (for project-a), got entries: {entries:?}"
 6192    );
 6193    assert!(
 6194        entries.iter().any(|e| e.contains("Unarchived Thread")),
 6195        "expected unarchived thread to appear, got entries: {entries:?}"
 6196    );
 6197}
 6198
 6199#[gpui::test]
 6200async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
 6201    cx: &mut TestAppContext,
 6202) {
 6203    agent_ui::test_support::init_test(cx);
 6204    cx.update(|cx| {
 6205        ThreadStore::init_global(cx);
 6206        ThreadMetadataStore::init_global(cx);
 6207        language_model::LanguageModelRegistry::test(cx);
 6208        prompt_store::init(cx);
 6209    });
 6210
 6211    let fs = FakeFs::new(cx.executor());
 6212    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6213        .await;
 6214    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6215        .await;
 6216    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6217
 6218    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6219    let (multi_workspace, cx) =
 6220        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6221    let sidebar = setup_sidebar(&multi_workspace, cx);
 6222    cx.run_until_parked();
 6223
 6224    let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
 6225    let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
 6226    let original_thread_id = ThreadId::new();
 6227    cx.update(|_, cx| {
 6228        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6229            store.save(
 6230                ThreadMetadata {
 6231                    thread_id: original_thread_id,
 6232                    session_id: Some(session_id.clone()),
 6233                    agent_id: agent::ZED_AGENT_ID.clone(),
 6234                    title: Some("Unarchived Thread".into()),
 6235                    updated_at: Utc::now(),
 6236                    created_at: None,
 6237                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6238                    archived: true,
 6239                    remote_connection: None,
 6240                },
 6241                cx,
 6242            )
 6243        });
 6244    });
 6245    cx.run_until_parked();
 6246
 6247    let metadata = cx.update(|_, cx| {
 6248        ThreadMetadataStore::global(cx)
 6249            .read(cx)
 6250            .entry(original_thread_id)
 6251            .cloned()
 6252            .expect("metadata should exist before unarchive")
 6253    });
 6254
 6255    sidebar.update_in(cx, |sidebar, window, cx| {
 6256        sidebar.activate_archived_thread(metadata, window, cx);
 6257    });
 6258    cx.run_until_parked();
 6259    cx.run_until_parked();
 6260    cx.run_until_parked();
 6261
 6262    assert_eq!(
 6263        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6264        2,
 6265        "expected unarchive to open the target workspace"
 6266    );
 6267
 6268    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6269        mw.workspaces()
 6270            .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
 6271            .cloned()
 6272            .expect("expected restored workspace for unarchived thread")
 6273    });
 6274    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6275        workspace
 6276            .panel::<AgentPanel>(cx)
 6277            .expect("expected unarchive to install an agent panel in the new workspace")
 6278    });
 6279
 6280    let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6281    assert_eq!(
 6282        restored_thread_id,
 6283        Some(original_thread_id),
 6284        "expected the new workspace's agent panel to target the restored archived thread id"
 6285    );
 6286
 6287    let session_entries = cx.update(|_, cx| {
 6288        ThreadMetadataStore::global(cx)
 6289            .read(cx)
 6290            .entries()
 6291            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6292            .cloned()
 6293            .collect::<Vec<_>>()
 6294    });
 6295    assert_eq!(
 6296        session_entries.len(),
 6297        1,
 6298        "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
 6299    );
 6300    assert_eq!(
 6301        session_entries[0].thread_id, original_thread_id,
 6302        "expected restore into a new workspace to reuse the original thread id"
 6303    );
 6304    assert!(
 6305        !session_entries[0].archived,
 6306        "expected restored thread metadata to be unarchived, got: {:?}",
 6307        session_entries[0]
 6308    );
 6309
 6310    let mapped_thread_id = cx.update(|_, cx| {
 6311        ThreadMetadataStore::global(cx)
 6312            .read(cx)
 6313            .entries()
 6314            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6315            .map(|e| e.thread_id)
 6316    });
 6317    assert_eq!(
 6318        mapped_thread_id,
 6319        Some(original_thread_id),
 6320        "expected session mapping to remain stable after opening the new workspace"
 6321    );
 6322
 6323    let entries = visible_entries_as_strings(&sidebar, cx);
 6324    let real_thread_rows = entries
 6325        .iter()
 6326        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6327        .filter(|entry| !entry.contains("Draft"))
 6328        .count();
 6329    assert_eq!(
 6330        real_thread_rows, 1,
 6331        "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
 6332    );
 6333    assert!(
 6334        entries
 6335            .iter()
 6336            .any(|entry| entry.contains("Unarchived Thread")),
 6337        "expected restored thread row to be visible, got entries: {entries:?}"
 6338    );
 6339}
 6340
 6341#[gpui::test]
 6342async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
 6343    // When a workspace already exists with an empty draft (from
 6344    // reconcile_groups) and a thread is unarchived into it, the draft
 6345    // should be replaced — not kept alongside the loaded thread.
 6346    agent_ui::test_support::init_test(cx);
 6347    cx.update(|cx| {
 6348        ThreadStore::init_global(cx);
 6349        ThreadMetadataStore::init_global(cx);
 6350        language_model::LanguageModelRegistry::test(cx);
 6351        prompt_store::init(cx);
 6352    });
 6353
 6354    let fs = FakeFs::new(cx.executor());
 6355    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6356        .await;
 6357    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6358
 6359    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6360    let (multi_workspace, cx) =
 6361        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6362    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6363    cx.run_until_parked();
 6364
 6365    // Create a thread and send a message so it's no longer a draft.
 6366    let connection = acp_thread::StubAgentConnection::new();
 6367    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6368        acp::ContentChunk::new("Done".into()),
 6369    )]);
 6370    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6371    agent_ui::test_support::send_message(&panel, cx);
 6372    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6373    cx.run_until_parked();
 6374
 6375    // Archive the thread — this creates a draft to replace it.
 6376    sidebar.update_in(cx, |sidebar, window, cx| {
 6377        sidebar.archive_thread(&session_id, window, cx);
 6378    });
 6379    cx.run_until_parked();
 6380
 6381    // Verify the draft exists before unarchive.
 6382    let entries = visible_entries_as_strings(&sidebar, cx);
 6383    assert!(
 6384        entries.iter().any(|e| e.contains("Draft")),
 6385        "expected a draft after archiving, got: {entries:?}"
 6386    );
 6387
 6388    // Un-archive the thread.
 6389    let thread_id = cx.update(|_, cx| {
 6390        ThreadMetadataStore::global(cx)
 6391            .read(cx)
 6392            .entries()
 6393            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6394            .map(|e| e.thread_id)
 6395            .expect("thread should exist in store")
 6396    });
 6397    let metadata = cx.update(|_, cx| {
 6398        ThreadMetadataStore::global(cx)
 6399            .read(cx)
 6400            .entry(thread_id)
 6401            .cloned()
 6402            .expect("metadata should exist")
 6403    });
 6404
 6405    sidebar.update_in(cx, |sidebar, window, cx| {
 6406        sidebar.activate_archived_thread(metadata, window, cx);
 6407    });
 6408    cx.run_until_parked();
 6409
 6410    // The draft should be gone — only the unarchived thread remains.
 6411    let entries = visible_entries_as_strings(&sidebar, cx);
 6412    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6413    assert_eq!(
 6414        draft_count, 0,
 6415        "expected no drafts after unarchiving, got entries: {entries:?}"
 6416    );
 6417}
 6418
 6419#[gpui::test]
 6420async fn test_pending_thread_activation_suppresses_reconcile_draft_creation(
 6421    cx: &mut TestAppContext,
 6422) {
 6423    agent_ui::test_support::init_test(cx);
 6424    cx.update(|cx| {
 6425        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6426        ThreadStore::init_global(cx);
 6427        ThreadMetadataStore::init_global(cx);
 6428        language_model::LanguageModelRegistry::test(cx);
 6429        prompt_store::init(cx);
 6430    });
 6431
 6432    let fs = FakeFs::new(cx.executor());
 6433    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6434        .await;
 6435    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6436        .await;
 6437    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6438
 6439    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6440    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6441
 6442    let (multi_workspace, cx) =
 6443        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6444    let sidebar = setup_sidebar(&multi_workspace, cx);
 6445
 6446    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6447        mw.test_add_workspace(project_b.clone(), window, cx)
 6448    });
 6449    let panel_b = add_agent_panel(&workspace_b, cx);
 6450    cx.run_until_parked();
 6451
 6452    let preexisting_empty_draft_ids = panel_b.read_with(cx, |panel, cx| {
 6453        panel
 6454            .draft_thread_ids(cx)
 6455            .into_iter()
 6456            .filter(|id| panel.editor_text(*id, cx).is_none())
 6457            .collect::<Vec<_>>()
 6458    });
 6459    if !preexisting_empty_draft_ids.is_empty() {
 6460        panel_b.update(cx, |panel, cx| {
 6461            for draft_id in &preexisting_empty_draft_ids {
 6462                panel.remove_thread(*draft_id, cx);
 6463            }
 6464        });
 6465        cx.run_until_parked();
 6466    }
 6467
 6468    let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
 6469
 6470    sidebar.update_in(cx, |sidebar, window, cx| {
 6471        assert!(
 6472            panel_b.read(cx).draft_thread_ids(cx).is_empty(),
 6473            "expected target panel to start without drafts after clearing setup state"
 6474        );
 6475
 6476        sidebar.pending_thread_activation = Some(ThreadId::new());
 6477        sidebar.reconcile_groups(window, cx);
 6478
 6479        assert!(
 6480            panel_b.read(cx).draft_thread_ids(cx).is_empty(),
 6481            "expected pending_thread_activation to suppress reconcile-driven fallback draft creation"
 6482        );
 6483
 6484        sidebar.pending_thread_activation = None;
 6485        sidebar.update_entries(cx);
 6486        sidebar.reconcile_groups(window, cx);
 6487
 6488        let created_draft_ids = panel_b.read(cx).draft_thread_ids(cx);
 6489        assert_eq!(
 6490            created_draft_ids.len(),
 6491            1,
 6492            "expected reconcile_groups to create a fallback draft again once the activation guard is cleared for the empty group {project_b_key:?}"
 6493        );
 6494        assert!(
 6495            panel_b.read(cx).editor_text(created_draft_ids[0], cx).is_none(),
 6496            "expected the reconciled draft to be empty"
 6497        );
 6498    });
 6499}
 6500
 6501#[gpui::test]
 6502async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
 6503    cx: &mut TestAppContext,
 6504) {
 6505    agent_ui::test_support::init_test(cx);
 6506    cx.update(|cx| {
 6507        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6508        ThreadStore::init_global(cx);
 6509        ThreadMetadataStore::init_global(cx);
 6510        language_model::LanguageModelRegistry::test(cx);
 6511        prompt_store::init(cx);
 6512    });
 6513
 6514    let fs = FakeFs::new(cx.executor());
 6515    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6516        .await;
 6517    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6518        .await;
 6519    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6520
 6521    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6522    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6523
 6524    let (multi_workspace, cx) =
 6525        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6526    let sidebar = setup_sidebar(&multi_workspace, cx);
 6527
 6528    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 6529    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6530        mw.test_add_workspace(project_b.clone(), window, cx)
 6531    });
 6532    let _panel_b = add_agent_panel(&workspace_b, cx);
 6533    cx.run_until_parked();
 6534
 6535    multi_workspace.update_in(cx, |mw, window, cx| {
 6536        mw.activate(workspace_a.clone(), window, cx);
 6537    });
 6538    cx.run_until_parked();
 6539
 6540    let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
 6541    let thread_id = ThreadId::new();
 6542    cx.update(|_, cx| {
 6543        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6544            store.save(
 6545                ThreadMetadata {
 6546                    thread_id,
 6547                    session_id: Some(session_id.clone()),
 6548                    agent_id: agent::ZED_AGENT_ID.clone(),
 6549                    title: Some("Restored In Inactive Workspace".into()),
 6550                    updated_at: Utc::now(),
 6551                    created_at: None,
 6552                    worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 6553                        PathBuf::from("/project-b"),
 6554                    ])),
 6555                    archived: true,
 6556                    remote_connection: None,
 6557                },
 6558                cx,
 6559            )
 6560        });
 6561    });
 6562    cx.run_until_parked();
 6563
 6564    let metadata = cx.update(|_, cx| {
 6565        ThreadMetadataStore::global(cx)
 6566            .read(cx)
 6567            .entry(thread_id)
 6568            .cloned()
 6569            .expect("archived metadata should exist before restore")
 6570    });
 6571
 6572    sidebar.update_in(cx, |sidebar, window, cx| {
 6573        sidebar.activate_archived_thread(metadata, window, cx);
 6574    });
 6575
 6576    let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
 6577        workspace.panel::<AgentPanel>(cx).expect(
 6578            "target workspace should still have an agent panel immediately after activation",
 6579        )
 6580    });
 6581    let immediate_active_thread_id =
 6582        panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6583    let immediate_draft_ids =
 6584        panel_b_before_settle.read_with(cx, |panel, cx| panel.draft_thread_ids(cx));
 6585
 6586    cx.run_until_parked();
 6587    cx.run_until_parked();
 6588    cx.run_until_parked();
 6589
 6590    sidebar.read_with(cx, |sidebar, _cx| {
 6591        assert_active_thread(
 6592            sidebar,
 6593            &session_id,
 6594            "unarchiving into an inactive existing workspace should end on the restored thread",
 6595        );
 6596    });
 6597
 6598    let panel_b = workspace_b.read_with(cx, |workspace, cx| {
 6599        workspace
 6600            .panel::<AgentPanel>(cx)
 6601            .expect("target workspace should still have an agent panel")
 6602    });
 6603    assert_eq!(
 6604        panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6605        Some(thread_id),
 6606        "expected target panel to activate the restored thread id"
 6607    );
 6608    assert!(
 6609        immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
 6610        "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}, draft_ids={immediate_draft_ids:?}"
 6611    );
 6612
 6613    let entries = visible_entries_as_strings(&sidebar, cx);
 6614    let target_rows: Vec<_> = entries
 6615        .iter()
 6616        .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
 6617        .cloned()
 6618        .collect();
 6619    assert_eq!(
 6620        target_rows.len(),
 6621        1,
 6622        "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
 6623    );
 6624    assert!(
 6625        target_rows[0].contains("Restored In Inactive Workspace"),
 6626        "expected the remaining row to be the restored thread, got entries: {entries:?}"
 6627    );
 6628    assert!(
 6629        !target_rows[0].contains("Draft"),
 6630        "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
 6631    );
 6632}
 6633
 6634#[gpui::test]
 6635async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
 6636    cx: &mut TestAppContext,
 6637) {
 6638    agent_ui::test_support::init_test(cx);
 6639    cx.update(|cx| {
 6640        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6641        ThreadStore::init_global(cx);
 6642        ThreadMetadataStore::init_global(cx);
 6643        language_model::LanguageModelRegistry::test(cx);
 6644        prompt_store::init(cx);
 6645    });
 6646
 6647    let fs = FakeFs::new(cx.executor());
 6648    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6649        .await;
 6650    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6651        .await;
 6652    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6653
 6654    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6655    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6656
 6657    let (multi_workspace, cx) =
 6658        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6659    let sidebar = setup_sidebar(&multi_workspace, cx);
 6660
 6661    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6662        mw.test_add_workspace(project_b.clone(), window, cx)
 6663    });
 6664    let panel_b = add_agent_panel(&workspace_b, cx);
 6665    cx.run_until_parked();
 6666
 6667    let connection = acp_thread::StubAgentConnection::new();
 6668    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6669        acp::ContentChunk::new("Done".into()),
 6670    )]);
 6671    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6672    agent_ui::test_support::send_message(&panel_b, cx);
 6673    let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
 6674    save_test_thread_metadata(&session_id, &project_b, cx).await;
 6675    cx.run_until_parked();
 6676
 6677    sidebar.update_in(cx, |sidebar, window, cx| {
 6678        sidebar.archive_thread(&session_id, window, cx);
 6679    });
 6680    cx.run_until_parked();
 6681    cx.run_until_parked();
 6682    cx.run_until_parked();
 6683
 6684    let archived_metadata = cx.update(|_, cx| {
 6685        let store = ThreadMetadataStore::global(cx).read(cx);
 6686        let thread_id = store
 6687            .entries()
 6688            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6689            .map(|e| e.thread_id)
 6690            .expect("archived thread should still exist in metadata store");
 6691        let metadata = store
 6692            .entry(thread_id)
 6693            .cloned()
 6694            .expect("archived metadata should still exist after archive");
 6695        assert!(
 6696            metadata.archived,
 6697            "thread should be archived before project removal"
 6698        );
 6699        metadata
 6700    });
 6701
 6702    let group_key_b =
 6703        project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
 6704    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 6705        mw.remove_project_group(&group_key_b, window, cx)
 6706    });
 6707    remove_task
 6708        .await
 6709        .expect("remove project group task should complete");
 6710    cx.run_until_parked();
 6711    cx.run_until_parked();
 6712
 6713    assert_eq!(
 6714        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6715        1,
 6716        "removing the archived thread's parent project group should remove its workspace"
 6717    );
 6718
 6719    sidebar.update_in(cx, |sidebar, window, cx| {
 6720        sidebar.activate_archived_thread(archived_metadata.clone(), window, cx);
 6721    });
 6722    cx.run_until_parked();
 6723    cx.run_until_parked();
 6724    cx.run_until_parked();
 6725
 6726    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6727        mw.workspaces()
 6728            .find(|workspace| {
 6729                PathList::new(&workspace.read(cx).root_paths(cx))
 6730                    == PathList::new(&[PathBuf::from("/project-b")])
 6731            })
 6732            .cloned()
 6733            .expect("expected unarchive to recreate the removed project workspace")
 6734    });
 6735    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6736        workspace
 6737            .panel::<AgentPanel>(cx)
 6738            .expect("expected restored workspace to bootstrap an agent panel")
 6739    });
 6740
 6741    let restored_thread_id = cx.update(|_, cx| {
 6742        ThreadMetadataStore::global(cx)
 6743            .read(cx)
 6744            .entries()
 6745            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6746            .map(|e| e.thread_id)
 6747            .expect("session should still map to restored thread id")
 6748    });
 6749    assert_eq!(
 6750        restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6751        Some(restored_thread_id),
 6752        "expected unarchive after project removal to activate the restored real thread"
 6753    );
 6754
 6755    sidebar.read_with(cx, |sidebar, _cx| {
 6756        assert_active_thread(
 6757            sidebar,
 6758            &session_id,
 6759            "expected sidebar active entry to track the restored thread after project removal",
 6760        );
 6761    });
 6762
 6763    let entries = visible_entries_as_strings(&sidebar, cx);
 6764    let restored_title = archived_metadata.display_title().to_string();
 6765    let matching_rows: Vec<_> = entries
 6766        .iter()
 6767        .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
 6768        .cloned()
 6769        .collect();
 6770    assert_eq!(
 6771        matching_rows.len(),
 6772        1,
 6773        "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
 6774    );
 6775    assert!(
 6776        !matching_rows[0].contains("Draft"),
 6777        "expected no draft row after unarchive following project removal, got entries: {entries:?}"
 6778    );
 6779}
 6780
 6781#[gpui::test]
 6782async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
 6783    agent_ui::test_support::init_test(cx);
 6784    cx.update(|cx| {
 6785        ThreadStore::init_global(cx);
 6786        ThreadMetadataStore::init_global(cx);
 6787        language_model::LanguageModelRegistry::test(cx);
 6788        prompt_store::init(cx);
 6789    });
 6790
 6791    let fs = FakeFs::new(cx.executor());
 6792    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6793        .await;
 6794    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6795
 6796    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6797    let (multi_workspace, cx) =
 6798        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6799    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6800    cx.run_until_parked();
 6801
 6802    let connection = acp_thread::StubAgentConnection::new();
 6803    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6804        acp::ContentChunk::new("Done".into()),
 6805    )]);
 6806    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6807    agent_ui::test_support::send_message(&panel, cx);
 6808    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6809    cx.run_until_parked();
 6810
 6811    let original_thread_id = cx.update(|_, cx| {
 6812        ThreadMetadataStore::global(cx)
 6813            .read(cx)
 6814            .entries()
 6815            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6816            .map(|e| e.thread_id)
 6817            .expect("thread should exist in store before archiving")
 6818    });
 6819
 6820    sidebar.update_in(cx, |sidebar, window, cx| {
 6821        sidebar.archive_thread(&session_id, window, cx);
 6822    });
 6823    cx.run_until_parked();
 6824
 6825    let metadata = cx.update(|_, cx| {
 6826        ThreadMetadataStore::global(cx)
 6827            .read(cx)
 6828            .entry(original_thread_id)
 6829            .cloned()
 6830            .expect("metadata should exist after archiving")
 6831    });
 6832
 6833    sidebar.update_in(cx, |sidebar, window, cx| {
 6834        sidebar.activate_archived_thread(metadata, window, cx);
 6835    });
 6836    cx.run_until_parked();
 6837
 6838    let session_entries = cx.update(|_, cx| {
 6839        ThreadMetadataStore::global(cx)
 6840            .read(cx)
 6841            .entries()
 6842            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6843            .cloned()
 6844            .collect::<Vec<_>>()
 6845    });
 6846
 6847    assert_eq!(
 6848        session_entries.len(),
 6849        1,
 6850        "expected exactly one metadata row for the restored session, got: {session_entries:?}"
 6851    );
 6852    assert_eq!(
 6853        session_entries[0].thread_id, original_thread_id,
 6854        "expected unarchive to reuse the original thread id instead of creating a duplicate row"
 6855    );
 6856    assert!(
 6857        !session_entries[0].is_draft(),
 6858        "expected restored metadata to be a real thread, got: {:?}",
 6859        session_entries[0]
 6860    );
 6861
 6862    let entries = visible_entries_as_strings(&sidebar, cx);
 6863    let real_thread_rows = entries
 6864        .iter()
 6865        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6866        .filter(|entry| !entry.contains("Draft"))
 6867        .count();
 6868    assert_eq!(
 6869        real_thread_rows, 1,
 6870        "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
 6871    );
 6872    assert!(
 6873        !entries.iter().any(|entry| entry.contains("Draft")),
 6874        "expected no draft rows after restoring, got entries: {entries:?}"
 6875    );
 6876}
 6877
 6878#[gpui::test]
 6879async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) {
 6880    // When a thread is archived while the user is in a different workspace,
 6881    // the archiving code replaces the thread with a tracked draft in its
 6882    // panel. Switching back to that workspace should show the draft.
 6883    agent_ui::test_support::init_test(cx);
 6884    cx.update(|cx| {
 6885        ThreadStore::init_global(cx);
 6886        ThreadMetadataStore::init_global(cx);
 6887        language_model::LanguageModelRegistry::test(cx);
 6888        prompt_store::init(cx);
 6889    });
 6890
 6891    let fs = FakeFs::new(cx.executor());
 6892    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6893        .await;
 6894    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6895        .await;
 6896    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6897
 6898    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6899    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6900
 6901    let (multi_workspace, cx) =
 6902        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6903    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6904
 6905    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6906        mw.test_add_workspace(project_b.clone(), window, cx)
 6907    });
 6908    let _panel_b = add_agent_panel(&workspace_b, cx);
 6909    cx.run_until_parked();
 6910
 6911    // Create a thread in project-a's panel (currently non-active).
 6912    let connection = acp_thread::StubAgentConnection::new();
 6913    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6914        acp::ContentChunk::new("Done".into()),
 6915    )]);
 6916    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 6917    agent_ui::test_support::send_message(&panel_a, cx);
 6918    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 6919    cx.run_until_parked();
 6920
 6921    // Archive it while project-b is active.
 6922    sidebar.update_in(cx, |sidebar, window, cx| {
 6923        sidebar.archive_thread(&thread_a, window, cx);
 6924    });
 6925    cx.run_until_parked();
 6926
 6927    // Switch back to project-a. Its panel was cleared during archiving,
 6928    // so active_entry should be Draft.
 6929    let workspace_a =
 6930        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 6931    multi_workspace.update_in(cx, |mw, window, cx| {
 6932        mw.activate(workspace_a.clone(), window, cx);
 6933    });
 6934    cx.run_until_parked();
 6935
 6936    sidebar.update_in(cx, |sidebar, _window, cx| {
 6937        sidebar.update_entries(cx);
 6938    });
 6939    cx.run_until_parked();
 6940
 6941    sidebar.read_with(cx, |sidebar, _| {
 6942        assert!(
 6943            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_a),
 6944            "expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}",
 6945            sidebar.active_entry,
 6946        );
 6947    });
 6948}
 6949
 6950#[gpui::test]
 6951async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
 6952    let project = init_test_project("/my-project", cx).await;
 6953    let (multi_workspace, cx) =
 6954        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6955    let sidebar = setup_sidebar(&multi_workspace, cx);
 6956
 6957    save_thread_metadata(
 6958        acp::SessionId::new(Arc::from("visible-thread")),
 6959        Some("Visible Thread".into()),
 6960        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 6961        None,
 6962        &project,
 6963        cx,
 6964    );
 6965
 6966    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
 6967    save_thread_metadata(
 6968        archived_thread_session_id.clone(),
 6969        Some("Archived Thread".into()),
 6970        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 6971        None,
 6972        &project,
 6973        cx,
 6974    );
 6975
 6976    cx.update(|_, cx| {
 6977        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6978            let thread_id = store
 6979                .entries()
 6980                .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
 6981                .map(|e| e.thread_id)
 6982                .unwrap();
 6983            store.archive(thread_id, None, cx)
 6984        })
 6985    });
 6986    cx.run_until_parked();
 6987
 6988    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 6989    cx.run_until_parked();
 6990
 6991    let entries = visible_entries_as_strings(&sidebar, cx);
 6992    assert!(
 6993        entries.iter().any(|e| e.contains("Visible Thread")),
 6994        "expected visible thread in sidebar, got: {entries:?}"
 6995    );
 6996    assert!(
 6997        !entries.iter().any(|e| e.contains("Archived Thread")),
 6998        "expected archived thread to be hidden from sidebar, got: {entries:?}"
 6999    );
 7000
 7001    cx.update(|_, cx| {
 7002        let store = ThreadMetadataStore::global(cx);
 7003        let all: Vec<_> = store.read(cx).entries().collect();
 7004        assert_eq!(
 7005            all.len(),
 7006            2,
 7007            "expected 2 total entries in the store, got: {}",
 7008            all.len()
 7009        );
 7010
 7011        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 7012        assert_eq!(archived.len(), 1);
 7013        assert_eq!(
 7014            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 7015            "archived-thread"
 7016        );
 7017    });
 7018}
 7019
 7020#[gpui::test]
 7021async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
 7022    cx: &mut TestAppContext,
 7023) {
 7024    // When a linked worktree has a single thread and that thread is archived,
 7025    // the sidebar must NOT create a new thread on the same worktree (which
 7026    // would prevent the worktree from being cleaned up on disk). Instead,
 7027    // archive_thread switches to a sibling thread on the main workspace (or
 7028    // creates a draft there) before archiving the metadata.
 7029    agent_ui::test_support::init_test(cx);
 7030    cx.update(|cx| {
 7031        ThreadStore::init_global(cx);
 7032        ThreadMetadataStore::init_global(cx);
 7033        language_model::LanguageModelRegistry::test(cx);
 7034        prompt_store::init(cx);
 7035    });
 7036
 7037    let fs = FakeFs::new(cx.executor());
 7038
 7039    fs.insert_tree(
 7040        "/project",
 7041        serde_json::json!({
 7042            ".git": {},
 7043            "src": {},
 7044        }),
 7045    )
 7046    .await;
 7047
 7048    fs.add_linked_worktree_for_repo(
 7049        Path::new("/project/.git"),
 7050        false,
 7051        git::repository::Worktree {
 7052            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7053            ref_name: Some("refs/heads/ochre-drift".into()),
 7054            sha: "aaa".into(),
 7055            is_main: false,
 7056        },
 7057    )
 7058    .await;
 7059
 7060    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7061
 7062    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7063    let worktree_project =
 7064        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7065
 7066    main_project
 7067        .update(cx, |p, cx| p.git_scans_complete(cx))
 7068        .await;
 7069    worktree_project
 7070        .update(cx, |p, cx| p.git_scans_complete(cx))
 7071        .await;
 7072
 7073    let (multi_workspace, cx) =
 7074        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7075
 7076    let sidebar = setup_sidebar(&multi_workspace, cx);
 7077
 7078    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7079        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7080    });
 7081
 7082    // Set up both workspaces with agent panels.
 7083    let main_workspace =
 7084        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7085    let _main_panel = add_agent_panel(&main_workspace, cx);
 7086    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7087
 7088    // Activate the linked worktree workspace so the sidebar tracks it.
 7089    multi_workspace.update_in(cx, |mw, window, cx| {
 7090        mw.activate(worktree_workspace.clone(), window, cx);
 7091    });
 7092
 7093    // Open a thread in the linked worktree panel and send a message
 7094    // so it becomes the active thread.
 7095    let connection = StubAgentConnection::new();
 7096    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7097    send_message(&worktree_panel, cx);
 7098
 7099    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7100
 7101    // Give the thread a response chunk so it has content.
 7102    cx.update(|_, cx| {
 7103        connection.send_update(
 7104            worktree_thread_id.clone(),
 7105            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7106            cx,
 7107        );
 7108    });
 7109
 7110    // Save the worktree thread's metadata.
 7111    save_thread_metadata(
 7112        worktree_thread_id.clone(),
 7113        Some("Ochre Drift Thread".into()),
 7114        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7115        None,
 7116        &worktree_project,
 7117        cx,
 7118    );
 7119
 7120    // Also save a thread on the main project so there's a sibling in the
 7121    // group that can be selected after archiving.
 7122    save_thread_metadata(
 7123        acp::SessionId::new(Arc::from("main-project-thread")),
 7124        Some("Main Project Thread".into()),
 7125        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7126        None,
 7127        &main_project,
 7128        cx,
 7129    );
 7130
 7131    cx.run_until_parked();
 7132
 7133    // Verify the linked worktree thread appears with its chip.
 7134    // The live thread title comes from the message text ("Hello"), not
 7135    // the metadata title we saved.
 7136    let entries_before = visible_entries_as_strings(&sidebar, cx);
 7137    assert!(
 7138        entries_before
 7139            .iter()
 7140            .any(|s| s.contains("{wt-ochre-drift}")),
 7141        "expected worktree thread with chip before archiving, got: {entries_before:?}"
 7142    );
 7143    assert!(
 7144        entries_before
 7145            .iter()
 7146            .any(|s| s.contains("Main Project Thread")),
 7147        "expected main project thread before archiving, got: {entries_before:?}"
 7148    );
 7149
 7150    // Confirm the worktree thread is the active entry.
 7151    sidebar.read_with(cx, |s, _| {
 7152        assert_active_thread(
 7153            s,
 7154            &worktree_thread_id,
 7155            "worktree thread should be active before archiving",
 7156        );
 7157    });
 7158
 7159    // Archive the worktree thread — it's the only thread using ochre-drift.
 7160    sidebar.update_in(cx, |sidebar, window, cx| {
 7161        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7162    });
 7163
 7164    cx.run_until_parked();
 7165
 7166    // The archived thread should no longer appear in the sidebar.
 7167    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7168    assert!(
 7169        !entries_after
 7170            .iter()
 7171            .any(|s| s.contains("Ochre Drift Thread")),
 7172        "archived thread should be hidden, got: {entries_after:?}"
 7173    );
 7174
 7175    // No "+ New Thread" entry should appear with the ochre-drift worktree
 7176    // chip — that would keep the worktree alive and prevent cleanup.
 7177    assert!(
 7178        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7179        "no entry should reference the archived worktree, got: {entries_after:?}"
 7180    );
 7181
 7182    // The main project thread should still be visible.
 7183    assert!(
 7184        entries_after
 7185            .iter()
 7186            .any(|s| s.contains("Main Project Thread")),
 7187        "main project thread should still be visible, got: {entries_after:?}"
 7188    );
 7189}
 7190
 7191#[gpui::test]
 7192async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_creates_draft_on_main(
 7193    cx: &mut TestAppContext,
 7194) {
 7195    // When a linked worktree thread is the ONLY thread in the project group
 7196    // (no threads on the main repo either), archiving it should create a
 7197    // draft on the main workspace, not the linked worktree workspace.
 7198    agent_ui::test_support::init_test(cx);
 7199    cx.update(|cx| {
 7200        ThreadStore::init_global(cx);
 7201        ThreadMetadataStore::init_global(cx);
 7202        language_model::LanguageModelRegistry::test(cx);
 7203        prompt_store::init(cx);
 7204    });
 7205
 7206    let fs = FakeFs::new(cx.executor());
 7207
 7208    fs.insert_tree(
 7209        "/project",
 7210        serde_json::json!({
 7211            ".git": {},
 7212            "src": {},
 7213        }),
 7214    )
 7215    .await;
 7216
 7217    fs.add_linked_worktree_for_repo(
 7218        Path::new("/project/.git"),
 7219        false,
 7220        git::repository::Worktree {
 7221            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7222            ref_name: Some("refs/heads/ochre-drift".into()),
 7223            sha: "aaa".into(),
 7224            is_main: false,
 7225        },
 7226    )
 7227    .await;
 7228
 7229    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7230
 7231    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7232    let worktree_project =
 7233        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7234
 7235    main_project
 7236        .update(cx, |p, cx| p.git_scans_complete(cx))
 7237        .await;
 7238    worktree_project
 7239        .update(cx, |p, cx| p.git_scans_complete(cx))
 7240        .await;
 7241
 7242    let (multi_workspace, cx) =
 7243        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7244
 7245    let sidebar = setup_sidebar(&multi_workspace, cx);
 7246
 7247    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7248        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7249    });
 7250
 7251    let main_workspace =
 7252        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7253    let _main_panel = add_agent_panel(&main_workspace, cx);
 7254    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7255
 7256    // Activate the linked worktree workspace.
 7257    multi_workspace.update_in(cx, |mw, window, cx| {
 7258        mw.activate(worktree_workspace.clone(), window, cx);
 7259    });
 7260
 7261    // Open a thread on the linked worktree — this is the ONLY thread.
 7262    let connection = StubAgentConnection::new();
 7263    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7264    send_message(&worktree_panel, cx);
 7265
 7266    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7267
 7268    cx.update(|_, cx| {
 7269        connection.send_update(
 7270            worktree_thread_id.clone(),
 7271            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7272            cx,
 7273        );
 7274    });
 7275
 7276    save_thread_metadata(
 7277        worktree_thread_id.clone(),
 7278        Some("Ochre Drift Thread".into()),
 7279        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7280        None,
 7281        &worktree_project,
 7282        cx,
 7283    );
 7284
 7285    cx.run_until_parked();
 7286
 7287    // Archive it — there are no other threads in the group.
 7288    sidebar.update_in(cx, |sidebar, window, cx| {
 7289        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7290    });
 7291
 7292    cx.run_until_parked();
 7293
 7294    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7295
 7296    // No entry should reference the linked worktree.
 7297    assert!(
 7298        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7299        "no entry should reference the archived worktree, got: {entries_after:?}"
 7300    );
 7301
 7302    // The active entry should be a draft on the main workspace.
 7303    sidebar.read_with(cx, |s, _| {
 7304        assert_active_draft(
 7305            s,
 7306            &main_workspace,
 7307            "active entry should be a draft on the main workspace",
 7308        );
 7309    });
 7310}
 7311
 7312#[gpui::test]
 7313async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
 7314    cx: &mut TestAppContext,
 7315) {
 7316    // When an archived thread belongs to a linked worktree whose main repo is
 7317    // already open, unarchiving should reopen the linked workspace into the
 7318    // same project group and show only the restored real thread row.
 7319    agent_ui::test_support::init_test(cx);
 7320    cx.update(|cx| {
 7321        ThreadStore::init_global(cx);
 7322        ThreadMetadataStore::init_global(cx);
 7323        language_model::LanguageModelRegistry::test(cx);
 7324        prompt_store::init(cx);
 7325    });
 7326
 7327    let fs = FakeFs::new(cx.executor());
 7328
 7329    fs.insert_tree(
 7330        "/project",
 7331        serde_json::json!({
 7332            ".git": {},
 7333            "src": {},
 7334        }),
 7335    )
 7336    .await;
 7337
 7338    fs.insert_tree(
 7339        "/wt-ochre-drift",
 7340        serde_json::json!({
 7341            ".git": "gitdir: /project/.git/worktrees/ochre-drift",
 7342            "src": {},
 7343        }),
 7344    )
 7345    .await;
 7346
 7347    fs.add_linked_worktree_for_repo(
 7348        Path::new("/project/.git"),
 7349        false,
 7350        git::repository::Worktree {
 7351            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7352            ref_name: Some("refs/heads/ochre-drift".into()),
 7353            sha: "aaa".into(),
 7354            is_main: false,
 7355        },
 7356    )
 7357    .await;
 7358
 7359    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7360
 7361    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7362    let worktree_project =
 7363        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7364
 7365    main_project
 7366        .update(cx, |p, cx| p.git_scans_complete(cx))
 7367        .await;
 7368    worktree_project
 7369        .update(cx, |p, cx| p.git_scans_complete(cx))
 7370        .await;
 7371
 7372    let (multi_workspace, cx) =
 7373        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7374
 7375    let sidebar = setup_sidebar(&multi_workspace, cx);
 7376    let main_workspace =
 7377        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7378    let _main_panel = add_agent_panel(&main_workspace, cx);
 7379    cx.run_until_parked();
 7380
 7381    let entries_before = visible_entries_as_strings(&sidebar, cx);
 7382    assert!(
 7383        entries_before.iter().any(|entry| entry.contains("Draft")),
 7384        "expected main workspace to start with a fallback draft, got entries: {entries_before:?}"
 7385    );
 7386
 7387    let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
 7388    let original_thread_id = ThreadId::new();
 7389    let main_paths = PathList::new(&[PathBuf::from("/project")]);
 7390    let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
 7391
 7392    cx.update(|_, cx| {
 7393        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7394            store.save(
 7395                ThreadMetadata {
 7396                    thread_id: original_thread_id,
 7397                    session_id: Some(session_id.clone()),
 7398                    agent_id: agent::ZED_AGENT_ID.clone(),
 7399                    title: Some("Unarchived Linked Thread".into()),
 7400                    updated_at: Utc::now(),
 7401                    created_at: None,
 7402                    worktree_paths: WorktreePaths::from_path_lists(
 7403                        main_paths.clone(),
 7404                        folder_paths.clone(),
 7405                    )
 7406                    .expect("main and folder paths should be well-formed"),
 7407                    archived: true,
 7408                    remote_connection: None,
 7409                },
 7410                cx,
 7411            )
 7412        });
 7413    });
 7414    cx.run_until_parked();
 7415
 7416    let metadata = cx.update(|_, cx| {
 7417        ThreadMetadataStore::global(cx)
 7418            .read(cx)
 7419            .entry(original_thread_id)
 7420            .cloned()
 7421            .expect("archived linked-worktree metadata should exist before restore")
 7422    });
 7423
 7424    sidebar.update_in(cx, |sidebar, window, cx| {
 7425        sidebar.activate_archived_thread(metadata, window, cx);
 7426    });
 7427
 7428    cx.run_until_parked();
 7429    cx.run_until_parked();
 7430    cx.run_until_parked();
 7431
 7432    assert_eq!(
 7433        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7434        2,
 7435        "expected unarchive to open the linked worktree workspace into the project group"
 7436    );
 7437
 7438    let session_entries = cx.update(|_, cx| {
 7439        ThreadMetadataStore::global(cx)
 7440            .read(cx)
 7441            .entries()
 7442            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7443            .cloned()
 7444            .collect::<Vec<_>>()
 7445    });
 7446    assert_eq!(
 7447        session_entries.len(),
 7448        1,
 7449        "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
 7450    );
 7451    assert_eq!(
 7452        session_entries[0].thread_id, original_thread_id,
 7453        "expected unarchive to reuse the original linked worktree thread id"
 7454    );
 7455    assert!(
 7456        !session_entries[0].archived,
 7457        "expected restored linked worktree metadata to be unarchived, got: {:?}",
 7458        session_entries[0]
 7459    );
 7460
 7461    let assert_no_extra_rows = |entries: &[String]| {
 7462        let real_thread_rows = entries
 7463            .iter()
 7464            .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7465            .filter(|entry| !entry.contains("Draft"))
 7466            .count();
 7467        assert_eq!(
 7468            real_thread_rows, 1,
 7469            "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
 7470        );
 7471        assert!(
 7472            !entries.iter().any(|entry| entry.contains("Draft")),
 7473            "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
 7474        );
 7475        assert!(
 7476            !entries
 7477                .iter()
 7478                .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
 7479            "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
 7480        );
 7481        assert!(
 7482            entries
 7483                .iter()
 7484                .any(|entry| entry.contains("Unarchived Linked Thread")),
 7485            "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
 7486        );
 7487    };
 7488
 7489    let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
 7490    assert_no_extra_rows(&entries_after_restore);
 7491
 7492    // The reported bug may only appear after an extra scheduling turn.
 7493    cx.run_until_parked();
 7494    cx.run_until_parked();
 7495
 7496    let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
 7497    assert_no_extra_rows(&entries_after_extra_turns);
 7498}
 7499
 7500#[gpui::test]
 7501async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
 7502    // When a linked worktree thread is archived but the group has other
 7503    // threads (e.g. on the main project), archive_thread should select
 7504    // the nearest sibling.
 7505    agent_ui::test_support::init_test(cx);
 7506    cx.update(|cx| {
 7507        ThreadStore::init_global(cx);
 7508        ThreadMetadataStore::init_global(cx);
 7509        language_model::LanguageModelRegistry::test(cx);
 7510        prompt_store::init(cx);
 7511    });
 7512
 7513    let fs = FakeFs::new(cx.executor());
 7514
 7515    fs.insert_tree(
 7516        "/project",
 7517        serde_json::json!({
 7518            ".git": {},
 7519            "src": {},
 7520        }),
 7521    )
 7522    .await;
 7523
 7524    fs.add_linked_worktree_for_repo(
 7525        Path::new("/project/.git"),
 7526        false,
 7527        git::repository::Worktree {
 7528            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7529            ref_name: Some("refs/heads/ochre-drift".into()),
 7530            sha: "aaa".into(),
 7531            is_main: false,
 7532        },
 7533    )
 7534    .await;
 7535
 7536    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7537
 7538    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7539    let worktree_project =
 7540        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7541
 7542    main_project
 7543        .update(cx, |p, cx| p.git_scans_complete(cx))
 7544        .await;
 7545    worktree_project
 7546        .update(cx, |p, cx| p.git_scans_complete(cx))
 7547        .await;
 7548
 7549    let (multi_workspace, cx) =
 7550        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7551
 7552    let sidebar = setup_sidebar(&multi_workspace, cx);
 7553
 7554    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7555        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7556    });
 7557
 7558    let main_workspace =
 7559        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7560    let _main_panel = add_agent_panel(&main_workspace, cx);
 7561    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7562
 7563    // Activate the linked worktree workspace.
 7564    multi_workspace.update_in(cx, |mw, window, cx| {
 7565        mw.activate(worktree_workspace.clone(), window, cx);
 7566    });
 7567
 7568    // Open a thread on the linked worktree.
 7569    let connection = StubAgentConnection::new();
 7570    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7571    send_message(&worktree_panel, cx);
 7572
 7573    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7574
 7575    cx.update(|_, cx| {
 7576        connection.send_update(
 7577            worktree_thread_id.clone(),
 7578            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7579            cx,
 7580        );
 7581    });
 7582
 7583    save_thread_metadata(
 7584        worktree_thread_id.clone(),
 7585        Some("Ochre Drift Thread".into()),
 7586        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7587        None,
 7588        &worktree_project,
 7589        cx,
 7590    );
 7591
 7592    // Save a sibling thread on the main project.
 7593    let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
 7594    save_thread_metadata(
 7595        main_thread_id,
 7596        Some("Main Project Thread".into()),
 7597        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7598        None,
 7599        &main_project,
 7600        cx,
 7601    );
 7602
 7603    cx.run_until_parked();
 7604
 7605    // Confirm the worktree thread is active.
 7606    sidebar.read_with(cx, |s, _| {
 7607        assert_active_thread(
 7608            s,
 7609            &worktree_thread_id,
 7610            "worktree thread should be active before archiving",
 7611        );
 7612    });
 7613
 7614    // Archive the worktree thread.
 7615    sidebar.update_in(cx, |sidebar, window, cx| {
 7616        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7617    });
 7618
 7619    cx.run_until_parked();
 7620
 7621    // The worktree workspace was removed and a draft was created on the
 7622    // main workspace. No entry should reference the linked worktree.
 7623    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7624    assert!(
 7625        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7626        "no entry should reference the archived worktree, got: {entries_after:?}"
 7627    );
 7628
 7629    // The main project thread should still be visible.
 7630    assert!(
 7631        entries_after
 7632            .iter()
 7633            .any(|s| s.contains("Main Project Thread")),
 7634        "main project thread should still be visible, got: {entries_after:?}"
 7635    );
 7636}
 7637
 7638#[gpui::test]
 7639async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
 7640    // When a linked worktree is opened as its own workspace and the user
 7641    // creates a draft thread from it, then switches away, the workspace must
 7642    // still be reachable from that DraftThread sidebar entry. Pressing
 7643    // RemoveSelectedThread (shift-backspace) on that entry should remove the
 7644    // workspace.
 7645    init_test(cx);
 7646    let fs = FakeFs::new(cx.executor());
 7647
 7648    fs.insert_tree(
 7649        "/project",
 7650        serde_json::json!({
 7651            ".git": {
 7652                "worktrees": {
 7653                    "feature-a": {
 7654                        "commondir": "../../",
 7655                        "HEAD": "ref: refs/heads/feature-a",
 7656                    },
 7657                },
 7658            },
 7659            "src": {},
 7660        }),
 7661    )
 7662    .await;
 7663
 7664    fs.insert_tree(
 7665        "/wt-feature-a",
 7666        serde_json::json!({
 7667            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7668            "src": {},
 7669        }),
 7670    )
 7671    .await;
 7672
 7673    fs.add_linked_worktree_for_repo(
 7674        Path::new("/project/.git"),
 7675        false,
 7676        git::repository::Worktree {
 7677            path: PathBuf::from("/wt-feature-a"),
 7678            ref_name: Some("refs/heads/feature-a".into()),
 7679            sha: "aaa".into(),
 7680            is_main: false,
 7681        },
 7682    )
 7683    .await;
 7684
 7685    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7686
 7687    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7688    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7689
 7690    main_project
 7691        .update(cx, |p, cx| p.git_scans_complete(cx))
 7692        .await;
 7693    worktree_project
 7694        .update(cx, |p, cx| p.git_scans_complete(cx))
 7695        .await;
 7696
 7697    let (multi_workspace, cx) =
 7698        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7699    let sidebar = setup_sidebar(&multi_workspace, cx);
 7700
 7701    // Open the linked worktree as a separate workspace (simulates cmd-o).
 7702    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7703        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7704    });
 7705    add_agent_panel(&worktree_workspace, cx);
 7706    cx.run_until_parked();
 7707
 7708    // Explicitly create a draft thread from the linked worktree workspace.
 7709    // Auto-created drafts use the group's first workspace (the main one),
 7710    // so a user-created draft is needed to make the linked worktree reachable.
 7711    sidebar.update_in(cx, |sidebar, window, cx| {
 7712        sidebar.create_new_thread(&worktree_workspace, window, cx);
 7713    });
 7714    cx.run_until_parked();
 7715
 7716    // Switch back to the main workspace.
 7717    multi_workspace.update_in(cx, |mw, window, cx| {
 7718        let main_ws = mw.workspaces().next().unwrap().clone();
 7719        mw.activate(main_ws, window, cx);
 7720    });
 7721    cx.run_until_parked();
 7722
 7723    sidebar.update_in(cx, |sidebar, _window, cx| {
 7724        sidebar.update_entries(cx);
 7725    });
 7726    cx.run_until_parked();
 7727
 7728    // The linked worktree workspace must be reachable from some sidebar entry.
 7729    let worktree_ws_id = worktree_workspace.entity_id();
 7730    let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
 7731        let mw = multi_workspace.read(cx);
 7732        sidebar
 7733            .contents
 7734            .entries
 7735            .iter()
 7736            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 7737            .map(|ws| ws.entity_id())
 7738            .collect()
 7739    });
 7740    assert!(
 7741        reachable.contains(&worktree_ws_id),
 7742        "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
 7743    );
 7744
 7745    // Find the draft Thread entry whose workspace is the linked worktree.
 7746    let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
 7747        sidebar
 7748            .contents
 7749            .entries
 7750            .iter()
 7751            .position(|entry| match entry {
 7752                ListEntry::Thread(thread) if thread.is_draft => matches!(
 7753                    &thread.workspace,
 7754                    ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
 7755                ),
 7756                _ => false,
 7757            })
 7758            .expect("expected a draft thread entry for the linked worktree")
 7759    });
 7760
 7761    assert_eq!(
 7762        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7763        2
 7764    );
 7765
 7766    sidebar.update_in(cx, |sidebar, window, cx| {
 7767        sidebar.selection = Some(new_thread_ix);
 7768        sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
 7769    });
 7770    cx.run_until_parked();
 7771
 7772    assert_eq!(
 7773        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7774        2,
 7775        "dismissing a draft no longer removes the linked worktree workspace"
 7776    );
 7777
 7778    let has_draft_for_worktree = sidebar.read_with(cx, |sidebar, _| {
 7779        sidebar.contents.entries.iter().any(|entry| match entry {
 7780            ListEntry::Thread(thread) if thread.is_draft => matches!(
 7781                &thread.workspace,
 7782                ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
 7783            ),
 7784            _ => false,
 7785        })
 7786    });
 7787    assert!(
 7788        !has_draft_for_worktree,
 7789        "draft thread entry for the linked worktree should be removed after dismiss"
 7790    );
 7791}
 7792
 7793#[gpui::test]
 7794async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
 7795    // When only a linked worktree workspace is open (not the main repo),
 7796    // threads saved against the main repo should still appear in the sidebar.
 7797    init_test(cx);
 7798    let fs = FakeFs::new(cx.executor());
 7799
 7800    // Create the main repo with a linked worktree.
 7801    fs.insert_tree(
 7802        "/project",
 7803        serde_json::json!({
 7804            ".git": {
 7805                "worktrees": {
 7806                    "feature-a": {
 7807                        "commondir": "../../",
 7808                        "HEAD": "ref: refs/heads/feature-a",
 7809                    },
 7810                },
 7811            },
 7812            "src": {},
 7813        }),
 7814    )
 7815    .await;
 7816
 7817    fs.insert_tree(
 7818        "/wt-feature-a",
 7819        serde_json::json!({
 7820            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7821            "src": {},
 7822        }),
 7823    )
 7824    .await;
 7825
 7826    fs.add_linked_worktree_for_repo(
 7827        std::path::Path::new("/project/.git"),
 7828        false,
 7829        git::repository::Worktree {
 7830            path: std::path::PathBuf::from("/wt-feature-a"),
 7831            ref_name: Some("refs/heads/feature-a".into()),
 7832            sha: "abc".into(),
 7833            is_main: false,
 7834        },
 7835    )
 7836    .await;
 7837
 7838    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7839
 7840    // Only open the linked worktree as a workspace — NOT the main repo.
 7841    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7842    worktree_project
 7843        .update(cx, |p, cx| p.git_scans_complete(cx))
 7844        .await;
 7845
 7846    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7847    main_project
 7848        .update(cx, |p, cx| p.git_scans_complete(cx))
 7849        .await;
 7850
 7851    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 7852        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 7853    });
 7854    let sidebar = setup_sidebar(&multi_workspace, cx);
 7855
 7856    // Save a thread against the MAIN repo path.
 7857    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
 7858
 7859    // Save a thread against the linked worktree path.
 7860    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
 7861
 7862    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7863    cx.run_until_parked();
 7864
 7865    // Both threads should be visible: the worktree thread by direct lookup,
 7866    // and the main repo thread because the workspace is a linked worktree
 7867    // and we also query the main repo path.
 7868    let entries = visible_entries_as_strings(&sidebar, cx);
 7869    assert!(
 7870        entries.iter().any(|e| e.contains("Main Repo Thread")),
 7871        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
 7872    );
 7873    assert!(
 7874        entries.iter().any(|e| e.contains("Worktree Thread")),
 7875        "expected worktree thread to be visible, got: {entries:?}"
 7876    );
 7877}
 7878
 7879async fn init_multi_project_test(
 7880    paths: &[&str],
 7881    cx: &mut TestAppContext,
 7882) -> (Arc<FakeFs>, Entity<project::Project>) {
 7883    agent_ui::test_support::init_test(cx);
 7884    cx.update(|cx| {
 7885        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 7886        ThreadStore::init_global(cx);
 7887        ThreadMetadataStore::init_global(cx);
 7888        language_model::LanguageModelRegistry::test(cx);
 7889        prompt_store::init(cx);
 7890    });
 7891    let fs = FakeFs::new(cx.executor());
 7892    for path in paths {
 7893        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
 7894            .await;
 7895    }
 7896    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7897    let project =
 7898        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
 7899    (fs, project)
 7900}
 7901
 7902async fn add_test_project(
 7903    path: &str,
 7904    fs: &Arc<FakeFs>,
 7905    multi_workspace: &Entity<MultiWorkspace>,
 7906    cx: &mut gpui::VisualTestContext,
 7907) -> Entity<Workspace> {
 7908    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
 7909    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7910        mw.test_add_workspace(project, window, cx)
 7911    });
 7912    cx.run_until_parked();
 7913    workspace
 7914}
 7915
 7916#[gpui::test]
 7917async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
 7918    let (fs, project_a) =
 7919        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 7920    let (multi_workspace, cx) =
 7921        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 7922    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
 7923
 7924    // Sidebar starts closed. Initial workspace A is transient.
 7925    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 7926    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 7927    assert_eq!(
 7928        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7929        1
 7930    );
 7931    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
 7932
 7933    // Add B — replaces A as the transient workspace.
 7934    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 7935    assert_eq!(
 7936        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7937        1
 7938    );
 7939    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 7940
 7941    // Add C — replaces B as the transient workspace.
 7942    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 7943    assert_eq!(
 7944        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7945        1
 7946    );
 7947    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 7948}
 7949
 7950#[gpui::test]
 7951async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
 7952    let (fs, project_a) = init_multi_project_test(
 7953        &["/project-a", "/project-b", "/project-c", "/project-d"],
 7954        cx,
 7955    )
 7956    .await;
 7957    let (multi_workspace, cx) =
 7958        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 7959    let _sidebar = setup_sidebar(&multi_workspace, cx);
 7960    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 7961
 7962    // Add B — retained since sidebar is open.
 7963    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 7964    assert_eq!(
 7965        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7966        2
 7967    );
 7968
 7969    // Switch to A — B survives. (Switching from one internal workspace, to another)
 7970    multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
 7971    cx.run_until_parked();
 7972    assert_eq!(
 7973        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7974        2
 7975    );
 7976
 7977    // Close sidebar — both A and B remain retained.
 7978    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
 7979    cx.run_until_parked();
 7980    assert_eq!(
 7981        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7982        2
 7983    );
 7984
 7985    // Add C — added as new transient workspace. (switching from retained, to transient)
 7986    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 7987    assert_eq!(
 7988        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7989        3
 7990    );
 7991    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 7992
 7993    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
 7994    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
 7995    assert_eq!(
 7996        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7997        3
 7998    );
 7999    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
 8000}
 8001
 8002#[gpui::test]
 8003async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
 8004    let (fs, project_a) =
 8005        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8006    let (multi_workspace, cx) =
 8007        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8008    setup_sidebar_closed(&multi_workspace, cx);
 8009
 8010    // Add B — replaces A as the transient workspace (A is discarded).
 8011    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8012    assert_eq!(
 8013        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8014        1
 8015    );
 8016    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8017
 8018    // Open sidebar — promotes the transient B to retained.
 8019    multi_workspace.update_in(cx, |mw, window, cx| {
 8020        mw.toggle_sidebar(window, cx);
 8021    });
 8022    cx.run_until_parked();
 8023    assert_eq!(
 8024        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8025        1
 8026    );
 8027    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
 8028
 8029    // Close sidebar — the retained B remains.
 8030    multi_workspace.update_in(cx, |mw, window, cx| {
 8031        mw.toggle_sidebar(window, cx);
 8032    });
 8033
 8034    // Add C — added as new transient workspace.
 8035    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8036    assert_eq!(
 8037        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8038        2
 8039    );
 8040    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8041}
 8042
 8043#[gpui::test]
 8044async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
 8045    init_test(cx);
 8046    let fs = FakeFs::new(cx.executor());
 8047
 8048    fs.insert_tree(
 8049        "/project",
 8050        serde_json::json!({
 8051            ".git": {
 8052                "worktrees": {
 8053                    "feature-a": {
 8054                        "commondir": "../../",
 8055                        "HEAD": "ref: refs/heads/feature-a",
 8056                    },
 8057                },
 8058            },
 8059            "src": {},
 8060        }),
 8061    )
 8062    .await;
 8063
 8064    fs.insert_tree(
 8065        "/wt-feature-a",
 8066        serde_json::json!({
 8067            ".git": "gitdir: /project/.git/worktrees/feature-a",
 8068            "src": {},
 8069        }),
 8070    )
 8071    .await;
 8072
 8073    fs.add_linked_worktree_for_repo(
 8074        Path::new("/project/.git"),
 8075        false,
 8076        git::repository::Worktree {
 8077            path: PathBuf::from("/wt-feature-a"),
 8078            ref_name: Some("refs/heads/feature-a".into()),
 8079            sha: "abc".into(),
 8080            is_main: false,
 8081        },
 8082    )
 8083    .await;
 8084
 8085    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8086
 8087    // Only a linked worktree workspace is open — no workspace for /project.
 8088    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8089    worktree_project
 8090        .update(cx, |p, cx| p.git_scans_complete(cx))
 8091        .await;
 8092
 8093    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 8094        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 8095    });
 8096    let sidebar = setup_sidebar(&multi_workspace, cx);
 8097
 8098    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
 8099    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
 8100    cx.update(|_, cx| {
 8101        let metadata = ThreadMetadata {
 8102            thread_id: ThreadId::new(),
 8103            session_id: Some(legacy_session.clone()),
 8104            agent_id: agent::ZED_AGENT_ID.clone(),
 8105            title: Some("Legacy Main Thread".into()),
 8106            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8107            created_at: None,
 8108            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 8109                "/project",
 8110            )])),
 8111            archived: false,
 8112            remote_connection: None,
 8113        };
 8114        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
 8115    });
 8116    cx.run_until_parked();
 8117
 8118    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 8119    cx.run_until_parked();
 8120
 8121    // The legacy thread should appear in the sidebar under the project group.
 8122    let entries = visible_entries_as_strings(&sidebar, cx);
 8123    assert!(
 8124        entries.iter().any(|e| e.contains("Legacy Main Thread")),
 8125        "legacy thread should be visible: {entries:?}",
 8126    );
 8127
 8128    // Verify only 1 workspace before clicking.
 8129    assert_eq!(
 8130        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8131        1,
 8132    );
 8133
 8134    // Focus and select the legacy thread, then confirm.
 8135    focus_sidebar(&sidebar, cx);
 8136    let thread_index = sidebar.read_with(cx, |sidebar, _| {
 8137        sidebar
 8138            .contents
 8139            .entries
 8140            .iter()
 8141            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
 8142            .expect("legacy thread should be in entries")
 8143    });
 8144    sidebar.update_in(cx, |sidebar, _window, _cx| {
 8145        sidebar.selection = Some(thread_index);
 8146    });
 8147    cx.dispatch_action(Confirm);
 8148    cx.run_until_parked();
 8149
 8150    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8151    let new_path_list =
 8152        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 8153    assert_eq!(
 8154        new_path_list,
 8155        PathList::new(&[PathBuf::from("/project")]),
 8156        "the new workspace should be for the main repo, not the linked worktree",
 8157    );
 8158}
 8159
 8160#[gpui::test]
 8161async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
 8162    cx: &mut TestAppContext,
 8163) {
 8164    // Regression test for a property-test finding:
 8165    //   AddLinkedWorktree { project_group_index: 0 }
 8166    //   AddProject { use_worktree: true }
 8167    //   AddProject { use_worktree: false }
 8168    // After these three steps, the linked-worktree workspace was not
 8169    // reachable from any sidebar entry.
 8170    agent_ui::test_support::init_test(cx);
 8171    cx.update(|cx| {
 8172        ThreadStore::init_global(cx);
 8173        ThreadMetadataStore::init_global(cx);
 8174        language_model::LanguageModelRegistry::test(cx);
 8175        prompt_store::init(cx);
 8176
 8177        cx.observe_new(
 8178            |workspace: &mut Workspace,
 8179             window: Option<&mut Window>,
 8180             cx: &mut gpui::Context<Workspace>| {
 8181                if let Some(window) = window {
 8182                    let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 8183                    workspace.add_panel(panel, window, cx);
 8184                }
 8185            },
 8186        )
 8187        .detach();
 8188    });
 8189
 8190    let fs = FakeFs::new(cx.executor());
 8191    fs.insert_tree(
 8192        "/my-project",
 8193        serde_json::json!({
 8194            ".git": {},
 8195            "src": {},
 8196        }),
 8197    )
 8198    .await;
 8199    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8200    let project =
 8201        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
 8202    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8203
 8204    let (multi_workspace, cx) =
 8205        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8206    let sidebar = setup_sidebar(&multi_workspace, cx);
 8207
 8208    // Step 1: Create a linked worktree for the main project.
 8209    let worktree_name = "wt-0";
 8210    let worktree_path = "/worktrees/wt-0";
 8211
 8212    fs.insert_tree(
 8213        worktree_path,
 8214        serde_json::json!({
 8215            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8216            "src": {},
 8217        }),
 8218    )
 8219    .await;
 8220    fs.insert_tree(
 8221        "/my-project/.git/worktrees/wt-0",
 8222        serde_json::json!({
 8223            "commondir": "../../",
 8224            "HEAD": "ref: refs/heads/wt-0",
 8225        }),
 8226    )
 8227    .await;
 8228    fs.add_linked_worktree_for_repo(
 8229        Path::new("/my-project/.git"),
 8230        false,
 8231        git::repository::Worktree {
 8232            path: PathBuf::from(worktree_path),
 8233            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 8234            sha: "aaa".into(),
 8235            is_main: false,
 8236        },
 8237    )
 8238    .await;
 8239
 8240    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8241    let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 8242    main_project
 8243        .update(cx, |p, cx| p.git_scans_complete(cx))
 8244        .await;
 8245    cx.run_until_parked();
 8246
 8247    // Step 2: Open the linked worktree as its own workspace.
 8248    let worktree_project =
 8249        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
 8250    worktree_project
 8251        .update(cx, |p, cx| p.git_scans_complete(cx))
 8252        .await;
 8253    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8254        mw.test_add_workspace(worktree_project.clone(), window, cx)
 8255    });
 8256    cx.run_until_parked();
 8257
 8258    // Step 3: Add an unrelated project.
 8259    fs.insert_tree(
 8260        "/other-project",
 8261        serde_json::json!({
 8262            ".git": {},
 8263            "src": {},
 8264        }),
 8265    )
 8266    .await;
 8267    let other_project = project::Project::test(
 8268        fs.clone() as Arc<dyn fs::Fs>,
 8269        ["/other-project".as_ref()],
 8270        cx,
 8271    )
 8272    .await;
 8273    other_project
 8274        .update(cx, |p, cx| p.git_scans_complete(cx))
 8275        .await;
 8276    multi_workspace.update_in(cx, |mw, window, cx| {
 8277        mw.test_add_workspace(other_project.clone(), window, cx);
 8278    });
 8279    cx.run_until_parked();
 8280
 8281    // Force a full sidebar rebuild with all groups expanded.
 8282    sidebar.update_in(cx, |sidebar, _window, cx| {
 8283        if let Some(mw) = sidebar.multi_workspace.upgrade() {
 8284            mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 8285        }
 8286        sidebar.update_entries(cx);
 8287    });
 8288    cx.run_until_parked();
 8289
 8290    // The linked-worktree workspace must be reachable from at least one
 8291    // sidebar entry — otherwise the user has no way to navigate to it.
 8292    let worktree_ws_id = worktree_workspace.entity_id();
 8293    let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
 8294        let mw = multi_workspace.read(cx);
 8295
 8296        let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
 8297        let reachable: HashSet<gpui::EntityId> = sidebar
 8298            .contents
 8299            .entries
 8300            .iter()
 8301            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 8302            .map(|ws| ws.entity_id())
 8303            .collect();
 8304        (all, reachable)
 8305    });
 8306
 8307    let unreachable = &all_ids - &reachable_ids;
 8308    eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
 8309
 8310    assert!(
 8311        unreachable.is_empty(),
 8312        "workspaces not reachable from any sidebar entry: {:?}\n\
 8313         (linked-worktree workspace id: {:?})",
 8314        unreachable,
 8315        worktree_ws_id,
 8316    );
 8317}
 8318
 8319#[gpui::test]
 8320async fn test_startup_failed_restoration_shows_draft(cx: &mut TestAppContext) {
 8321    // Rule 4: When the app starts and the AgentPanel fails to restore its
 8322    // last thread (no metadata), a draft should appear in the sidebar.
 8323    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8324    let (multi_workspace, cx) =
 8325        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8326    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8327
 8328    let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8329
 8330    let entries = visible_entries_as_strings(&sidebar, cx);
 8331    assert_eq!(entries.len(), 2, "should have header + draft: {entries:?}");
 8332    assert!(
 8333        entries[1].contains("Draft"),
 8334        "second entry should be a draft: {entries:?}"
 8335    );
 8336}
 8337
 8338#[gpui::test]
 8339async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
 8340    // Rule 5: When the app starts and the AgentPanel successfully loads
 8341    // a thread, no spurious draft should appear.
 8342    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8343    let (multi_workspace, cx) =
 8344        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8345    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8346
 8347    // Create and send a message to make a real thread.
 8348    let connection = StubAgentConnection::new();
 8349    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8350        acp::ContentChunk::new("Done".into()),
 8351    )]);
 8352    open_thread_with_connection(&panel, connection, cx);
 8353    send_message(&panel, cx);
 8354    let session_id = active_session_id(&panel, cx);
 8355    save_test_thread_metadata(&session_id, &project, cx).await;
 8356    cx.run_until_parked();
 8357
 8358    // Should show the thread, NOT a spurious draft.
 8359    let entries = visible_entries_as_strings(&sidebar, cx);
 8360    assert_eq!(entries, vec!["v [my-project]", "  Hello *"]);
 8361
 8362    // active_entry should be Thread, not Draft.
 8363    sidebar.read_with(cx, |sidebar, _| {
 8364        assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
 8365    });
 8366}
 8367
 8368#[gpui::test]
 8369async fn test_delete_last_draft_in_empty_group_shows_placeholder(cx: &mut TestAppContext) {
 8370    // Deleting the last draft in a threadless group should
 8371    // leave a placeholder draft entry (not an empty group).
 8372    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8373    let (multi_workspace, cx) =
 8374        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8375    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8376
 8377    // Reconciliation creates a draft for the empty group.
 8378    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8379    let entries = visible_entries_as_strings(&sidebar, cx);
 8380    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8381    assert_eq!(
 8382        draft_count, 1,
 8383        "should start with 1 draft from reconciliation"
 8384    );
 8385
 8386    // Find and delete the draft.
 8387    let draft_thread_id = sidebar.read_with(cx, |_sidebar, cx| {
 8388        let panel = workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
 8389        panel
 8390            .read(cx)
 8391            .draft_thread_ids(cx)
 8392            .into_iter()
 8393            .next()
 8394            .unwrap()
 8395    });
 8396    sidebar.update_in(cx, |sidebar, window, cx| {
 8397        sidebar.remove_draft(draft_thread_id, &workspace, window, cx);
 8398    });
 8399    cx.run_until_parked();
 8400
 8401    // The group has no threads and no tracked drafts, so a
 8402    // placeholder draft should appear via reconciliation.
 8403    let entries = visible_entries_as_strings(&sidebar, cx);
 8404    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8405    assert_eq!(
 8406        draft_count, 1,
 8407        "placeholder draft should appear after deleting all tracked drafts"
 8408    );
 8409}
 8410
 8411#[gpui::test]
 8412async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
 8413    // Rule 9: Clicking a project header should restore whatever the
 8414    // user was last looking at in that group, not create new drafts
 8415    // or jump to the first entry.
 8416    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 8417    let (multi_workspace, cx) =
 8418        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8419    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8420
 8421    // Create two threads in project-a.
 8422    let conn1 = StubAgentConnection::new();
 8423    conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8424        acp::ContentChunk::new("Done".into()),
 8425    )]);
 8426    open_thread_with_connection(&panel_a, conn1, cx);
 8427    send_message(&panel_a, cx);
 8428    let thread_a1 = active_session_id(&panel_a, cx);
 8429    save_test_thread_metadata(&thread_a1, &project_a, cx).await;
 8430
 8431    let conn2 = StubAgentConnection::new();
 8432    conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8433        acp::ContentChunk::new("Done".into()),
 8434    )]);
 8435    open_thread_with_connection(&panel_a, conn2, cx);
 8436    send_message(&panel_a, cx);
 8437    let thread_a2 = active_session_id(&panel_a, cx);
 8438    save_test_thread_metadata(&thread_a2, &project_a, cx).await;
 8439    cx.run_until_parked();
 8440
 8441    // The user is now looking at thread_a2.
 8442    sidebar.read_with(cx, |sidebar, _| {
 8443        assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
 8444    });
 8445
 8446    // Add project-b and switch to it.
 8447    let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
 8448    fs.as_fake()
 8449        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 8450        .await;
 8451    let project_b =
 8452        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8453    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8454        mw.test_add_workspace(project_b.clone(), window, cx)
 8455    });
 8456    let _panel_b = add_agent_panel(&workspace_b, cx);
 8457    cx.run_until_parked();
 8458
 8459    // Now switch BACK to project-a by activating its workspace.
 8460    let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
 8461        mw.workspaces()
 8462            .find(|ws| {
 8463                ws.read(cx)
 8464                    .project()
 8465                    .read(cx)
 8466                    .visible_worktrees(cx)
 8467                    .any(|wt| {
 8468                        wt.read(cx)
 8469                            .abs_path()
 8470                            .to_string_lossy()
 8471                            .contains("project-a")
 8472                    })
 8473            })
 8474            .unwrap()
 8475            .clone()
 8476    });
 8477    multi_workspace.update_in(cx, |mw, window, cx| {
 8478        mw.activate(workspace_a.clone(), window, cx);
 8479    });
 8480    cx.run_until_parked();
 8481
 8482    // The panel should still show thread_a2 (the last thing the user
 8483    // was viewing in project-a), not a draft or thread_a1.
 8484    sidebar.read_with(cx, |sidebar, _| {
 8485        assert_active_thread(
 8486            sidebar,
 8487            &thread_a2,
 8488            "switching back to project-a should restore thread_a2",
 8489        );
 8490    });
 8491
 8492    // No spurious draft entries should have been created in
 8493    // project-a's group (project-b may have a placeholder).
 8494    let entries = visible_entries_as_strings(&sidebar, cx);
 8495    // Find project-a's section and check it has no drafts.
 8496    let project_a_start = entries
 8497        .iter()
 8498        .position(|e| e.contains("project-a"))
 8499        .unwrap();
 8500    let project_a_end = entries[project_a_start + 1..]
 8501        .iter()
 8502        .position(|e| e.starts_with("v "))
 8503        .map(|i| i + project_a_start + 1)
 8504        .unwrap_or(entries.len());
 8505    let project_a_drafts = entries[project_a_start..project_a_end]
 8506        .iter()
 8507        .filter(|e| e.contains("Draft"))
 8508        .count();
 8509    assert_eq!(
 8510        project_a_drafts, 0,
 8511        "switching back to project-a should not create drafts in its group"
 8512    );
 8513}
 8514
 8515#[gpui::test]
 8516async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
 8517    // Clicking the + button when an empty draft already exists should
 8518    // focus the existing draft rather than creating a new one.
 8519    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8520    let (multi_workspace, cx) =
 8521        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8522    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8523
 8524    // Start: panel has 1 draft from set_active.
 8525    let entries = visible_entries_as_strings(&sidebar, cx);
 8526    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8527    assert_eq!(draft_count, 1, "should start with 1 draft");
 8528
 8529    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8530    let simulate_plus_button =
 8531        |sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context<Sidebar>| {
 8532            sidebar.create_new_thread(&workspace, window, cx);
 8533        };
 8534
 8535    // + click with empty draft: should reuse it, not create a new one.
 8536    sidebar.update_in(cx, |sidebar, window, cx| {
 8537        simulate_plus_button(sidebar, window, cx);
 8538    });
 8539    cx.run_until_parked();
 8540
 8541    let entries = visible_entries_as_strings(&sidebar, cx);
 8542    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8543    assert_eq!(
 8544        draft_count, 1,
 8545        "+ click should reuse the existing empty draft, not create a new one"
 8546    );
 8547
 8548    // The draft should be active.
 8549    assert_eq!(entries[1], "  [~ Draft] *");
 8550}
 8551
 8552#[gpui::test]
 8553async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
 8554    // When a workspace has a draft (from the panel's load fallback)
 8555    // and the user activates it (e.g. by clicking the placeholder or
 8556    // the project header), no extra drafts should be created.
 8557    init_test(cx);
 8558    let fs = FakeFs::new(cx.executor());
 8559    fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
 8560        .await;
 8561    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8562        .await;
 8563    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8564
 8565    let project_a =
 8566        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
 8567    let (multi_workspace, cx) =
 8568        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8569    let sidebar = setup_sidebar(&multi_workspace, cx);
 8570    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8571    let _panel_a = add_agent_panel(&workspace_a, cx);
 8572    cx.run_until_parked();
 8573
 8574    // Add project-b with its own workspace and agent panel.
 8575    let project_b =
 8576        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8577    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8578        mw.test_add_workspace(project_b.clone(), window, cx)
 8579    });
 8580    let _panel_b = add_agent_panel(&workspace_b, cx);
 8581    cx.run_until_parked();
 8582
 8583    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 8584    sidebar.update_in(cx, |sidebar, window, cx| {
 8585        sidebar.create_new_thread(&workspace_b, window, cx);
 8586    });
 8587    cx.run_until_parked();
 8588
 8589    // Count project-b's drafts.
 8590    let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
 8591        let entries = visible_entries_as_strings(&sidebar, cx);
 8592        entries
 8593            .iter()
 8594            .skip_while(|e| !e.contains("project-b"))
 8595            .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
 8596            .filter(|e| e.contains("Draft"))
 8597            .count()
 8598    };
 8599    let drafts_before = count_b_drafts(cx);
 8600
 8601    // Switch away from project-b, then back.
 8602    multi_workspace.update_in(cx, |mw, window, cx| {
 8603        mw.activate(workspace_a.clone(), window, cx);
 8604    });
 8605    cx.run_until_parked();
 8606    multi_workspace.update_in(cx, |mw, window, cx| {
 8607        mw.activate(workspace_b.clone(), window, cx);
 8608    });
 8609    cx.run_until_parked();
 8610
 8611    let drafts_after = count_b_drafts(cx);
 8612    assert_eq!(
 8613        drafts_before, drafts_after,
 8614        "activating workspace should not create extra drafts"
 8615    );
 8616
 8617    // The draft should be highlighted as active after switching back.
 8618    sidebar.read_with(cx, |sidebar, _| {
 8619        assert_active_draft(
 8620            sidebar,
 8621            &workspace_b,
 8622            "draft should be active after switching back to its workspace",
 8623        );
 8624    });
 8625}
 8626
 8627#[gpui::test]
 8628async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
 8629    // Historical threads (not open in any agent panel) should have their
 8630    // worktree paths updated when a folder is added to or removed from the
 8631    // project.
 8632    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
 8633    let (multi_workspace, cx) =
 8634        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8635    let sidebar = setup_sidebar(&multi_workspace, cx);
 8636
 8637    // Save two threads directly into the metadata store (not via the agent
 8638    // panel), so they are purely historical — no open views hold them.
 8639    // Use different timestamps so sort order is deterministic.
 8640    save_thread_metadata(
 8641        acp::SessionId::new(Arc::from("hist-1")),
 8642        Some("Historical 1".into()),
 8643        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8644        None,
 8645        &project,
 8646        cx,
 8647    );
 8648    save_thread_metadata(
 8649        acp::SessionId::new(Arc::from("hist-2")),
 8650        Some("Historical 2".into()),
 8651        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 8652        None,
 8653        &project,
 8654        cx,
 8655    );
 8656    cx.run_until_parked();
 8657    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8658    cx.run_until_parked();
 8659
 8660    // Sanity-check: both threads exist under the initial key [/project-a].
 8661    let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
 8662    cx.update(|_window, cx| {
 8663        let store = ThreadMetadataStore::global(cx).read(cx);
 8664        assert_eq!(
 8665            store.entries_for_main_worktree_path(&old_key_paths).count(),
 8666            2,
 8667            "should have 2 historical threads under old key before worktree add"
 8668        );
 8669    });
 8670
 8671    // Add a second worktree to the project.
 8672    // TODO: Should there be different behavior for calling Project::find_or_create_worktree,
 8673    //       or MultiWorkspace::add_folders_to_project_group?
 8674    project
 8675        .update(cx, |project, cx| {
 8676            project.find_or_create_worktree("/project-b", true, cx)
 8677        })
 8678        .await
 8679        .expect("should add worktree");
 8680    cx.run_until_parked();
 8681
 8682    // The historical threads should now be indexed under the new combined
 8683    // key [/project-a, /project-b].
 8684    let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
 8685    cx.update(|_window, cx| {
 8686        let store = ThreadMetadataStore::global(cx).read(cx);
 8687        assert_eq!(
 8688            store.entries_for_main_worktree_path(&old_key_paths).count(),
 8689            0,
 8690            "should have 0 historical threads under old key after worktree add"
 8691        );
 8692        assert_eq!(
 8693            store.entries_for_main_worktree_path(&new_key_paths).count(),
 8694            2,
 8695            "should have 2 historical threads under new key after worktree add"
 8696        );
 8697    });
 8698
 8699    // Sidebar should show threads under the new header.
 8700    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8701    cx.run_until_parked();
 8702    assert_eq!(
 8703        visible_entries_as_strings(&sidebar, cx),
 8704        vec![
 8705            "v [project-a, project-b]",
 8706            "  Historical 2",
 8707            "  Historical 1",
 8708        ]
 8709    );
 8710
 8711    // Now remove the second worktree.
 8712    let worktree_id = project.read_with(cx, |project, cx| {
 8713        project
 8714            .visible_worktrees(cx)
 8715            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
 8716            .map(|wt| wt.read(cx).id())
 8717            .expect("should find project-b worktree")
 8718    });
 8719    project.update(cx, |project, cx| {
 8720        project.remove_worktree(worktree_id, cx);
 8721    });
 8722    cx.run_until_parked();
 8723
 8724    // Historical threads should migrate back to the original key.
 8725    cx.update(|_window, cx| {
 8726        let store = ThreadMetadataStore::global(cx).read(cx);
 8727        assert_eq!(
 8728            store.entries_for_main_worktree_path(&new_key_paths).count(),
 8729            0,
 8730            "should have 0 historical threads under new key after worktree remove"
 8731        );
 8732        assert_eq!(
 8733            store.entries_for_main_worktree_path(&old_key_paths).count(),
 8734            2,
 8735            "should have 2 historical threads under old key after worktree remove"
 8736        );
 8737    });
 8738
 8739    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8740    cx.run_until_parked();
 8741    assert_eq!(
 8742        visible_entries_as_strings(&sidebar, cx),
 8743        vec!["v [project-a]", "  Historical 2", "  Historical 1",]
 8744    );
 8745}
 8746
 8747#[gpui::test]
 8748async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut TestAppContext) {
 8749    // When two workspaces share the same project group (same main path)
 8750    // but have different folder paths (main repo vs linked worktree),
 8751    // adding a worktree to the main workspace should only migrate threads
 8752    // whose folder paths match that workspace — not the linked worktree's
 8753    // threads.
 8754    agent_ui::test_support::init_test(cx);
 8755    cx.update(|cx| {
 8756        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 8757        ThreadStore::init_global(cx);
 8758        ThreadMetadataStore::init_global(cx);
 8759        language_model::LanguageModelRegistry::test(cx);
 8760        prompt_store::init(cx);
 8761    });
 8762
 8763    let fs = FakeFs::new(cx.executor());
 8764    fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
 8765        .await;
 8766    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8767        .await;
 8768    fs.add_linked_worktree_for_repo(
 8769        Path::new("/project/.git"),
 8770        false,
 8771        git::repository::Worktree {
 8772            path: std::path::PathBuf::from("/wt-feature"),
 8773            ref_name: Some("refs/heads/feature".into()),
 8774            sha: "aaa".into(),
 8775            is_main: false,
 8776        },
 8777    )
 8778    .await;
 8779    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8780
 8781    // Workspace A: main repo at /project.
 8782    let main_project =
 8783        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
 8784    // Workspace B: linked worktree of the same repo (same group, different folder).
 8785    let worktree_project =
 8786        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
 8787
 8788    main_project
 8789        .update(cx, |p, cx| p.git_scans_complete(cx))
 8790        .await;
 8791    worktree_project
 8792        .update(cx, |p, cx| p.git_scans_complete(cx))
 8793        .await;
 8794
 8795    let (multi_workspace, cx) =
 8796        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 8797    let _sidebar = setup_sidebar(&multi_workspace, cx);
 8798    multi_workspace.update_in(cx, |mw, window, cx| {
 8799        mw.test_add_workspace(worktree_project.clone(), window, cx);
 8800    });
 8801    cx.run_until_parked();
 8802
 8803    // Save a thread for each workspace's folder paths.
 8804    let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
 8805    let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
 8806    save_thread_metadata(
 8807        acp::SessionId::new(Arc::from("thread-main")),
 8808        Some("Main Thread".into()),
 8809        time_main,
 8810        Some(time_main),
 8811        &main_project,
 8812        cx,
 8813    );
 8814    save_thread_metadata(
 8815        acp::SessionId::new(Arc::from("thread-wt")),
 8816        Some("Worktree Thread".into()),
 8817        time_wt,
 8818        Some(time_wt),
 8819        &worktree_project,
 8820        cx,
 8821    );
 8822    cx.run_until_parked();
 8823
 8824    let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
 8825    let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
 8826
 8827    // Sanity-check: each thread is indexed under its own folder paths.
 8828    cx.update(|_window, cx| {
 8829        let store = ThreadMetadataStore::global(cx).read(cx);
 8830        assert_eq!(
 8831            store.entries_for_path(&folder_paths_main).count(),
 8832            1,
 8833            "one thread under [/project]"
 8834        );
 8835        assert_eq!(
 8836            store.entries_for_path(&folder_paths_wt).count(),
 8837            1,
 8838            "one thread under [/wt-feature]"
 8839        );
 8840    });
 8841
 8842    // Add /project-b to the main project only.
 8843    main_project
 8844        .update(cx, |project, cx| {
 8845            project.find_or_create_worktree("/project-b", true, cx)
 8846        })
 8847        .await
 8848        .expect("should add worktree");
 8849    cx.run_until_parked();
 8850
 8851    // Main Thread (folder paths [/project]) should have migrated to
 8852    // [/project, /project-b]. Worktree Thread should be unchanged.
 8853    let folder_paths_main_b =
 8854        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
 8855    cx.update(|_window, cx| {
 8856        let store = ThreadMetadataStore::global(cx).read(cx);
 8857        assert_eq!(
 8858            store.entries_for_path(&folder_paths_main).count(),
 8859            0,
 8860            "main thread should no longer be under old folder paths [/project]"
 8861        );
 8862        assert_eq!(
 8863            store.entries_for_path(&folder_paths_main_b).count(),
 8864            1,
 8865            "main thread should now be under [/project, /project-b]"
 8866        );
 8867        assert_eq!(
 8868            store.entries_for_path(&folder_paths_wt).count(),
 8869            1,
 8870            "worktree thread should remain unchanged under [/wt-feature]"
 8871        );
 8872    });
 8873}
 8874
 8875#[gpui::test]
 8876async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
 8877    cx: &mut TestAppContext,
 8878) {
 8879    // When a linked worktree is opened as its own workspace and then a new
 8880    // folder is added to the main project group, the linked worktree
 8881    // workspace must still be reachable from some sidebar entry.
 8882    let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
 8883    let fs = _fs.clone();
 8884
 8885    // Set up git worktree infrastructure.
 8886    fs.insert_tree(
 8887        "/my-project/.git/worktrees/wt-0",
 8888        serde_json::json!({
 8889            "commondir": "../../",
 8890            "HEAD": "ref: refs/heads/wt-0",
 8891        }),
 8892    )
 8893    .await;
 8894    fs.insert_tree(
 8895        "/worktrees/wt-0",
 8896        serde_json::json!({
 8897            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8898            "src": {},
 8899        }),
 8900    )
 8901    .await;
 8902    fs.add_linked_worktree_for_repo(
 8903        Path::new("/my-project/.git"),
 8904        false,
 8905        git::repository::Worktree {
 8906            path: PathBuf::from("/worktrees/wt-0"),
 8907            ref_name: Some("refs/heads/wt-0".into()),
 8908            sha: "aaa".into(),
 8909            is_main: false,
 8910        },
 8911    )
 8912    .await;
 8913
 8914    // Re-scan so the main project discovers the linked worktree.
 8915    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8916
 8917    let (multi_workspace, cx) =
 8918        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8919    let sidebar = setup_sidebar(&multi_workspace, cx);
 8920
 8921    // Open the linked worktree as its own workspace.
 8922    let worktree_project = project::Project::test(
 8923        fs.clone() as Arc<dyn fs::Fs>,
 8924        ["/worktrees/wt-0".as_ref()],
 8925        cx,
 8926    )
 8927    .await;
 8928    worktree_project
 8929        .update(cx, |p, cx| p.git_scans_complete(cx))
 8930        .await;
 8931    multi_workspace.update_in(cx, |mw, window, cx| {
 8932        mw.test_add_workspace(worktree_project.clone(), window, cx);
 8933    });
 8934    cx.run_until_parked();
 8935
 8936    // Both workspaces should be reachable.
 8937    let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 8938    assert_eq!(workspace_count, 2, "should have 2 workspaces");
 8939
 8940    // Add a new folder to the main project, changing the project group key.
 8941    fs.insert_tree(
 8942        "/other-project",
 8943        serde_json::json!({ ".git": {}, "src": {} }),
 8944    )
 8945    .await;
 8946    project
 8947        .update(cx, |project, cx| {
 8948            project.find_or_create_worktree("/other-project", true, cx)
 8949        })
 8950        .await
 8951        .expect("should add worktree");
 8952    cx.run_until_parked();
 8953
 8954    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8955    cx.run_until_parked();
 8956
 8957    // The linked worktree workspace must still be reachable.
 8958    let entries = visible_entries_as_strings(&sidebar, cx);
 8959    let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
 8960        mw.workspaces().map(|ws| ws.entity_id()).collect()
 8961    });
 8962    sidebar.read_with(cx, |sidebar, cx| {
 8963        let multi_workspace = multi_workspace.read(cx);
 8964        let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
 8965            .contents
 8966            .entries
 8967            .iter()
 8968            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 8969            .map(|ws| ws.entity_id())
 8970            .collect();
 8971        let all: std::collections::HashSet<gpui::EntityId> =
 8972            mw_workspaces.iter().copied().collect();
 8973        let unreachable = &all - &reachable;
 8974        assert!(
 8975            unreachable.is_empty(),
 8976            "all workspaces should be reachable after adding folder; \
 8977             unreachable: {:?}, entries: {:?}",
 8978            unreachable,
 8979            entries,
 8980        );
 8981    });
 8982}
 8983
 8984mod property_test {
 8985    use super::*;
 8986    use gpui::proptest::prelude::*;
 8987
 8988    struct UnopenedWorktree {
 8989        path: String,
 8990        main_workspace_path: String,
 8991    }
 8992
 8993    struct TestState {
 8994        fs: Arc<FakeFs>,
 8995        thread_counter: u32,
 8996        workspace_counter: u32,
 8997        worktree_counter: u32,
 8998        saved_thread_ids: Vec<acp::SessionId>,
 8999        unopened_worktrees: Vec<UnopenedWorktree>,
 9000    }
 9001
 9002    impl TestState {
 9003        fn new(fs: Arc<FakeFs>) -> Self {
 9004            Self {
 9005                fs,
 9006                thread_counter: 0,
 9007                workspace_counter: 1,
 9008                worktree_counter: 0,
 9009                saved_thread_ids: Vec::new(),
 9010                unopened_worktrees: Vec::new(),
 9011            }
 9012        }
 9013
 9014        fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
 9015            let id = self.thread_counter;
 9016            self.thread_counter += 1;
 9017            acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
 9018        }
 9019
 9020        fn next_workspace_path(&mut self) -> String {
 9021            let id = self.workspace_counter;
 9022            self.workspace_counter += 1;
 9023            format!("/prop-project-{id}")
 9024        }
 9025
 9026        fn next_worktree_name(&mut self) -> String {
 9027            let id = self.worktree_counter;
 9028            self.worktree_counter += 1;
 9029            format!("wt-{id}")
 9030        }
 9031    }
 9032
 9033    #[derive(Debug)]
 9034    enum Operation {
 9035        SaveThread { project_group_index: usize },
 9036        SaveWorktreeThread { worktree_index: usize },
 9037        ToggleAgentPanel,
 9038        CreateDraftThread,
 9039        AddProject { use_worktree: bool },
 9040        ArchiveThread { index: usize },
 9041        SwitchToThread { index: usize },
 9042        SwitchToProjectGroup { index: usize },
 9043        AddLinkedWorktree { project_group_index: usize },
 9044        AddWorktreeToProject { project_group_index: usize },
 9045        RemoveWorktreeFromProject { project_group_index: usize },
 9046    }
 9047
 9048    // Distribution (out of 24 slots):
 9049    //   SaveThread:                5 slots (~21%)
 9050    //   SaveWorktreeThread:        2 slots (~8%)
 9051    //   ToggleAgentPanel:          1 slot  (~4%)
 9052    //   CreateDraftThread:         1 slot  (~4%)
 9053    //   AddProject:                1 slot  (~4%)
 9054    //   ArchiveThread:             2 slots (~8%)
 9055    //   SwitchToThread:            2 slots (~8%)
 9056    //   SwitchToProjectGroup:      2 slots (~8%)
 9057    //   AddLinkedWorktree:         4 slots (~17%)
 9058    //   AddWorktreeToProject:      2 slots (~8%)
 9059    //   RemoveWorktreeFromProject: 2 slots (~8%)
 9060    const DISTRIBUTION_SLOTS: u32 = 24;
 9061
 9062    impl TestState {
 9063        fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
 9064            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
 9065
 9066            match raw % DISTRIBUTION_SLOTS {
 9067                0..=4 => Operation::SaveThread {
 9068                    project_group_index: extra % project_group_count,
 9069                },
 9070                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
 9071                    worktree_index: extra % self.unopened_worktrees.len(),
 9072                },
 9073                5..=6 => Operation::SaveThread {
 9074                    project_group_index: extra % project_group_count,
 9075                },
 9076                7 => Operation::ToggleAgentPanel,
 9077                8 => Operation::CreateDraftThread,
 9078                9 => Operation::AddProject {
 9079                    use_worktree: !self.unopened_worktrees.is_empty(),
 9080                },
 9081                10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
 9082                    index: extra % self.saved_thread_ids.len(),
 9083                },
 9084                10..=11 => Operation::AddProject {
 9085                    use_worktree: !self.unopened_worktrees.is_empty(),
 9086                },
 9087                12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
 9088                    index: extra % self.saved_thread_ids.len(),
 9089                },
 9090                12..=13 => Operation::SwitchToProjectGroup {
 9091                    index: extra % project_group_count,
 9092                },
 9093                14..=15 => Operation::SwitchToProjectGroup {
 9094                    index: extra % project_group_count,
 9095                },
 9096                16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
 9097                    project_group_index: extra % project_group_count,
 9098                },
 9099                16..=19 => Operation::SaveThread {
 9100                    project_group_index: extra % project_group_count,
 9101                },
 9102                20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
 9103                    project_group_index: extra % project_group_count,
 9104                },
 9105                20..=21 => Operation::SaveThread {
 9106                    project_group_index: extra % project_group_count,
 9107                },
 9108                22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
 9109                    project_group_index: extra % project_group_count,
 9110                },
 9111                22..=23 => Operation::SaveThread {
 9112                    project_group_index: extra % project_group_count,
 9113                },
 9114                _ => unreachable!(),
 9115            }
 9116        }
 9117    }
 9118
 9119    fn save_thread_to_path_with_main(
 9120        state: &mut TestState,
 9121        path_list: PathList,
 9122        main_worktree_paths: PathList,
 9123        cx: &mut gpui::VisualTestContext,
 9124    ) {
 9125        let session_id = state.next_metadata_only_thread_id();
 9126        let title: SharedString = format!("Thread {}", session_id).into();
 9127        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9128            .unwrap()
 9129            + chrono::Duration::seconds(state.thread_counter as i64);
 9130        let metadata = ThreadMetadata {
 9131            thread_id: ThreadId::new(),
 9132            session_id: Some(session_id),
 9133            agent_id: agent::ZED_AGENT_ID.clone(),
 9134            title: Some(title),
 9135            updated_at,
 9136            created_at: None,
 9137            worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
 9138            archived: false,
 9139            remote_connection: None,
 9140        };
 9141        cx.update(|_, cx| {
 9142            ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
 9143        });
 9144        cx.run_until_parked();
 9145    }
 9146
 9147    async fn perform_operation(
 9148        operation: Operation,
 9149        state: &mut TestState,
 9150        multi_workspace: &Entity<MultiWorkspace>,
 9151        sidebar: &Entity<Sidebar>,
 9152        cx: &mut gpui::VisualTestContext,
 9153    ) {
 9154        match operation {
 9155            Operation::SaveThread {
 9156                project_group_index,
 9157            } => {
 9158                // Find a workspace for this project group and create a real
 9159                // thread via its agent panel.
 9160                let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
 9161                    let keys = mw.project_group_keys();
 9162                    let key = &keys[project_group_index];
 9163                    let ws = mw
 9164                        .workspaces_for_project_group(key, cx)
 9165                        .and_then(|ws| ws.first().cloned())
 9166                        .unwrap_or_else(|| mw.workspace().clone());
 9167                    let project = ws.read(cx).project().clone();
 9168                    (ws, project)
 9169                });
 9170
 9171                let panel =
 9172                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9173                if let Some(panel) = panel {
 9174                    let connection = StubAgentConnection::new();
 9175                    connection.set_next_prompt_updates(vec![
 9176                        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
 9177                            "Done".into(),
 9178                        )),
 9179                    ]);
 9180                    open_thread_with_connection(&panel, connection, cx);
 9181                    send_message(&panel, cx);
 9182                    let session_id = active_session_id(&panel, cx);
 9183                    state.saved_thread_ids.push(session_id.clone());
 9184
 9185                    let title: SharedString = format!("Thread {}", state.thread_counter).into();
 9186                    state.thread_counter += 1;
 9187                    let updated_at =
 9188                        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9189                            .unwrap()
 9190                            + chrono::Duration::seconds(state.thread_counter as i64);
 9191                    save_thread_metadata(session_id, Some(title), updated_at, None, &project, cx);
 9192                }
 9193            }
 9194            Operation::SaveWorktreeThread { worktree_index } => {
 9195                let worktree = &state.unopened_worktrees[worktree_index];
 9196                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
 9197                let main_worktree_paths =
 9198                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
 9199                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
 9200            }
 9201
 9202            Operation::ToggleAgentPanel => {
 9203                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9204                let panel_open =
 9205                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
 9206                workspace.update_in(cx, |workspace, window, cx| {
 9207                    if panel_open {
 9208                        workspace.close_panel::<AgentPanel>(window, cx);
 9209                    } else {
 9210                        workspace.open_panel::<AgentPanel>(window, cx);
 9211                    }
 9212                });
 9213            }
 9214            Operation::CreateDraftThread => {
 9215                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9216                let panel =
 9217                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9218                if let Some(panel) = panel {
 9219                    panel.update_in(cx, |panel, window, cx| {
 9220                        panel.new_thread(&NewThread, window, cx);
 9221                    });
 9222                    cx.run_until_parked();
 9223                }
 9224                workspace.update_in(cx, |workspace, window, cx| {
 9225                    workspace.focus_panel::<AgentPanel>(window, cx);
 9226                });
 9227            }
 9228            Operation::AddProject { use_worktree } => {
 9229                let path = if use_worktree {
 9230                    // Open an existing linked worktree as a project (simulates Cmd+O
 9231                    // on a worktree directory).
 9232                    state.unopened_worktrees.remove(0).path
 9233                } else {
 9234                    // Create a brand new project.
 9235                    let path = state.next_workspace_path();
 9236                    state
 9237                        .fs
 9238                        .insert_tree(
 9239                            &path,
 9240                            serde_json::json!({
 9241                                ".git": {},
 9242                                "src": {},
 9243                            }),
 9244                        )
 9245                        .await;
 9246                    path
 9247                };
 9248                let project = project::Project::test(
 9249                    state.fs.clone() as Arc<dyn fs::Fs>,
 9250                    [path.as_ref()],
 9251                    cx,
 9252                )
 9253                .await;
 9254                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9255                multi_workspace.update_in(cx, |mw, window, cx| {
 9256                    mw.test_add_workspace(project.clone(), window, cx)
 9257                });
 9258            }
 9259
 9260            Operation::ArchiveThread { index } => {
 9261                let session_id = state.saved_thread_ids[index].clone();
 9262                sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 9263                    sidebar.archive_thread(&session_id, window, cx);
 9264                });
 9265                cx.run_until_parked();
 9266                state.saved_thread_ids.remove(index);
 9267            }
 9268            Operation::SwitchToThread { index } => {
 9269                let session_id = state.saved_thread_ids[index].clone();
 9270                // Find the thread's position in the sidebar entries and select it.
 9271                let thread_index = sidebar.read_with(cx, |sidebar, _| {
 9272                    sidebar.contents.entries.iter().position(|entry| {
 9273                        matches!(
 9274                            entry,
 9275                            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
 9276                        )
 9277                    })
 9278                });
 9279                if let Some(ix) = thread_index {
 9280                    sidebar.update_in(cx, |sidebar, window, cx| {
 9281                        sidebar.selection = Some(ix);
 9282                        sidebar.confirm(&Confirm, window, cx);
 9283                    });
 9284                    cx.run_until_parked();
 9285                }
 9286            }
 9287            Operation::SwitchToProjectGroup { index } => {
 9288                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9289                    let keys = mw.project_group_keys();
 9290                    let key = &keys[index];
 9291                    mw.workspaces_for_project_group(key, cx)
 9292                        .and_then(|ws| ws.first().cloned())
 9293                        .unwrap_or_else(|| mw.workspace().clone())
 9294                });
 9295                multi_workspace.update_in(cx, |mw, window, cx| {
 9296                    mw.activate(workspace, window, cx);
 9297                });
 9298            }
 9299            Operation::AddLinkedWorktree {
 9300                project_group_index,
 9301            } => {
 9302                // Get the main worktree path from the project group key.
 9303                let main_path = multi_workspace.read_with(cx, |mw, _| {
 9304                    let keys = mw.project_group_keys();
 9305                    let key = &keys[project_group_index];
 9306                    key.path_list()
 9307                        .paths()
 9308                        .first()
 9309                        .unwrap()
 9310                        .to_string_lossy()
 9311                        .to_string()
 9312                });
 9313                let dot_git = format!("{}/.git", main_path);
 9314                let worktree_name = state.next_worktree_name();
 9315                let worktree_path = format!("/worktrees/{}", worktree_name);
 9316
 9317                state.fs
 9318                    .insert_tree(
 9319                        &worktree_path,
 9320                        serde_json::json!({
 9321                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
 9322                            "src": {},
 9323                        }),
 9324                    )
 9325                    .await;
 9326
 9327                // Also create the worktree metadata dir inside the main repo's .git
 9328                state
 9329                    .fs
 9330                    .insert_tree(
 9331                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
 9332                        serde_json::json!({
 9333                            "commondir": "../../",
 9334                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
 9335                        }),
 9336                    )
 9337                    .await;
 9338
 9339                let dot_git_path = std::path::Path::new(&dot_git);
 9340                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
 9341                state
 9342                    .fs
 9343                    .add_linked_worktree_for_repo(
 9344                        dot_git_path,
 9345                        false,
 9346                        git::repository::Worktree {
 9347                            path: worktree_pathbuf,
 9348                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 9349                            sha: "aaa".into(),
 9350                            is_main: false,
 9351                        },
 9352                    )
 9353                    .await;
 9354
 9355                // Re-scan the main workspace's project so it discovers the new worktree.
 9356                let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
 9357                    let keys = mw.project_group_keys();
 9358                    let key = &keys[project_group_index];
 9359                    mw.workspaces_for_project_group(key, cx)
 9360                        .and_then(|ws| ws.first().cloned())
 9361                        .unwrap()
 9362                });
 9363                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 9364                main_project
 9365                    .update(cx, |p, cx| p.git_scans_complete(cx))
 9366                    .await;
 9367
 9368                state.unopened_worktrees.push(UnopenedWorktree {
 9369                    path: worktree_path,
 9370                    main_workspace_path: main_path.clone(),
 9371                });
 9372            }
 9373            Operation::AddWorktreeToProject {
 9374                project_group_index,
 9375            } => {
 9376                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9377                    let keys = mw.project_group_keys();
 9378                    let key = &keys[project_group_index];
 9379                    mw.workspaces_for_project_group(key, cx)
 9380                        .and_then(|ws| ws.first().cloned())
 9381                });
 9382                let Some(workspace) = workspace else { return };
 9383                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9384
 9385                let new_path = state.next_workspace_path();
 9386                state
 9387                    .fs
 9388                    .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
 9389                    .await;
 9390
 9391                let result = project
 9392                    .update(cx, |project, cx| {
 9393                        project.find_or_create_worktree(&new_path, true, cx)
 9394                    })
 9395                    .await;
 9396                if result.is_err() {
 9397                    return;
 9398                }
 9399                cx.run_until_parked();
 9400            }
 9401            Operation::RemoveWorktreeFromProject {
 9402                project_group_index,
 9403            } => {
 9404                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9405                    let keys = mw.project_group_keys();
 9406                    let key = &keys[project_group_index];
 9407                    mw.workspaces_for_project_group(key, cx)
 9408                        .and_then(|ws| ws.first().cloned())
 9409                });
 9410                let Some(workspace) = workspace else { return };
 9411                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9412
 9413                let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
 9414                if worktree_count <= 1 {
 9415                    return;
 9416                }
 9417
 9418                let worktree_id = project.read_with(cx, |p, cx| {
 9419                    p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
 9420                });
 9421                if let Some(worktree_id) = worktree_id {
 9422                    project.update(cx, |project, cx| {
 9423                        project.remove_worktree(worktree_id, cx);
 9424                    });
 9425                    cx.run_until_parked();
 9426                }
 9427            }
 9428        }
 9429    }
 9430
 9431    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
 9432        sidebar.update_in(cx, |sidebar, _window, cx| {
 9433            if let Some(mw) = sidebar.multi_workspace.upgrade() {
 9434                mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 9435            }
 9436            sidebar.update_entries(cx);
 9437        });
 9438    }
 9439
 9440    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9441        verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
 9442        verify_no_duplicate_threads(sidebar)?;
 9443        verify_all_threads_are_shown(sidebar, cx)?;
 9444        verify_active_state_matches_current_workspace(sidebar, cx)?;
 9445        verify_all_workspaces_are_reachable(sidebar, cx)?;
 9446        verify_workspace_group_key_integrity(sidebar, cx)?;
 9447        Ok(())
 9448    }
 9449
 9450    fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
 9451        let mut seen: HashSet<acp::SessionId> = HashSet::default();
 9452        let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
 9453
 9454        for entry in &sidebar.contents.entries {
 9455            if let Some(session_id) = entry.session_id() {
 9456                if !seen.insert(session_id.clone()) {
 9457                    let title = match entry {
 9458                        ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
 9459                        _ => "<unknown>".to_string(),
 9460                    };
 9461                    duplicates.push((session_id.clone(), title));
 9462                }
 9463            }
 9464        }
 9465
 9466        anyhow::ensure!(
 9467            duplicates.is_empty(),
 9468            "threads appear more than once in sidebar: {:?}",
 9469            duplicates,
 9470        );
 9471        Ok(())
 9472    }
 9473
 9474    fn verify_every_group_in_multiworkspace_is_shown(
 9475        sidebar: &Sidebar,
 9476        cx: &App,
 9477    ) -> anyhow::Result<()> {
 9478        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9479            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9480        };
 9481
 9482        let mw = multi_workspace.read(cx);
 9483
 9484        // Every project group key in the multi-workspace that has a
 9485        // non-empty path list should appear as a ProjectHeader in the
 9486        // sidebar.
 9487        let all_keys = mw.project_group_keys();
 9488        let expected_keys: HashSet<&ProjectGroupKey> = all_keys
 9489            .iter()
 9490            .filter(|k| !k.path_list().paths().is_empty())
 9491            .collect();
 9492
 9493        let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
 9494            .contents
 9495            .entries
 9496            .iter()
 9497            .filter_map(|entry| match entry {
 9498                ListEntry::ProjectHeader { key, .. } => Some(key),
 9499                _ => None,
 9500            })
 9501            .collect();
 9502
 9503        let missing = &expected_keys - &sidebar_keys;
 9504        let stray = &sidebar_keys - &expected_keys;
 9505
 9506        anyhow::ensure!(
 9507            missing.is_empty() && stray.is_empty(),
 9508            "sidebar project groups don't match multi-workspace.\n\
 9509             Only in multi-workspace (missing): {:?}\n\
 9510             Only in sidebar (stray): {:?}",
 9511            missing,
 9512            stray,
 9513        );
 9514
 9515        Ok(())
 9516    }
 9517
 9518    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9519        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9520            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9521        };
 9522        let workspaces = multi_workspace
 9523            .read(cx)
 9524            .workspaces()
 9525            .cloned()
 9526            .collect::<Vec<_>>();
 9527        let thread_store = ThreadMetadataStore::global(cx);
 9528
 9529        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
 9530            .contents
 9531            .entries
 9532            .iter()
 9533            .filter_map(|entry| entry.session_id().cloned())
 9534            .collect();
 9535
 9536        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
 9537
 9538        // Query using the same approach as the sidebar: iterate project
 9539        // group keys, then do main + legacy queries per group.
 9540        let mw = multi_workspace.read(cx);
 9541        let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
 9542            HashMap::default();
 9543        for workspace in &workspaces {
 9544            let key = workspace.read(cx).project_group_key(cx);
 9545            workspaces_by_group
 9546                .entry(key)
 9547                .or_default()
 9548                .push(workspace.clone());
 9549        }
 9550
 9551        for group_key in mw.project_group_keys() {
 9552            let path_list = group_key.path_list().clone();
 9553            if path_list.paths().is_empty() {
 9554                continue;
 9555            }
 9556
 9557            let group_workspaces = workspaces_by_group
 9558                .get(&group_key)
 9559                .map(|ws| ws.as_slice())
 9560                .unwrap_or_default();
 9561
 9562            // Main code path queries (run for all groups, even without workspaces).
 9563            // Skip drafts (session_id: None) — they are shown via the
 9564            // panel's draft_thread_ids, not by session_id matching.
 9565            for metadata in thread_store
 9566                .read(cx)
 9567                .entries_for_main_worktree_path(&path_list)
 9568            {
 9569                if let Some(sid) = metadata.session_id.clone() {
 9570                    metadata_thread_ids.insert(sid);
 9571                }
 9572            }
 9573            for metadata in thread_store.read(cx).entries_for_path(&path_list) {
 9574                if let Some(sid) = metadata.session_id.clone() {
 9575                    metadata_thread_ids.insert(sid);
 9576                }
 9577            }
 9578
 9579            // Legacy: per-workspace queries for different root paths.
 9580            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
 9581                .iter()
 9582                .flat_map(|ws| {
 9583                    ws.read(cx)
 9584                        .root_paths(cx)
 9585                        .into_iter()
 9586                        .map(|p| p.to_path_buf())
 9587                })
 9588                .collect();
 9589
 9590            for workspace in group_workspaces {
 9591                let ws_path_list = workspace_path_list(workspace, cx);
 9592                if ws_path_list != path_list {
 9593                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list) {
 9594                        if let Some(sid) = metadata.session_id.clone() {
 9595                            metadata_thread_ids.insert(sid);
 9596                        }
 9597                    }
 9598                }
 9599            }
 9600
 9601            for workspace in group_workspaces {
 9602                for snapshot in root_repository_snapshots(workspace, cx) {
 9603                    let repo_path_list =
 9604                        PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
 9605                    if repo_path_list != path_list {
 9606                        continue;
 9607                    }
 9608                    for linked_worktree in snapshot.linked_worktrees() {
 9609                        if covered_paths.contains(&*linked_worktree.path) {
 9610                            continue;
 9611                        }
 9612                        let worktree_path_list =
 9613                            PathList::new(std::slice::from_ref(&linked_worktree.path));
 9614                        for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list)
 9615                        {
 9616                            if let Some(sid) = metadata.session_id.clone() {
 9617                                metadata_thread_ids.insert(sid);
 9618                            }
 9619                        }
 9620                    }
 9621                }
 9622            }
 9623        }
 9624
 9625        anyhow::ensure!(
 9626            sidebar_thread_ids == metadata_thread_ids,
 9627            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
 9628            sidebar_thread_ids,
 9629            metadata_thread_ids,
 9630        );
 9631        Ok(())
 9632    }
 9633
 9634    fn verify_active_state_matches_current_workspace(
 9635        sidebar: &Sidebar,
 9636        cx: &App,
 9637    ) -> anyhow::Result<()> {
 9638        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9639            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9640        };
 9641
 9642        let active_workspace = multi_workspace.read(cx).workspace();
 9643
 9644        // 1. active_entry should be Some when the panel has content.
 9645        //    It may be None when the panel is uninitialized (no drafts,
 9646        //    no threads), which is fine.
 9647        //    It may also temporarily point at a different workspace
 9648        //    when the workspace just changed and the new panel has no
 9649        //    content yet.
 9650        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
 9651        let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
 9652            || panel.read(cx).active_conversation_view().is_some();
 9653
 9654        let Some(entry) = sidebar.active_entry.as_ref() else {
 9655            if panel_has_content {
 9656                anyhow::bail!("active_entry is None but panel has content (draft or thread)");
 9657            }
 9658            return Ok(());
 9659        };
 9660
 9661        // If the entry workspace doesn't match the active workspace
 9662        // and the panel has no content, this is a transient state that
 9663        // will resolve when the panel gets content.
 9664        if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
 9665            return Ok(());
 9666        }
 9667
 9668        // 2. The entry's workspace must agree with the multi-workspace's
 9669        //    active workspace.
 9670        anyhow::ensure!(
 9671            entry.workspace().entity_id() == active_workspace.entity_id(),
 9672            "active_entry workspace ({:?}) != active workspace ({:?})",
 9673            entry.workspace().entity_id(),
 9674            active_workspace.entity_id(),
 9675        );
 9676
 9677        // 3. The entry must match the agent panel's current state.
 9678        if panel.read(cx).active_thread_id(cx).is_some() {
 9679            anyhow::ensure!(
 9680                matches!(entry, ActiveEntry { .. }),
 9681                "panel shows a tracked draft but active_entry is {:?}",
 9682                entry,
 9683            );
 9684        } else if let Some(thread_id) = panel
 9685            .read(cx)
 9686            .active_conversation_view()
 9687            .map(|cv| cv.read(cx).parent_id())
 9688        {
 9689            anyhow::ensure!(
 9690                matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
 9691                "panel has thread {:?} but active_entry is {:?}",
 9692                thread_id,
 9693                entry,
 9694            );
 9695        }
 9696
 9697        // 4. Exactly one entry in sidebar contents must be uniquely
 9698        //    identified by the active_entry.
 9699        let matching_count = sidebar
 9700            .contents
 9701            .entries
 9702            .iter()
 9703            .filter(|e| entry.matches_entry(e))
 9704            .count();
 9705        if matching_count != 1 {
 9706            let thread_entries: Vec<_> = sidebar
 9707                .contents
 9708                .entries
 9709                .iter()
 9710                .filter_map(|e| match e {
 9711                    ListEntry::Thread(t) => Some(format!(
 9712                        "tid={:?} sid={:?} draft={}",
 9713                        t.metadata.thread_id, t.metadata.session_id, t.is_draft
 9714                    )),
 9715                    _ => None,
 9716                })
 9717                .collect();
 9718            let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
 9719            let store_entries: Vec<_> = store
 9720                .entries()
 9721                .map(|m| {
 9722                    format!(
 9723                        "tid={:?} sid={:?} archived={} paths={:?}",
 9724                        m.thread_id,
 9725                        m.session_id,
 9726                        m.archived,
 9727                        m.folder_paths()
 9728                    )
 9729                })
 9730                .collect();
 9731            anyhow::bail!(
 9732                "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
 9733                entry,
 9734                matching_count,
 9735                thread_entries,
 9736                store_entries,
 9737            );
 9738        }
 9739
 9740        Ok(())
 9741    }
 9742
 9743    /// Every workspace in the multi-workspace should be "reachable" from
 9744    /// the sidebar — meaning there is at least one entry (thread, draft,
 9745    /// new-thread, or project header) that, when clicked, would activate
 9746    /// that workspace.
 9747    fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9748        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9749            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9750        };
 9751
 9752        let multi_workspace = multi_workspace.read(cx);
 9753
 9754        let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
 9755            .contents
 9756            .entries
 9757            .iter()
 9758            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 9759            .map(|ws| ws.entity_id())
 9760            .collect();
 9761
 9762        let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
 9763            .workspaces()
 9764            .map(|ws| ws.entity_id())
 9765            .collect();
 9766
 9767        let unreachable = &all_workspace_ids - &reachable_workspaces;
 9768
 9769        anyhow::ensure!(
 9770            unreachable.is_empty(),
 9771            "The following workspaces are not reachable from any sidebar entry: {:?}",
 9772            unreachable,
 9773        );
 9774
 9775        Ok(())
 9776    }
 9777
 9778    fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9779        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9780            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9781        };
 9782        multi_workspace
 9783            .read(cx)
 9784            .assert_project_group_key_integrity(cx)
 9785    }
 9786
 9787    #[gpui::property_test(config = ProptestConfig {
 9788        cases: 20,
 9789        ..Default::default()
 9790    })]
 9791    async fn test_sidebar_invariants(
 9792        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
 9793        raw_operations: Vec<u32>,
 9794        cx: &mut TestAppContext,
 9795    ) {
 9796        use std::sync::atomic::{AtomicUsize, Ordering};
 9797        static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
 9798
 9799        agent_ui::test_support::init_test(cx);
 9800        cx.update(|cx| {
 9801            cx.set_global(db::AppDatabase::test_new());
 9802            cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 9803            cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
 9804                format!(
 9805                    "PROPTEST_THREAD_METADATA_{}",
 9806                    NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
 9807                ),
 9808            ));
 9809
 9810            ThreadStore::init_global(cx);
 9811            ThreadMetadataStore::init_global(cx);
 9812            language_model::LanguageModelRegistry::test(cx);
 9813            prompt_store::init(cx);
 9814
 9815            // Auto-add an AgentPanel to every workspace so that implicitly
 9816            // created workspaces (e.g. from thread activation) also have one.
 9817            cx.observe_new(
 9818                |workspace: &mut Workspace,
 9819                 window: Option<&mut Window>,
 9820                 cx: &mut gpui::Context<Workspace>| {
 9821                    if let Some(window) = window {
 9822                        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 9823                        workspace.add_panel(panel, window, cx);
 9824                    }
 9825                },
 9826            )
 9827            .detach();
 9828        });
 9829
 9830        let fs = FakeFs::new(cx.executor());
 9831        fs.insert_tree(
 9832            "/my-project",
 9833            serde_json::json!({
 9834                ".git": {},
 9835                "src": {},
 9836            }),
 9837        )
 9838        .await;
 9839        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 9840        let project =
 9841            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
 9842                .await;
 9843        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9844
 9845        let (multi_workspace, cx) =
 9846            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9847        let sidebar = setup_sidebar(&multi_workspace, cx);
 9848
 9849        let mut state = TestState::new(fs);
 9850        let mut executed: Vec<String> = Vec::new();
 9851
 9852        for &raw_op in &raw_operations {
 9853            let project_group_count =
 9854                multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
 9855            let operation = state.generate_operation(raw_op, project_group_count);
 9856            executed.push(format!("{:?}", operation));
 9857            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
 9858            cx.run_until_parked();
 9859
 9860            update_sidebar(&sidebar, cx);
 9861            cx.run_until_parked();
 9862
 9863            let result =
 9864                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
 9865            if let Err(err) = result {
 9866                let log = executed.join("\n  ");
 9867                panic!(
 9868                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
 9869                    executed.len(),
 9870                );
 9871            }
 9872        }
 9873    }
 9874}
 9875
 9876#[gpui::test]
 9877async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
 9878    cx: &mut TestAppContext,
 9879    server_cx: &mut TestAppContext,
 9880) {
 9881    init_test(cx);
 9882
 9883    cx.update(|cx| {
 9884        release_channel::init(semver::Version::new(0, 0, 0), cx);
 9885    });
 9886
 9887    let app_state = cx.update(|cx| {
 9888        let app_state = workspace::AppState::test(cx);
 9889        workspace::init(app_state.clone(), cx);
 9890        app_state
 9891    });
 9892
 9893    // Set up the remote server side.
 9894    let server_fs = FakeFs::new(server_cx.executor());
 9895    server_fs
 9896        .insert_tree(
 9897            "/project",
 9898            serde_json::json!({
 9899                ".git": {},
 9900                "src": { "main.rs": "fn main() {}" }
 9901            }),
 9902        )
 9903        .await;
 9904    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
 9905
 9906    // Create the linked worktree checkout path on the remote server,
 9907    // but do not yet register it as a git-linked worktree. The real
 9908    // regrouping update in this test should happen only after the
 9909    // sidebar opens the closed remote thread.
 9910    server_fs
 9911        .insert_tree(
 9912            "/project-wt-1",
 9913            serde_json::json!({
 9914                "src": { "main.rs": "fn main() {}" }
 9915            }),
 9916        )
 9917        .await;
 9918
 9919    server_cx.update(|cx| {
 9920        release_channel::init(semver::Version::new(0, 0, 0), cx);
 9921    });
 9922
 9923    let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
 9924
 9925    server_cx.update(remote_server::HeadlessProject::init);
 9926    let server_executor = server_cx.executor();
 9927    let _headless = server_cx.new(|cx| {
 9928        remote_server::HeadlessProject::new(
 9929            remote_server::HeadlessAppState {
 9930                session: server_session,
 9931                fs: server_fs.clone(),
 9932                http_client: Arc::new(http_client::BlockedHttpClient),
 9933                node_runtime: node_runtime::NodeRuntime::unavailable(),
 9934                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
 9935                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
 9936                startup_time: std::time::Instant::now(),
 9937            },
 9938            false,
 9939            cx,
 9940        )
 9941    });
 9942
 9943    // Connect the client side and build a remote project.
 9944    let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
 9945    let project = cx.update(|cx| {
 9946        let project_client = client::Client::new(
 9947            Arc::new(clock::FakeSystemClock::new()),
 9948            http_client::FakeHttpClient::with_404_response(),
 9949            cx,
 9950        );
 9951        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
 9952        project::Project::remote(
 9953            remote_client,
 9954            project_client,
 9955            node_runtime::NodeRuntime::unavailable(),
 9956            user_store,
 9957            app_state.languages.clone(),
 9958            app_state.fs.clone(),
 9959            false,
 9960            cx,
 9961        )
 9962    });
 9963
 9964    // Open the remote worktree.
 9965    project
 9966        .update(cx, |project, cx| {
 9967            project.find_or_create_worktree(Path::new("/project"), true, cx)
 9968        })
 9969        .await
 9970        .expect("should open remote worktree");
 9971    cx.run_until_parked();
 9972
 9973    // Verify the project is remote.
 9974    project.read_with(cx, |project, cx| {
 9975        assert!(!project.is_local(), "project should be remote");
 9976        assert!(
 9977            project.remote_connection_options(cx).is_some(),
 9978            "project should have remote connection options"
 9979        );
 9980    });
 9981
 9982    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
 9983
 9984    // Create MultiWorkspace with the remote project.
 9985    let (multi_workspace, cx) =
 9986        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9987    let sidebar = setup_sidebar(&multi_workspace, cx);
 9988
 9989    cx.run_until_parked();
 9990
 9991    // Save a thread for the main remote workspace (folder_paths match
 9992    // the open workspace, so it will be classified as Open).
 9993    let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
 9994    save_thread_metadata(
 9995        main_thread_id.clone(),
 9996        Some("Main Thread".into()),
 9997        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 9998        None,
 9999        &project,
10000        cx,
10001    );
10002    cx.run_until_parked();
10003
10004    // Save a thread whose folder_paths point to a linked worktree path
10005    // that doesn't have an open workspace ("/project-wt-1"), but whose
10006    // main_worktree_paths match the project group key so it appears
10007    // in the sidebar under the same remote group. This simulates a
10008    // linked worktree workspace that was closed.
10009    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10010    let main_worktree_paths =
10011        project.read_with(cx, |p, cx| p.project_group_key(cx).path_list().clone());
10012    cx.update(|_window, cx| {
10013        let metadata = ThreadMetadata {
10014            thread_id: ThreadId::new(),
10015            session_id: Some(remote_thread_id.clone()),
10016            agent_id: agent::ZED_AGENT_ID.clone(),
10017            title: Some("Worktree Thread".into()),
10018            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10019            created_at: None,
10020            worktree_paths: WorktreePaths::from_path_lists(
10021                main_worktree_paths,
10022                PathList::new(&[PathBuf::from("/project-wt-1")]),
10023            )
10024            .unwrap(),
10025            archived: false,
10026            remote_connection: None,
10027        };
10028        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10029    });
10030    cx.run_until_parked();
10031
10032    focus_sidebar(&sidebar, cx);
10033    sidebar.update_in(cx, |sidebar, _window, _cx| {
10034        sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10035            matches!(
10036                entry,
10037                ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10038            )
10039        });
10040    });
10041
10042    let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10043    let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10044
10045    sidebar
10046        .update(cx, |_, cx| {
10047            cx.observe_self(move |sidebar, _cx| {
10048                let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10049                    if let ListEntry::ProjectHeader { label, .. } = entry {
10050                        Some(label.as_ref())
10051                    } else {
10052                        None
10053                    }
10054                });
10055
10056                let Some(project_header) = project_headers.next() else {
10057                    saw_separate_project_header_for_observer
10058                        .store(true, std::sync::atomic::Ordering::SeqCst);
10059                    return;
10060                };
10061
10062                if project_header != "project" || project_headers.next().is_some() {
10063                    saw_separate_project_header_for_observer
10064                        .store(true, std::sync::atomic::Ordering::SeqCst);
10065                }
10066            })
10067        })
10068        .detach();
10069
10070    multi_workspace.update(cx, |multi_workspace, cx| {
10071        let workspace = multi_workspace.workspace().clone();
10072        workspace.update(cx, |workspace: &mut Workspace, cx| {
10073            let remote_client = workspace
10074                .project()
10075                .read(cx)
10076                .remote_client()
10077                .expect("main remote project should have a remote client");
10078            remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10079                remote_client.force_server_not_running(cx);
10080            });
10081        });
10082    });
10083    cx.run_until_parked();
10084
10085    let (server_session_2, connect_guard_2) =
10086        remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10087    let _headless_2 = server_cx.new(|cx| {
10088        remote_server::HeadlessProject::new(
10089            remote_server::HeadlessAppState {
10090                session: server_session_2,
10091                fs: server_fs.clone(),
10092                http_client: Arc::new(http_client::BlockedHttpClient),
10093                node_runtime: node_runtime::NodeRuntime::unavailable(),
10094                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10095                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10096                startup_time: std::time::Instant::now(),
10097            },
10098            false,
10099            cx,
10100        )
10101    });
10102    drop(connect_guard_2);
10103
10104    let window = cx.windows()[0];
10105    cx.update_window(window, |_, window, cx| {
10106        window.dispatch_action(Confirm.boxed_clone(), cx);
10107    })
10108    .unwrap();
10109
10110    cx.run_until_parked();
10111
10112    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10113        assert_eq!(
10114            mw.workspaces().count(),
10115            2,
10116            "confirming a closed remote thread should open a second workspace"
10117        );
10118        mw.workspaces()
10119            .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10120            .unwrap()
10121            .clone()
10122    });
10123
10124    server_fs
10125        .add_linked_worktree_for_repo(
10126            Path::new("/project/.git"),
10127            true,
10128            git::repository::Worktree {
10129                path: PathBuf::from("/project-wt-1"),
10130                ref_name: Some("refs/heads/feature-wt".into()),
10131                sha: "abc123".into(),
10132                is_main: false,
10133            },
10134        )
10135        .await;
10136
10137    server_cx.run_until_parked();
10138    cx.run_until_parked();
10139    server_cx.run_until_parked();
10140    cx.run_until_parked();
10141
10142    let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10143    let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10144        workspace.project().read(cx).project_group_key(cx)
10145    });
10146
10147    assert_eq!(
10148        group_after_update,
10149        project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10150        "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10151         final sidebar entries: {:?}",
10152        entries_after_update,
10153    );
10154
10155    sidebar.update(cx, |sidebar, _cx| {
10156        assert_remote_project_integration_sidebar_state(
10157            sidebar,
10158            &main_thread_id,
10159            &remote_thread_id,
10160        );
10161    });
10162
10163    assert!(
10164        !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10165        "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10166         final group: {:?}; final sidebar entries: {:?}",
10167        group_after_update,
10168        entries_after_update,
10169    );
10170}