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, rel_path::rel_path};
   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]"]
  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 only the header (no auto-created draft).
 1500    assert_eq!(
 1501        visible_entries_as_strings(&sidebar, cx),
 1502        vec!["v [empty-project]"]
 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 with only one entry stays at index 0
 1514    cx.dispatch_action(SelectNext);
 1515    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1516
 1517    // SelectPrevious from first entry clears selection (returns to editor)
 1518    cx.dispatch_action(SelectPrevious);
 1519    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1520
 1521    // SelectPrevious from None selects the last entry
 1522    cx.dispatch_action(SelectPrevious);
 1523    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 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  <== selected".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    // Create a new thread (activates the draft as base view and connects).
 3118    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 3119    let panel = workspace.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
 3120    let connection = StubAgentConnection::new();
 3121    open_thread_with_connection(&panel, connection, cx);
 3122    cx.run_until_parked();
 3123
 3124    // Type into the draft's message editor.
 3125    let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3126    let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
 3127    message_editor.update_in(cx, |editor, window, cx| {
 3128        editor.set_text("Fix the login bug", window, cx);
 3129    });
 3130    cx.run_until_parked();
 3131
 3132    // The sidebar draft title should now reflect the editor text.
 3133    let draft_title = sidebar.read_with(cx, |sidebar, _cx| {
 3134        sidebar
 3135            .contents
 3136            .entries
 3137            .iter()
 3138            .find_map(|entry| match entry {
 3139                ListEntry::Thread(thread) if thread.is_draft => {
 3140                    Some(thread.metadata.display_title())
 3141                }
 3142                _ => None,
 3143            })
 3144            .expect("should still have a draft entry")
 3145    });
 3146    assert_eq!(
 3147        draft_title.as_ref(),
 3148        "Fix the login bug",
 3149        "draft title should update to match editor text"
 3150    );
 3151}
 3152
 3153#[gpui::test]
 3154async fn test_draft_title_updates_across_two_groups(cx: &mut TestAppContext) {
 3155    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 3156    let (multi_workspace, cx) =
 3157        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3158    let (sidebar, _panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3159
 3160    // Add a second project group.
 3161    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 3162    fs.as_fake()
 3163        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 3164        .await;
 3165    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
 3166    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 3167        mw.test_add_workspace(project_b.clone(), window, cx)
 3168    });
 3169    let panel_b = add_agent_panel(&workspace_b, cx);
 3170    cx.run_until_parked();
 3171
 3172    // Open a thread in each group's panel to get Connected state.
 3173    let workspace_a =
 3174        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 3175    let panel_a = workspace_a.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
 3176
 3177    let connection_a = StubAgentConnection::new();
 3178    open_thread_with_connection(&panel_a, connection_a, cx);
 3179    cx.run_until_parked();
 3180
 3181    let connection_b = StubAgentConnection::new();
 3182    open_thread_with_connection(&panel_b, connection_b, cx);
 3183    cx.run_until_parked();
 3184
 3185    // Type into group A's draft editor.
 3186    let thread_view_a = panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3187    let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone());
 3188    editor_a.update_in(cx, |editor, window, cx| {
 3189        editor.set_text("Fix the login bug", window, cx);
 3190    });
 3191    cx.run_until_parked();
 3192
 3193    // Type into group B's draft editor.
 3194    let thread_view_b = panel_b.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3195    let editor_b = thread_view_b.read_with(cx, |view, _cx| view.message_editor.clone());
 3196    editor_b.update_in(cx, |editor, window, cx| {
 3197        editor.set_text("Refactor the database", window, cx);
 3198    });
 3199    cx.run_until_parked();
 3200
 3201    // Both draft titles should reflect their respective editor text.
 3202    let draft_titles: Vec<SharedString> = sidebar.read_with(cx, |sidebar, _cx| {
 3203        sidebar
 3204            .contents
 3205            .entries
 3206            .iter()
 3207            .filter_map(|entry| match entry {
 3208                ListEntry::Thread(thread) if thread.is_draft => {
 3209                    Some(thread.metadata.display_title())
 3210                }
 3211                _ => None,
 3212            })
 3213            .collect()
 3214    });
 3215    assert_eq!(draft_titles.len(), 2, "should still have two drafts");
 3216    assert!(
 3217        draft_titles.contains(&SharedString::from("Fix the login bug")),
 3218        "group A draft should show editor text, got: {:?}",
 3219        draft_titles
 3220    );
 3221    assert!(
 3222        draft_titles.contains(&SharedString::from("Refactor the database")),
 3223        "group B draft should show editor text, got: {:?}",
 3224        draft_titles
 3225    );
 3226}
 3227
 3228#[gpui::test]
 3229async fn test_draft_title_survives_folder_addition(cx: &mut TestAppContext) {
 3230    // When a folder is added to the project, the group key changes.
 3231    // The draft's editor observation should still work and the title
 3232    // should update when the user types.
 3233    init_test(cx);
 3234    let fs = FakeFs::new(cx.executor());
 3235    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 3236        .await;
 3237    fs.insert_tree("/project-b", serde_json::json!({ "lib": {} }))
 3238        .await;
 3239    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3240
 3241    let project = project::Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
 3242    let (multi_workspace, cx) =
 3243        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3244    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3245
 3246    // Create a thread with a connection (has a session_id, considered
 3247    // a draft by the panel until messages are sent).
 3248    let connection = StubAgentConnection::new();
 3249    open_thread_with_connection(&panel, connection, cx);
 3250    cx.run_until_parked();
 3251
 3252    // Type into the editor.
 3253    let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3254    let editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
 3255    editor.update_in(cx, |editor, window, cx| {
 3256        editor.set_text("Initial text", window, cx);
 3257    });
 3258    let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
 3259    cx.run_until_parked();
 3260
 3261    // The thread without a title should show the editor text via
 3262    // the draft title override.
 3263    sidebar.read_with(cx, |sidebar, _cx| {
 3264        let thread = sidebar
 3265            .contents
 3266            .entries
 3267            .iter()
 3268            .find_map(|entry| match entry {
 3269                ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
 3270                _ => None,
 3271            });
 3272        assert_eq!(
 3273            thread.and_then(|t| t.metadata.title.as_ref().map(|s| s.as_ref())),
 3274            Some("Initial text"),
 3275            "draft title should show editor text before folder add"
 3276        );
 3277    });
 3278
 3279    // Add a second folder to the project — this changes the group key.
 3280    project
 3281        .update(cx, |project, cx| {
 3282            project.find_or_create_worktree("/project-b", true, cx)
 3283        })
 3284        .await
 3285        .expect("should add worktree");
 3286    cx.run_until_parked();
 3287
 3288    // Update editor text.
 3289    editor.update_in(cx, |editor, window, cx| {
 3290        editor.set_text("Updated after folder add", window, cx);
 3291    });
 3292    cx.run_until_parked();
 3293
 3294    // The draft title should still update. After adding a folder the
 3295    // group key changes, so the thread may not appear in the sidebar
 3296    // if its metadata was saved under the old path list. If it IS
 3297    // found, verify the title was overridden.
 3298    sidebar.read_with(cx, |sidebar, _cx| {
 3299        let thread = sidebar
 3300            .contents
 3301            .entries
 3302            .iter()
 3303            .find_map(|entry| match entry {
 3304                ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
 3305                _ => None,
 3306            });
 3307        if let Some(thread) = thread {
 3308            assert_eq!(
 3309                thread.metadata.title.as_ref().map(|s| s.as_ref()),
 3310                Some("Updated after folder add"),
 3311                "draft title should update even after adding a folder"
 3312            );
 3313        }
 3314    });
 3315}
 3316
 3317#[gpui::test]
 3318async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
 3319    // When the user presses Cmd-N (NewThread action) while viewing a
 3320    // non-empty thread, the sidebar should show the "New Thread" entry.
 3321    // This exercises the same code path as the workspace action handler
 3322    // (which bypasses the sidebar's create_new_thread method).
 3323    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3324    let (multi_workspace, cx) =
 3325        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3326    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3327
 3328    // Create a non-empty thread (has messages).
 3329    let connection = StubAgentConnection::new();
 3330    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3331        acp::ContentChunk::new("Done".into()),
 3332    )]);
 3333    open_thread_with_connection(&panel, connection, cx);
 3334    send_message(&panel, cx);
 3335
 3336    let session_id = active_session_id(&panel, cx);
 3337    save_test_thread_metadata(&session_id, &project, cx).await;
 3338    cx.run_until_parked();
 3339
 3340    assert_eq!(
 3341        visible_entries_as_strings(&sidebar, cx),
 3342        vec![
 3343            //
 3344            "v [my-project]",
 3345            "  Hello *",
 3346        ]
 3347    );
 3348
 3349    // Simulate cmd-n
 3350    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 3351    panel.update_in(cx, |panel, window, cx| {
 3352        panel.new_thread(&NewThread, window, cx);
 3353    });
 3354    workspace.update_in(cx, |workspace, window, cx| {
 3355        workspace.focus_panel::<AgentPanel>(window, cx);
 3356    });
 3357    cx.run_until_parked();
 3358
 3359    assert_eq!(
 3360        visible_entries_as_strings(&sidebar, cx),
 3361        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3362        "After Cmd-N the sidebar should show a highlighted Draft entry"
 3363    );
 3364
 3365    sidebar.read_with(cx, |sidebar, _cx| {
 3366        assert_active_draft(
 3367            sidebar,
 3368            &workspace,
 3369            "active_entry should be Draft after Cmd-N",
 3370        );
 3371    });
 3372}
 3373
 3374#[gpui::test]
 3375async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
 3376    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3377    let (multi_workspace, cx) =
 3378        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3379    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3380
 3381    // Create a saved thread so the workspace has history.
 3382    let connection = StubAgentConnection::new();
 3383    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3384        acp::ContentChunk::new("Done".into()),
 3385    )]);
 3386    open_thread_with_connection(&panel, connection, cx);
 3387    send_message(&panel, cx);
 3388    let saved_session_id = active_session_id(&panel, cx);
 3389    save_test_thread_metadata(&saved_session_id, &project, cx).await;
 3390    cx.run_until_parked();
 3391
 3392    assert_eq!(
 3393        visible_entries_as_strings(&sidebar, cx),
 3394        vec![
 3395            //
 3396            "v [my-project]",
 3397            "  Hello *",
 3398        ]
 3399    );
 3400
 3401    // Create a new draft via Cmd-N. Since new_thread() now creates a
 3402    // tracked draft in the AgentPanel, it appears in the sidebar.
 3403    panel.update_in(cx, |panel, window, cx| {
 3404        panel.new_thread(&NewThread, window, cx);
 3405    });
 3406    cx.run_until_parked();
 3407
 3408    assert_eq!(
 3409        visible_entries_as_strings(&sidebar, cx),
 3410        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3411    );
 3412
 3413    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 3414    sidebar.read_with(cx, |sidebar, _cx| {
 3415        assert_active_draft(
 3416            sidebar,
 3417            &workspace,
 3418            "Draft with server session should be Draft, not Thread",
 3419        );
 3420    });
 3421}
 3422
 3423#[gpui::test]
 3424async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext) {
 3425    // When the user sends a message from a draft thread, the draft
 3426    // should be removed from the sidebar and the active_entry should
 3427    // transition to a Thread pointing at the new session.
 3428    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3429    let (multi_workspace, cx) =
 3430        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3431    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3432
 3433    // Create a saved thread so the group isn't empty.
 3434    let connection = StubAgentConnection::new();
 3435    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3436        acp::ContentChunk::new("Done".into()),
 3437    )]);
 3438    open_thread_with_connection(&panel, connection, cx);
 3439    send_message(&panel, cx);
 3440    let existing_session_id = active_session_id(&panel, cx);
 3441    save_test_thread_metadata(&existing_session_id, &project, cx).await;
 3442    cx.run_until_parked();
 3443
 3444    // Create a draft via Cmd-N.
 3445    panel.update_in(cx, |panel, window, cx| {
 3446        panel.new_thread(&NewThread, window, cx);
 3447    });
 3448    cx.run_until_parked();
 3449
 3450    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 3451    assert_eq!(
 3452        visible_entries_as_strings(&sidebar, cx),
 3453        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3454        "draft should be visible before sending",
 3455    );
 3456    sidebar.read_with(cx, |sidebar, _| {
 3457        assert_active_draft(sidebar, &workspace, "should be on draft before sending");
 3458    });
 3459
 3460    // Simulate what happens when a draft sends its first message:
 3461    // the AgentPanel's MessageSentOrQueued handler removes the draft
 3462    // from `draft_threads`, then the sidebar rebuilds. We can't use
 3463    // the NativeAgentServer in tests, so replicate the key steps:
 3464    // remove the draft, open a real thread with a stub connection,
 3465    // and send.
 3466    let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
 3467    panel.update_in(cx, |panel, _window, cx| {
 3468        panel.remove_thread(thread_id, cx);
 3469    });
 3470    let draft_connection = StubAgentConnection::new();
 3471    draft_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3472        acp::ContentChunk::new("World".into()),
 3473    )]);
 3474    open_thread_with_connection(&panel, draft_connection, cx);
 3475    send_message(&panel, cx);
 3476    let new_session_id = active_session_id(&panel, cx);
 3477    save_test_thread_metadata(&new_session_id, &project, cx).await;
 3478    cx.run_until_parked();
 3479
 3480    // The draft should be gone and the new thread should be active.
 3481    let entries = visible_entries_as_strings(&sidebar, cx);
 3482    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 3483    assert_eq!(
 3484        draft_count, 0,
 3485        "draft should be removed after sending a message"
 3486    );
 3487
 3488    sidebar.read_with(cx, |sidebar, _| {
 3489        assert_active_thread(
 3490            sidebar,
 3491            &new_session_id,
 3492            "active_entry should transition to the new thread after sending",
 3493        );
 3494    });
 3495}
 3496
 3497#[gpui::test]
 3498async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
 3499    // When the active workspace is an absorbed git worktree, cmd-n
 3500    // should still show the "New Thread" entry under the main repo's
 3501    // header and highlight it as active.
 3502    agent_ui::test_support::init_test(cx);
 3503    cx.update(|cx| {
 3504        ThreadStore::init_global(cx);
 3505        ThreadMetadataStore::init_global(cx);
 3506        language_model::LanguageModelRegistry::test(cx);
 3507        prompt_store::init(cx);
 3508    });
 3509
 3510    let fs = FakeFs::new(cx.executor());
 3511
 3512    // Main repo with a linked worktree.
 3513    fs.insert_tree(
 3514        "/project",
 3515        serde_json::json!({
 3516            ".git": {},
 3517            "src": {},
 3518        }),
 3519    )
 3520    .await;
 3521
 3522    // Worktree checkout pointing back to the main repo.
 3523    fs.add_linked_worktree_for_repo(
 3524        Path::new("/project/.git"),
 3525        false,
 3526        git::repository::Worktree {
 3527            path: std::path::PathBuf::from("/wt-feature-a"),
 3528            ref_name: Some("refs/heads/feature-a".into()),
 3529            sha: "aaa".into(),
 3530            is_main: false,
 3531        },
 3532    )
 3533    .await;
 3534
 3535    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3536
 3537    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3538    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3539
 3540    main_project
 3541        .update(cx, |p, cx| p.git_scans_complete(cx))
 3542        .await;
 3543    worktree_project
 3544        .update(cx, |p, cx| p.git_scans_complete(cx))
 3545        .await;
 3546
 3547    let (multi_workspace, cx) =
 3548        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3549
 3550    let sidebar = setup_sidebar(&multi_workspace, cx);
 3551
 3552    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3553        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3554    });
 3555
 3556    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3557
 3558    // Switch to the worktree workspace.
 3559    multi_workspace.update_in(cx, |mw, window, cx| {
 3560        let workspace = mw.workspaces().nth(1).unwrap().clone();
 3561        mw.activate(workspace, window, cx);
 3562    });
 3563
 3564    // Create a non-empty thread in the worktree workspace.
 3565    let connection = StubAgentConnection::new();
 3566    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3567        acp::ContentChunk::new("Done".into()),
 3568    )]);
 3569    open_thread_with_connection(&worktree_panel, connection, cx);
 3570    send_message(&worktree_panel, cx);
 3571
 3572    let session_id = active_session_id(&worktree_panel, cx);
 3573    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3574    cx.run_until_parked();
 3575
 3576    assert_eq!(
 3577        visible_entries_as_strings(&sidebar, cx),
 3578        vec![
 3579            //
 3580            "v [project]",
 3581            "  Hello {wt-feature-a} *",
 3582        ]
 3583    );
 3584
 3585    // Simulate Cmd-N in the worktree workspace.
 3586    worktree_panel.update_in(cx, |panel, window, cx| {
 3587        panel.new_thread(&NewThread, window, cx);
 3588    });
 3589    worktree_workspace.update_in(cx, |workspace, window, cx| {
 3590        workspace.focus_panel::<AgentPanel>(window, cx);
 3591    });
 3592    cx.run_until_parked();
 3593
 3594    assert_eq!(
 3595        visible_entries_as_strings(&sidebar, cx),
 3596        vec![
 3597            //
 3598            "v [project]",
 3599            "  [~ Draft {wt-feature-a}] *",
 3600            "  Hello {wt-feature-a} *"
 3601        ],
 3602        "After Cmd-N in an absorbed worktree, the sidebar should show \
 3603             a highlighted Draft entry under the main repo header"
 3604    );
 3605
 3606    sidebar.read_with(cx, |sidebar, _cx| {
 3607        assert_active_draft(
 3608            sidebar,
 3609            &worktree_workspace,
 3610            "active_entry should be Draft after Cmd-N",
 3611        );
 3612    });
 3613}
 3614
 3615async fn init_test_project_with_git(
 3616    worktree_path: &str,
 3617    cx: &mut TestAppContext,
 3618) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
 3619    init_test(cx);
 3620    let fs = FakeFs::new(cx.executor());
 3621    fs.insert_tree(
 3622        worktree_path,
 3623        serde_json::json!({
 3624            ".git": {},
 3625            "src": {},
 3626        }),
 3627    )
 3628    .await;
 3629    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3630    let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
 3631    (project, fs)
 3632}
 3633
 3634#[gpui::test]
 3635async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
 3636    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3637
 3638    fs.as_fake()
 3639        .add_linked_worktree_for_repo(
 3640            Path::new("/project/.git"),
 3641            false,
 3642            git::repository::Worktree {
 3643                path: std::path::PathBuf::from("/wt/rosewood"),
 3644                ref_name: Some("refs/heads/rosewood".into()),
 3645                sha: "abc".into(),
 3646                is_main: false,
 3647            },
 3648        )
 3649        .await;
 3650
 3651    project
 3652        .update(cx, |project, cx| project.git_scans_complete(cx))
 3653        .await;
 3654
 3655    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3656    worktree_project
 3657        .update(cx, |p, cx| p.git_scans_complete(cx))
 3658        .await;
 3659
 3660    let (multi_workspace, cx) =
 3661        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3662    let sidebar = setup_sidebar(&multi_workspace, cx);
 3663
 3664    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
 3665    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
 3666
 3667    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3668    cx.run_until_parked();
 3669
 3670    // Search for "rosewood" — should match the worktree name, not the title.
 3671    type_in_search(&sidebar, "rosewood", cx);
 3672
 3673    assert_eq!(
 3674        visible_entries_as_strings(&sidebar, cx),
 3675        vec![
 3676            //
 3677            "v [project]",
 3678            "  Fix Bug {rosewood}  <== selected",
 3679        ],
 3680    );
 3681}
 3682
 3683#[gpui::test]
 3684async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
 3685    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3686
 3687    project
 3688        .update(cx, |project, cx| project.git_scans_complete(cx))
 3689        .await;
 3690
 3691    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3692    worktree_project
 3693        .update(cx, |p, cx| p.git_scans_complete(cx))
 3694        .await;
 3695
 3696    let (multi_workspace, cx) =
 3697        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3698    let sidebar = setup_sidebar(&multi_workspace, cx);
 3699
 3700    // Save a thread against a worktree path with the correct main
 3701    // worktree association (as if the git state had been resolved).
 3702    save_thread_metadata_with_main_paths(
 3703        "wt-thread",
 3704        "Worktree Thread",
 3705        PathList::new(&[PathBuf::from("/wt/rosewood")]),
 3706        PathList::new(&[PathBuf::from("/project")]),
 3707        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3708        cx,
 3709    );
 3710
 3711    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3712    cx.run_until_parked();
 3713
 3714    // Thread is visible because its main_worktree_paths match the group.
 3715    // The chip name is derived from the path even before git discovery.
 3716    assert_eq!(
 3717        visible_entries_as_strings(&sidebar, cx),
 3718        vec!["v [project]", "  Worktree Thread {rosewood}"]
 3719    );
 3720
 3721    // Now add the worktree to the git state and trigger a rescan.
 3722    fs.as_fake()
 3723        .add_linked_worktree_for_repo(
 3724            Path::new("/project/.git"),
 3725            true,
 3726            git::repository::Worktree {
 3727                path: std::path::PathBuf::from("/wt/rosewood"),
 3728                ref_name: Some("refs/heads/rosewood".into()),
 3729                sha: "abc".into(),
 3730                is_main: false,
 3731            },
 3732        )
 3733        .await;
 3734
 3735    cx.run_until_parked();
 3736
 3737    assert_eq!(
 3738        visible_entries_as_strings(&sidebar, cx),
 3739        vec![
 3740            //
 3741            "v [project]",
 3742            "  Worktree Thread {rosewood}",
 3743        ]
 3744    );
 3745}
 3746
 3747#[gpui::test]
 3748async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
 3749    init_test(cx);
 3750    let fs = FakeFs::new(cx.executor());
 3751
 3752    // Create the main repo directory (not opened as a workspace yet).
 3753    fs.insert_tree(
 3754        "/project",
 3755        serde_json::json!({
 3756            ".git": {
 3757            },
 3758            "src": {},
 3759        }),
 3760    )
 3761    .await;
 3762
 3763    // Two worktree checkouts whose .git files point back to the main repo.
 3764    fs.add_linked_worktree_for_repo(
 3765        Path::new("/project/.git"),
 3766        false,
 3767        git::repository::Worktree {
 3768            path: std::path::PathBuf::from("/wt-feature-a"),
 3769            ref_name: Some("refs/heads/feature-a".into()),
 3770            sha: "aaa".into(),
 3771            is_main: false,
 3772        },
 3773    )
 3774    .await;
 3775    fs.add_linked_worktree_for_repo(
 3776        Path::new("/project/.git"),
 3777        false,
 3778        git::repository::Worktree {
 3779            path: std::path::PathBuf::from("/wt-feature-b"),
 3780            ref_name: Some("refs/heads/feature-b".into()),
 3781            sha: "bbb".into(),
 3782            is_main: false,
 3783        },
 3784    )
 3785    .await;
 3786
 3787    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3788
 3789    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3790    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3791
 3792    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3793    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3794
 3795    // Open both worktrees as workspaces — no main repo yet.
 3796    let (multi_workspace, cx) =
 3797        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3798    multi_workspace.update_in(cx, |mw, window, cx| {
 3799        mw.test_add_workspace(project_b.clone(), window, cx);
 3800    });
 3801    let sidebar = setup_sidebar(&multi_workspace, cx);
 3802
 3803    save_thread_metadata(
 3804        acp::SessionId::new(Arc::from("thread-a")),
 3805        Some("Thread A".into()),
 3806        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3807        None,
 3808        &project_a,
 3809        cx,
 3810    );
 3811    save_thread_metadata(
 3812        acp::SessionId::new(Arc::from("thread-b")),
 3813        Some("Thread B".into()),
 3814        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 3815        None,
 3816        &project_b,
 3817        cx,
 3818    );
 3819
 3820    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3821    cx.run_until_parked();
 3822
 3823    // Without the main repo, each worktree has its own header.
 3824    assert_eq!(
 3825        visible_entries_as_strings(&sidebar, cx),
 3826        vec![
 3827            //
 3828            "v [project]",
 3829            "  Thread B {wt-feature-b}",
 3830            "  Thread A {wt-feature-a}",
 3831        ]
 3832    );
 3833
 3834    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3835    main_project
 3836        .update(cx, |p, cx| p.git_scans_complete(cx))
 3837        .await;
 3838
 3839    multi_workspace.update_in(cx, |mw, window, cx| {
 3840        mw.test_add_workspace(main_project.clone(), window, cx);
 3841    });
 3842    cx.run_until_parked();
 3843
 3844    // Both worktree workspaces should now be absorbed under the main
 3845    // repo header, with worktree chips.
 3846    assert_eq!(
 3847        visible_entries_as_strings(&sidebar, cx),
 3848        vec![
 3849            //
 3850            "v [project]",
 3851            "  Thread B {wt-feature-b}",
 3852            "  Thread A {wt-feature-a}",
 3853        ]
 3854    );
 3855}
 3856
 3857#[gpui::test]
 3858async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
 3859    // When a group has two workspaces — one with threads and one
 3860    // without — the threadless workspace should appear as a
 3861    // "New Thread" button with its worktree chip.
 3862    init_test(cx);
 3863    let fs = FakeFs::new(cx.executor());
 3864
 3865    // Main repo with two linked worktrees.
 3866    fs.insert_tree(
 3867        "/project",
 3868        serde_json::json!({
 3869            ".git": {},
 3870            "src": {},
 3871        }),
 3872    )
 3873    .await;
 3874    fs.add_linked_worktree_for_repo(
 3875        Path::new("/project/.git"),
 3876        false,
 3877        git::repository::Worktree {
 3878            path: std::path::PathBuf::from("/wt-feature-a"),
 3879            ref_name: Some("refs/heads/feature-a".into()),
 3880            sha: "aaa".into(),
 3881            is_main: false,
 3882        },
 3883    )
 3884    .await;
 3885    fs.add_linked_worktree_for_repo(
 3886        Path::new("/project/.git"),
 3887        false,
 3888        git::repository::Worktree {
 3889            path: std::path::PathBuf::from("/wt-feature-b"),
 3890            ref_name: Some("refs/heads/feature-b".into()),
 3891            sha: "bbb".into(),
 3892            is_main: false,
 3893        },
 3894    )
 3895    .await;
 3896
 3897    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3898
 3899    // Workspace A: worktree feature-a (has threads).
 3900    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3901    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3902
 3903    // Workspace B: worktree feature-b (no threads).
 3904    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3905    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3906
 3907    let (multi_workspace, cx) =
 3908        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3909    multi_workspace.update_in(cx, |mw, window, cx| {
 3910        mw.test_add_workspace(project_b.clone(), window, cx);
 3911    });
 3912    let sidebar = setup_sidebar(&multi_workspace, cx);
 3913
 3914    // Only save a thread for workspace A.
 3915    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
 3916
 3917    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3918    cx.run_until_parked();
 3919
 3920    // Workspace A's thread appears normally. Workspace B (threadless)
 3921    // appears as a "New Thread" button with its worktree chip.
 3922    assert_eq!(
 3923        visible_entries_as_strings(&sidebar, cx),
 3924        vec!["v [project]", "  Thread A {wt-feature-a}",]
 3925    );
 3926}
 3927
 3928#[gpui::test]
 3929async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
 3930    // A thread created in a workspace with roots from different git
 3931    // worktrees should show a chip for each distinct worktree name.
 3932    init_test(cx);
 3933    let fs = FakeFs::new(cx.executor());
 3934
 3935    // Two main repos.
 3936    fs.insert_tree(
 3937        "/project_a",
 3938        serde_json::json!({
 3939            ".git": {},
 3940            "src": {},
 3941        }),
 3942    )
 3943    .await;
 3944    fs.insert_tree(
 3945        "/project_b",
 3946        serde_json::json!({
 3947            ".git": {},
 3948            "src": {},
 3949        }),
 3950    )
 3951    .await;
 3952
 3953    // Worktree checkouts.
 3954    for repo in &["project_a", "project_b"] {
 3955        let git_path = format!("/{repo}/.git");
 3956        for branch in &["olivetti", "selectric"] {
 3957            fs.add_linked_worktree_for_repo(
 3958                Path::new(&git_path),
 3959                false,
 3960                git::repository::Worktree {
 3961                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
 3962                    ref_name: Some(format!("refs/heads/{branch}").into()),
 3963                    sha: "aaa".into(),
 3964                    is_main: false,
 3965                },
 3966            )
 3967            .await;
 3968        }
 3969    }
 3970
 3971    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3972
 3973    // Open a workspace with the worktree checkout paths as roots
 3974    // (this is the workspace the thread was created in).
 3975    let project = project::Project::test(
 3976        fs.clone(),
 3977        [
 3978            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3979            "/worktrees/project_b/selectric/project_b".as_ref(),
 3980        ],
 3981        cx,
 3982    )
 3983    .await;
 3984    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3985
 3986    let (multi_workspace, cx) =
 3987        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3988    let sidebar = setup_sidebar(&multi_workspace, cx);
 3989
 3990    // Save a thread under the same paths as the workspace roots.
 3991    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
 3992
 3993    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3994    cx.run_until_parked();
 3995
 3996    // Should show two distinct worktree chips.
 3997    assert_eq!(
 3998        visible_entries_as_strings(&sidebar, cx),
 3999        vec![
 4000            //
 4001            "v [project_a, project_b]",
 4002            "  Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
 4003        ]
 4004    );
 4005}
 4006
 4007#[gpui::test]
 4008async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
 4009    // When a thread's roots span multiple repos but share the same
 4010    // worktree name (e.g. both in "olivetti"), only one chip should
 4011    // appear.
 4012    init_test(cx);
 4013    let fs = FakeFs::new(cx.executor());
 4014
 4015    fs.insert_tree(
 4016        "/project_a",
 4017        serde_json::json!({
 4018            ".git": {},
 4019            "src": {},
 4020        }),
 4021    )
 4022    .await;
 4023    fs.insert_tree(
 4024        "/project_b",
 4025        serde_json::json!({
 4026            ".git": {},
 4027            "src": {},
 4028        }),
 4029    )
 4030    .await;
 4031
 4032    for repo in &["project_a", "project_b"] {
 4033        let git_path = format!("/{repo}/.git");
 4034        fs.add_linked_worktree_for_repo(
 4035            Path::new(&git_path),
 4036            false,
 4037            git::repository::Worktree {
 4038                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
 4039                ref_name: Some("refs/heads/olivetti".into()),
 4040                sha: "aaa".into(),
 4041                is_main: false,
 4042            },
 4043        )
 4044        .await;
 4045    }
 4046
 4047    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4048
 4049    let project = project::Project::test(
 4050        fs.clone(),
 4051        [
 4052            "/worktrees/project_a/olivetti/project_a".as_ref(),
 4053            "/worktrees/project_b/olivetti/project_b".as_ref(),
 4054        ],
 4055        cx,
 4056    )
 4057    .await;
 4058    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 4059
 4060    let (multi_workspace, cx) =
 4061        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 4062    let sidebar = setup_sidebar(&multi_workspace, cx);
 4063
 4064    // Thread with roots in both repos' "olivetti" worktrees.
 4065    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
 4066
 4067    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4068    cx.run_until_parked();
 4069
 4070    // Both worktree paths have the name "olivetti", so only one chip.
 4071    assert_eq!(
 4072        visible_entries_as_strings(&sidebar, cx),
 4073        vec![
 4074            //
 4075            "v [project_a, project_b]",
 4076            "  Same Branch Thread {olivetti}",
 4077        ]
 4078    );
 4079}
 4080
 4081#[gpui::test]
 4082async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
 4083    // When a worktree workspace is absorbed under the main repo, a
 4084    // running thread in the worktree's agent panel should still show
 4085    // live status (spinner + "(running)") in the sidebar.
 4086    agent_ui::test_support::init_test(cx);
 4087    cx.update(|cx| {
 4088        ThreadStore::init_global(cx);
 4089        ThreadMetadataStore::init_global(cx);
 4090        language_model::LanguageModelRegistry::test(cx);
 4091        prompt_store::init(cx);
 4092    });
 4093
 4094    let fs = FakeFs::new(cx.executor());
 4095
 4096    // Main repo with a linked worktree.
 4097    fs.insert_tree(
 4098        "/project",
 4099        serde_json::json!({
 4100            ".git": {},
 4101            "src": {},
 4102        }),
 4103    )
 4104    .await;
 4105
 4106    // Worktree checkout pointing back to the main repo.
 4107    fs.add_linked_worktree_for_repo(
 4108        Path::new("/project/.git"),
 4109        false,
 4110        git::repository::Worktree {
 4111            path: std::path::PathBuf::from("/wt-feature-a"),
 4112            ref_name: Some("refs/heads/feature-a".into()),
 4113            sha: "aaa".into(),
 4114            is_main: false,
 4115        },
 4116    )
 4117    .await;
 4118
 4119    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4120
 4121    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4122    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4123
 4124    main_project
 4125        .update(cx, |p, cx| p.git_scans_complete(cx))
 4126        .await;
 4127    worktree_project
 4128        .update(cx, |p, cx| p.git_scans_complete(cx))
 4129        .await;
 4130
 4131    // Create the MultiWorkspace with both projects.
 4132    let (multi_workspace, cx) =
 4133        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4134
 4135    let sidebar = setup_sidebar(&multi_workspace, cx);
 4136
 4137    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4138        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4139    });
 4140
 4141    // Add an agent panel to the worktree workspace so we can run a
 4142    // thread inside it.
 4143    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 4144
 4145    // Switch back to the main workspace before setting up the sidebar.
 4146    multi_workspace.update_in(cx, |mw, window, cx| {
 4147        let workspace = mw.workspaces().next().unwrap().clone();
 4148        mw.activate(workspace, window, cx);
 4149    });
 4150
 4151    // Start a thread in the worktree workspace's panel and keep it
 4152    // generating (don't resolve it).
 4153    let connection = StubAgentConnection::new();
 4154    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 4155    send_message(&worktree_panel, cx);
 4156
 4157    let session_id = active_session_id(&worktree_panel, cx);
 4158
 4159    // Save metadata so the sidebar knows about this thread.
 4160    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 4161
 4162    // Keep the thread generating by sending a chunk without ending
 4163    // the turn.
 4164    cx.update(|_, cx| {
 4165        connection.send_update(
 4166            session_id.clone(),
 4167            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 4168            cx,
 4169        );
 4170    });
 4171    cx.run_until_parked();
 4172
 4173    // The worktree thread should be absorbed under the main project
 4174    // and show live running status.
 4175    let entries = visible_entries_as_strings(&sidebar, cx);
 4176    assert_eq!(
 4177        entries,
 4178        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 4179    );
 4180}
 4181
 4182#[gpui::test]
 4183async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
 4184    agent_ui::test_support::init_test(cx);
 4185    cx.update(|cx| {
 4186        ThreadStore::init_global(cx);
 4187        ThreadMetadataStore::init_global(cx);
 4188        language_model::LanguageModelRegistry::test(cx);
 4189        prompt_store::init(cx);
 4190    });
 4191
 4192    let fs = FakeFs::new(cx.executor());
 4193
 4194    fs.insert_tree(
 4195        "/project",
 4196        serde_json::json!({
 4197            ".git": {},
 4198            "src": {},
 4199        }),
 4200    )
 4201    .await;
 4202
 4203    fs.add_linked_worktree_for_repo(
 4204        Path::new("/project/.git"),
 4205        false,
 4206        git::repository::Worktree {
 4207            path: std::path::PathBuf::from("/wt-feature-a"),
 4208            ref_name: Some("refs/heads/feature-a".into()),
 4209            sha: "aaa".into(),
 4210            is_main: false,
 4211        },
 4212    )
 4213    .await;
 4214
 4215    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4216
 4217    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4218    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4219
 4220    main_project
 4221        .update(cx, |p, cx| p.git_scans_complete(cx))
 4222        .await;
 4223    worktree_project
 4224        .update(cx, |p, cx| p.git_scans_complete(cx))
 4225        .await;
 4226
 4227    let (multi_workspace, cx) =
 4228        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4229
 4230    let sidebar = setup_sidebar(&multi_workspace, cx);
 4231
 4232    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4233        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4234    });
 4235
 4236    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 4237
 4238    multi_workspace.update_in(cx, |mw, window, cx| {
 4239        let workspace = mw.workspaces().next().unwrap().clone();
 4240        mw.activate(workspace, window, cx);
 4241    });
 4242
 4243    let connection = StubAgentConnection::new();
 4244    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 4245    send_message(&worktree_panel, cx);
 4246
 4247    let session_id = active_session_id(&worktree_panel, cx);
 4248    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 4249
 4250    cx.update(|_, cx| {
 4251        connection.send_update(
 4252            session_id.clone(),
 4253            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 4254            cx,
 4255        );
 4256    });
 4257    cx.run_until_parked();
 4258
 4259    assert_eq!(
 4260        visible_entries_as_strings(&sidebar, cx),
 4261        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 4262    );
 4263
 4264    connection.end_turn(session_id, acp::StopReason::EndTurn);
 4265    cx.run_until_parked();
 4266
 4267    assert_eq!(
 4268        visible_entries_as_strings(&sidebar, cx),
 4269        vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
 4270    );
 4271}
 4272
 4273#[gpui::test]
 4274async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
 4275    init_test(cx);
 4276    let fs = FakeFs::new(cx.executor());
 4277
 4278    fs.insert_tree(
 4279        "/project",
 4280        serde_json::json!({
 4281            ".git": {},
 4282            "src": {},
 4283        }),
 4284    )
 4285    .await;
 4286
 4287    fs.add_linked_worktree_for_repo(
 4288        Path::new("/project/.git"),
 4289        false,
 4290        git::repository::Worktree {
 4291            path: std::path::PathBuf::from("/wt-feature-a"),
 4292            ref_name: Some("refs/heads/feature-a".into()),
 4293            sha: "aaa".into(),
 4294            is_main: false,
 4295        },
 4296    )
 4297    .await;
 4298
 4299    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4300
 4301    // Only open the main repo — no workspace for the worktree.
 4302    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4303    main_project
 4304        .update(cx, |p, cx| p.git_scans_complete(cx))
 4305        .await;
 4306
 4307    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4308    worktree_project
 4309        .update(cx, |p, cx| p.git_scans_complete(cx))
 4310        .await;
 4311
 4312    let (multi_workspace, cx) =
 4313        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4314    let sidebar = setup_sidebar(&multi_workspace, cx);
 4315
 4316    // Save a thread for the worktree path (no workspace for it).
 4317    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4318
 4319    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4320    cx.run_until_parked();
 4321
 4322    // Thread should appear under the main repo with a worktree chip.
 4323    assert_eq!(
 4324        visible_entries_as_strings(&sidebar, cx),
 4325        vec![
 4326            //
 4327            "v [project]",
 4328            "  WT Thread {wt-feature-a}",
 4329        ],
 4330    );
 4331
 4332    // Only 1 workspace should exist.
 4333    assert_eq!(
 4334        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4335        1,
 4336    );
 4337
 4338    // Focus the sidebar and select the worktree thread.
 4339    focus_sidebar(&sidebar, cx);
 4340    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4341        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 4342    });
 4343
 4344    // Confirm to open the worktree thread.
 4345    cx.dispatch_action(Confirm);
 4346    cx.run_until_parked();
 4347
 4348    // A new workspace should have been created for the worktree path.
 4349    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
 4350        assert_eq!(
 4351            mw.workspaces().count(),
 4352            2,
 4353            "confirming a worktree thread without a workspace should open one",
 4354        );
 4355        mw.workspaces().nth(1).unwrap().clone()
 4356    });
 4357
 4358    let new_path_list =
 4359        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 4360    assert_eq!(
 4361        new_path_list,
 4362        PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
 4363        "the new workspace should have been opened for the worktree path",
 4364    );
 4365}
 4366
 4367#[gpui::test]
 4368async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
 4369    cx: &mut TestAppContext,
 4370) {
 4371    init_test(cx);
 4372    let fs = FakeFs::new(cx.executor());
 4373
 4374    fs.insert_tree(
 4375        "/project",
 4376        serde_json::json!({
 4377            ".git": {},
 4378            "src": {},
 4379        }),
 4380    )
 4381    .await;
 4382
 4383    fs.add_linked_worktree_for_repo(
 4384        Path::new("/project/.git"),
 4385        false,
 4386        git::repository::Worktree {
 4387            path: std::path::PathBuf::from("/wt-feature-a"),
 4388            ref_name: Some("refs/heads/feature-a".into()),
 4389            sha: "aaa".into(),
 4390            is_main: false,
 4391        },
 4392    )
 4393    .await;
 4394
 4395    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4396
 4397    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4398    main_project
 4399        .update(cx, |p, cx| p.git_scans_complete(cx))
 4400        .await;
 4401
 4402    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4403    worktree_project
 4404        .update(cx, |p, cx| p.git_scans_complete(cx))
 4405        .await;
 4406
 4407    let (multi_workspace, cx) =
 4408        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4409    let sidebar = setup_sidebar(&multi_workspace, cx);
 4410
 4411    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4412
 4413    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4414    cx.run_until_parked();
 4415
 4416    assert_eq!(
 4417        visible_entries_as_strings(&sidebar, cx),
 4418        vec![
 4419            //
 4420            "v [project]",
 4421            "  WT Thread {wt-feature-a}",
 4422        ],
 4423    );
 4424
 4425    focus_sidebar(&sidebar, cx);
 4426    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4427        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 4428    });
 4429
 4430    let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
 4431        let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
 4432            if let ListEntry::ProjectHeader { label, .. } = entry {
 4433                Some(label.as_ref())
 4434            } else {
 4435                None
 4436            }
 4437        });
 4438
 4439        let Some(project_header) = project_headers.next() else {
 4440            panic!("expected exactly one sidebar project header named `project`, found none");
 4441        };
 4442        assert_eq!(
 4443            project_header, "project",
 4444            "expected the only sidebar project header to be `project`"
 4445        );
 4446        if let Some(unexpected_header) = project_headers.next() {
 4447            panic!(
 4448                "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
 4449            );
 4450        }
 4451
 4452        let mut saw_expected_thread = false;
 4453        for entry in &sidebar.contents.entries {
 4454            match entry {
 4455                ListEntry::ProjectHeader { label, .. } => {
 4456                    assert_eq!(
 4457                        label.as_ref(),
 4458                        "project",
 4459                        "expected the only sidebar project header to be `project`"
 4460                    );
 4461                }
 4462                ListEntry::Thread(thread)
 4463                    if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
 4464                        && thread.worktrees.first().map(|wt| wt.name.as_ref())
 4465                            == Some("wt-feature-a") =>
 4466                {
 4467                    saw_expected_thread = true;
 4468                }
 4469                ListEntry::Thread(thread) if thread.is_draft => {}
 4470                ListEntry::Thread(thread) => {
 4471                    let title = thread.metadata.display_title();
 4472                    let worktree_name = thread
 4473                        .worktrees
 4474                        .first()
 4475                        .map(|wt| wt.name.as_ref())
 4476                        .unwrap_or("<none>");
 4477                    panic!(
 4478                        "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
 4479                        title, worktree_name
 4480                    );
 4481                }
 4482                ListEntry::ViewMore { .. } => {
 4483                    panic!("unexpected `View More` entry while opening linked worktree thread");
 4484                }
 4485            }
 4486        }
 4487
 4488        assert!(
 4489            saw_expected_thread,
 4490            "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
 4491        );
 4492    };
 4493
 4494    sidebar
 4495        .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
 4496        .detach();
 4497
 4498    let window = cx.windows()[0];
 4499    cx.update_window(window, |_, window, cx| {
 4500        window.dispatch_action(Confirm.boxed_clone(), cx);
 4501    })
 4502    .unwrap();
 4503
 4504    cx.run_until_parked();
 4505
 4506    sidebar.update(cx, assert_sidebar_state);
 4507}
 4508
 4509#[gpui::test]
 4510async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
 4511    cx: &mut TestAppContext,
 4512) {
 4513    init_test(cx);
 4514    let fs = FakeFs::new(cx.executor());
 4515
 4516    fs.insert_tree(
 4517        "/project",
 4518        serde_json::json!({
 4519            ".git": {},
 4520            "src": {},
 4521        }),
 4522    )
 4523    .await;
 4524
 4525    fs.add_linked_worktree_for_repo(
 4526        Path::new("/project/.git"),
 4527        false,
 4528        git::repository::Worktree {
 4529            path: std::path::PathBuf::from("/wt-feature-a"),
 4530            ref_name: Some("refs/heads/feature-a".into()),
 4531            sha: "aaa".into(),
 4532            is_main: false,
 4533        },
 4534    )
 4535    .await;
 4536
 4537    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4538
 4539    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4540    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4541
 4542    main_project
 4543        .update(cx, |p, cx| p.git_scans_complete(cx))
 4544        .await;
 4545    worktree_project
 4546        .update(cx, |p, cx| p.git_scans_complete(cx))
 4547        .await;
 4548
 4549    let (multi_workspace, cx) =
 4550        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4551
 4552    let sidebar = setup_sidebar(&multi_workspace, cx);
 4553
 4554    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4555        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4556    });
 4557
 4558    // Activate the main workspace before setting up the sidebar.
 4559    let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4560        let workspace = mw.workspaces().next().unwrap().clone();
 4561        mw.activate(workspace.clone(), window, cx);
 4562        workspace
 4563    });
 4564
 4565    save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
 4566    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4567
 4568    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4569    cx.run_until_parked();
 4570
 4571    // The worktree workspace should be absorbed under the main repo.
 4572    let entries = visible_entries_as_strings(&sidebar, cx);
 4573    assert_eq!(entries.len(), 3);
 4574    assert_eq!(entries[0], "v [project]");
 4575    assert!(entries.contains(&"  Main Thread".to_string()));
 4576    assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
 4577
 4578    let wt_thread_index = entries
 4579        .iter()
 4580        .position(|e| e.contains("WT Thread"))
 4581        .expect("should find the worktree thread entry");
 4582
 4583    assert_eq!(
 4584        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4585        main_workspace,
 4586        "main workspace should be active initially"
 4587    );
 4588
 4589    // Focus the sidebar and select the absorbed worktree thread.
 4590    focus_sidebar(&sidebar, cx);
 4591    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4592        sidebar.selection = Some(wt_thread_index);
 4593    });
 4594
 4595    // Confirm to activate the worktree thread.
 4596    cx.dispatch_action(Confirm);
 4597    cx.run_until_parked();
 4598
 4599    // The worktree workspace should now be active, not the main one.
 4600    let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 4601    assert_eq!(
 4602        active_workspace, worktree_workspace,
 4603        "clicking an absorbed worktree thread should activate the worktree workspace"
 4604    );
 4605}
 4606
 4607#[gpui::test]
 4608async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
 4609    cx: &mut TestAppContext,
 4610) {
 4611    // Thread has saved metadata in ThreadStore. A matching workspace is
 4612    // already open. Expected: activates the matching workspace.
 4613    init_test(cx);
 4614    let fs = FakeFs::new(cx.executor());
 4615    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4616        .await;
 4617    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4618        .await;
 4619    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4620
 4621    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4622    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4623
 4624    let (multi_workspace, cx) =
 4625        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 4626
 4627    let sidebar = setup_sidebar(&multi_workspace, cx);
 4628
 4629    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4630        mw.test_add_workspace(project_b.clone(), window, cx)
 4631    });
 4632    let workspace_a =
 4633        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4634
 4635    // Save a thread with path_list pointing to project-b.
 4636    let session_id = acp::SessionId::new(Arc::from("archived-1"));
 4637    save_test_thread_metadata(&session_id, &project_b, cx).await;
 4638
 4639    // Ensure workspace A is active.
 4640    multi_workspace.update_in(cx, |mw, window, cx| {
 4641        let workspace = mw.workspaces().next().unwrap().clone();
 4642        mw.activate(workspace, window, cx);
 4643    });
 4644    cx.run_until_parked();
 4645    assert_eq!(
 4646        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4647        workspace_a
 4648    );
 4649
 4650    // Call activate_archived_thread – should resolve saved paths and
 4651    // switch to the workspace for project-b.
 4652    sidebar.update_in(cx, |sidebar, window, cx| {
 4653        sidebar.activate_archived_thread(
 4654            ThreadMetadata {
 4655                thread_id: ThreadId::new(),
 4656                session_id: Some(session_id.clone()),
 4657                agent_id: agent::ZED_AGENT_ID.clone(),
 4658                title: Some("Archived Thread".into()),
 4659                updated_at: Utc::now(),
 4660                created_at: None,
 4661                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4662                    "/project-b",
 4663                )])),
 4664                archived: false,
 4665                remote_connection: None,
 4666            },
 4667            window,
 4668            cx,
 4669        );
 4670    });
 4671    cx.run_until_parked();
 4672
 4673    assert_eq!(
 4674        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4675        workspace_b,
 4676        "should have switched to the workspace matching the saved paths"
 4677    );
 4678}
 4679
 4680#[gpui::test]
 4681async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
 4682    cx: &mut TestAppContext,
 4683) {
 4684    // Thread has no saved metadata but session_info has cwd. A matching
 4685    // workspace is open. Expected: uses cwd to find and activate it.
 4686    init_test(cx);
 4687    let fs = FakeFs::new(cx.executor());
 4688    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4689        .await;
 4690    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4691        .await;
 4692    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4693
 4694    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4695    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4696
 4697    let (multi_workspace, cx) =
 4698        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4699
 4700    let sidebar = setup_sidebar(&multi_workspace, cx);
 4701
 4702    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4703        mw.test_add_workspace(project_b, window, cx)
 4704    });
 4705    let workspace_a =
 4706        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4707
 4708    // Start with workspace A active.
 4709    multi_workspace.update_in(cx, |mw, window, cx| {
 4710        let workspace = mw.workspaces().next().unwrap().clone();
 4711        mw.activate(workspace, window, cx);
 4712    });
 4713    cx.run_until_parked();
 4714    assert_eq!(
 4715        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4716        workspace_a
 4717    );
 4718
 4719    // No thread saved to the store – cwd is the only path hint.
 4720    sidebar.update_in(cx, |sidebar, window, cx| {
 4721        sidebar.activate_archived_thread(
 4722            ThreadMetadata {
 4723                thread_id: ThreadId::new(),
 4724                session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
 4725                agent_id: agent::ZED_AGENT_ID.clone(),
 4726                title: Some("CWD Thread".into()),
 4727                updated_at: Utc::now(),
 4728                created_at: None,
 4729                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 4730                    std::path::PathBuf::from("/project-b"),
 4731                ])),
 4732                archived: false,
 4733                remote_connection: None,
 4734            },
 4735            window,
 4736            cx,
 4737        );
 4738    });
 4739    cx.run_until_parked();
 4740
 4741    assert_eq!(
 4742        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4743        workspace_b,
 4744        "should have activated the workspace matching the cwd"
 4745    );
 4746}
 4747
 4748#[gpui::test]
 4749async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
 4750    cx: &mut TestAppContext,
 4751) {
 4752    // Thread has no saved metadata and no cwd. Expected: falls back to
 4753    // the currently active workspace.
 4754    init_test(cx);
 4755    let fs = FakeFs::new(cx.executor());
 4756    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4757        .await;
 4758    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4759        .await;
 4760    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4761
 4762    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4763    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4764
 4765    let (multi_workspace, cx) =
 4766        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4767
 4768    let sidebar = setup_sidebar(&multi_workspace, cx);
 4769
 4770    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4771        mw.test_add_workspace(project_b, window, cx)
 4772    });
 4773
 4774    // Activate workspace B (index 1) to make it the active one.
 4775    multi_workspace.update_in(cx, |mw, window, cx| {
 4776        let workspace = mw.workspaces().nth(1).unwrap().clone();
 4777        mw.activate(workspace, window, cx);
 4778    });
 4779    cx.run_until_parked();
 4780    assert_eq!(
 4781        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4782        workspace_b
 4783    );
 4784
 4785    // No saved thread, no cwd – should fall back to the active workspace.
 4786    sidebar.update_in(cx, |sidebar, window, cx| {
 4787        sidebar.activate_archived_thread(
 4788            ThreadMetadata {
 4789                thread_id: ThreadId::new(),
 4790                session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
 4791                agent_id: agent::ZED_AGENT_ID.clone(),
 4792                title: Some("Contextless Thread".into()),
 4793                updated_at: Utc::now(),
 4794                created_at: None,
 4795                worktree_paths: WorktreePaths::default(),
 4796                archived: false,
 4797                remote_connection: None,
 4798            },
 4799            window,
 4800            cx,
 4801        );
 4802    });
 4803    cx.run_until_parked();
 4804
 4805    assert_eq!(
 4806        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4807        workspace_b,
 4808        "should have stayed on the active workspace when no path info is available"
 4809    );
 4810}
 4811
 4812#[gpui::test]
 4813async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
 4814    // Thread has saved metadata pointing to a path with no open workspace.
 4815    // Expected: opens a new workspace for that path.
 4816    init_test(cx);
 4817    let fs = FakeFs::new(cx.executor());
 4818    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4819        .await;
 4820    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4821        .await;
 4822    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4823
 4824    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4825
 4826    let (multi_workspace, cx) =
 4827        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4828
 4829    let sidebar = setup_sidebar(&multi_workspace, cx);
 4830
 4831    // Save a thread with path_list pointing to project-b – which has no
 4832    // open workspace.
 4833    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 4834    let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
 4835
 4836    assert_eq!(
 4837        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4838        1,
 4839        "should start with one workspace"
 4840    );
 4841
 4842    sidebar.update_in(cx, |sidebar, window, cx| {
 4843        sidebar.activate_archived_thread(
 4844            ThreadMetadata {
 4845                thread_id: ThreadId::new(),
 4846                session_id: Some(session_id.clone()),
 4847                agent_id: agent::ZED_AGENT_ID.clone(),
 4848                title: Some("New WS Thread".into()),
 4849                updated_at: Utc::now(),
 4850                created_at: None,
 4851                worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 4852                archived: false,
 4853                remote_connection: None,
 4854            },
 4855            window,
 4856            cx,
 4857        );
 4858    });
 4859    cx.run_until_parked();
 4860
 4861    assert_eq!(
 4862        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4863        2,
 4864        "should have opened a second workspace for the archived thread's saved paths"
 4865    );
 4866}
 4867
 4868#[gpui::test]
 4869async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
 4870    init_test(cx);
 4871    let fs = FakeFs::new(cx.executor());
 4872    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4873        .await;
 4874    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4875        .await;
 4876    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4877
 4878    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4879    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4880
 4881    let multi_workspace_a =
 4882        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4883    let multi_workspace_b =
 4884        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4885
 4886    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4887    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4888
 4889    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4890    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4891
 4892    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4893    let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4894
 4895    let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
 4896
 4897    sidebar.update_in(cx_a, |sidebar, window, cx| {
 4898        sidebar.activate_archived_thread(
 4899            ThreadMetadata {
 4900                thread_id: ThreadId::new(),
 4901                session_id: Some(session_id.clone()),
 4902                agent_id: agent::ZED_AGENT_ID.clone(),
 4903                title: Some("Cross Window Thread".into()),
 4904                updated_at: Utc::now(),
 4905                created_at: None,
 4906                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4907                    "/project-b",
 4908                )])),
 4909                archived: false,
 4910                remote_connection: None,
 4911            },
 4912            window,
 4913            cx,
 4914        );
 4915    });
 4916    cx_a.run_until_parked();
 4917
 4918    assert_eq!(
 4919        multi_workspace_a
 4920            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4921            .unwrap(),
 4922        1,
 4923        "should not add the other window's workspace into the current window"
 4924    );
 4925    assert_eq!(
 4926        multi_workspace_b
 4927            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4928            .unwrap(),
 4929        1,
 4930        "should reuse the existing workspace in the other window"
 4931    );
 4932    assert!(
 4933        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4934        "should activate the window that already owns the matching workspace"
 4935    );
 4936    sidebar.read_with(cx_a, |sidebar, _| {
 4937            assert!(
 4938                !is_active_session(&sidebar, &session_id),
 4939                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4940            );
 4941        });
 4942}
 4943
 4944#[gpui::test]
 4945async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
 4946    cx: &mut TestAppContext,
 4947) {
 4948    init_test(cx);
 4949    let fs = FakeFs::new(cx.executor());
 4950    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4951        .await;
 4952    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4953        .await;
 4954    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4955
 4956    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4957    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4958
 4959    let multi_workspace_a =
 4960        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4961    let multi_workspace_b =
 4962        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
 4963
 4964    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4965    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4966
 4967    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4968    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4969
 4970    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4971    let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4972    let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
 4973    let _panel_b = add_agent_panel(&workspace_b, cx_b);
 4974
 4975    let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
 4976
 4977    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4978        sidebar.activate_archived_thread(
 4979            ThreadMetadata {
 4980                thread_id: ThreadId::new(),
 4981                session_id: Some(session_id.clone()),
 4982                agent_id: agent::ZED_AGENT_ID.clone(),
 4983                title: Some("Cross Window Thread".into()),
 4984                updated_at: Utc::now(),
 4985                created_at: None,
 4986                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4987                    "/project-b",
 4988                )])),
 4989                archived: false,
 4990                remote_connection: None,
 4991            },
 4992            window,
 4993            cx,
 4994        );
 4995    });
 4996    cx_a.run_until_parked();
 4997
 4998    assert_eq!(
 4999        multi_workspace_a
 5000            .read_with(cx_a, |mw, _| mw.workspaces().count())
 5001            .unwrap(),
 5002        1,
 5003        "should not add the other window's workspace into the current window"
 5004    );
 5005    assert_eq!(
 5006        multi_workspace_b
 5007            .read_with(cx_a, |mw, _| mw.workspaces().count())
 5008            .unwrap(),
 5009        1,
 5010        "should reuse the existing workspace in the other window"
 5011    );
 5012    assert!(
 5013        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 5014        "should activate the window that already owns the matching workspace"
 5015    );
 5016    sidebar_a.read_with(cx_a, |sidebar, _| {
 5017            assert!(
 5018                !is_active_session(&sidebar, &session_id),
 5019                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 5020            );
 5021        });
 5022    sidebar_b.read_with(cx_b, |sidebar, _| {
 5023        assert_active_thread(
 5024            sidebar,
 5025            &session_id,
 5026            "target window's sidebar should eagerly focus the activated archived thread",
 5027        );
 5028    });
 5029}
 5030
 5031#[gpui::test]
 5032async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
 5033    cx: &mut TestAppContext,
 5034) {
 5035    init_test(cx);
 5036    let fs = FakeFs::new(cx.executor());
 5037    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 5038        .await;
 5039    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5040
 5041    let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 5042    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 5043
 5044    let multi_workspace_b =
 5045        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 5046    let multi_workspace_a =
 5047        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 5048
 5049    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 5050    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 5051
 5052    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 5053    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 5054
 5055    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 5056    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 5057
 5058    let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
 5059
 5060    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 5061        sidebar.activate_archived_thread(
 5062            ThreadMetadata {
 5063                thread_id: ThreadId::new(),
 5064                session_id: Some(session_id.clone()),
 5065                agent_id: agent::ZED_AGENT_ID.clone(),
 5066                title: Some("Current Window Thread".into()),
 5067                updated_at: Utc::now(),
 5068                created_at: None,
 5069                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 5070                    "/project-a",
 5071                )])),
 5072                archived: false,
 5073                remote_connection: None,
 5074            },
 5075            window,
 5076            cx,
 5077        );
 5078    });
 5079    cx_a.run_until_parked();
 5080
 5081    assert!(
 5082        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
 5083        "should keep activation in the current window when it already has a matching workspace"
 5084    );
 5085    sidebar_a.read_with(cx_a, |sidebar, _| {
 5086        assert_active_thread(
 5087            sidebar,
 5088            &session_id,
 5089            "current window's sidebar should eagerly focus the activated archived thread",
 5090        );
 5091    });
 5092    assert_eq!(
 5093        multi_workspace_a
 5094            .read_with(cx_a, |mw, _| mw.workspaces().count())
 5095            .unwrap(),
 5096        1,
 5097        "current window should continue reusing its existing workspace"
 5098    );
 5099    assert_eq!(
 5100        multi_workspace_b
 5101            .read_with(cx_a, |mw, _| mw.workspaces().count())
 5102            .unwrap(),
 5103        1,
 5104        "other windows should not be activated just because they also match the saved paths"
 5105    );
 5106}
 5107
 5108#[gpui::test]
 5109async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
 5110    // Regression test: archive_thread previously always loaded the next thread
 5111    // through group_workspace (the main workspace's ProjectHeader), even when
 5112    // the next thread belonged to an absorbed linked-worktree workspace. That
 5113    // caused the worktree thread to be loaded in the main panel, which bound it
 5114    // to the main project and corrupted its stored folder_paths.
 5115    //
 5116    // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
 5117    // falling back to group_workspace only for Closed workspaces.
 5118    agent_ui::test_support::init_test(cx);
 5119    cx.update(|cx| {
 5120        ThreadStore::init_global(cx);
 5121        ThreadMetadataStore::init_global(cx);
 5122        language_model::LanguageModelRegistry::test(cx);
 5123        prompt_store::init(cx);
 5124    });
 5125
 5126    let fs = FakeFs::new(cx.executor());
 5127
 5128    fs.insert_tree(
 5129        "/project",
 5130        serde_json::json!({
 5131            ".git": {},
 5132            "src": {},
 5133        }),
 5134    )
 5135    .await;
 5136
 5137    fs.add_linked_worktree_for_repo(
 5138        Path::new("/project/.git"),
 5139        false,
 5140        git::repository::Worktree {
 5141            path: std::path::PathBuf::from("/wt-feature-a"),
 5142            ref_name: Some("refs/heads/feature-a".into()),
 5143            sha: "aaa".into(),
 5144            is_main: false,
 5145        },
 5146    )
 5147    .await;
 5148
 5149    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5150
 5151    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5152    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5153
 5154    main_project
 5155        .update(cx, |p, cx| p.git_scans_complete(cx))
 5156        .await;
 5157    worktree_project
 5158        .update(cx, |p, cx| p.git_scans_complete(cx))
 5159        .await;
 5160
 5161    let (multi_workspace, cx) =
 5162        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5163
 5164    let sidebar = setup_sidebar(&multi_workspace, cx);
 5165
 5166    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5167        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5168    });
 5169
 5170    // Activate main workspace so the sidebar tracks the main panel.
 5171    multi_workspace.update_in(cx, |mw, window, cx| {
 5172        let workspace = mw.workspaces().next().unwrap().clone();
 5173        mw.activate(workspace, window, cx);
 5174    });
 5175
 5176    let main_workspace =
 5177        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 5178    let main_panel = add_agent_panel(&main_workspace, cx);
 5179    let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
 5180
 5181    // Open Thread 2 in the main panel and keep it running.
 5182    let connection = StubAgentConnection::new();
 5183    open_thread_with_connection(&main_panel, connection.clone(), cx);
 5184    send_message(&main_panel, cx);
 5185
 5186    let thread2_session_id = active_session_id(&main_panel, cx);
 5187
 5188    cx.update(|_, cx| {
 5189        connection.send_update(
 5190            thread2_session_id.clone(),
 5191            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 5192            cx,
 5193        );
 5194    });
 5195
 5196    // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
 5197    save_thread_metadata(
 5198        thread2_session_id.clone(),
 5199        Some("Thread 2".into()),
 5200        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5201        None,
 5202        &main_project,
 5203        cx,
 5204    );
 5205
 5206    // Save thread 1's metadata with the worktree path and an older timestamp so
 5207    // it sorts below thread 2. archive_thread will find it as the "next" candidate.
 5208    let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
 5209    save_thread_metadata(
 5210        thread1_session_id,
 5211        Some("Thread 1".into()),
 5212        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5213        None,
 5214        &worktree_project,
 5215        cx,
 5216    );
 5217
 5218    cx.run_until_parked();
 5219
 5220    // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
 5221    let entries_before = visible_entries_as_strings(&sidebar, cx);
 5222    assert!(
 5223        entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
 5224        "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
 5225        entries_before
 5226    );
 5227
 5228    // The sidebar should track T2 as the focused thread (derived from the
 5229    // main panel's active view).
 5230    sidebar.read_with(cx, |s, _| {
 5231        assert_active_thread(
 5232            s,
 5233            &thread2_session_id,
 5234            "focused thread should be Thread 2 before archiving",
 5235        );
 5236    });
 5237
 5238    // Archive thread 2.
 5239    sidebar.update_in(cx, |sidebar, window, cx| {
 5240        sidebar.archive_thread(&thread2_session_id, window, cx);
 5241    });
 5242
 5243    cx.run_until_parked();
 5244
 5245    // The main panel's active thread must still be thread 2.
 5246    let main_active = main_panel.read_with(cx, |panel, cx| {
 5247        panel
 5248            .active_agent_thread(cx)
 5249            .map(|t| t.read(cx).session_id().clone())
 5250    });
 5251    assert_eq!(
 5252        main_active,
 5253        Some(thread2_session_id.clone()),
 5254        "main panel should not have been taken over by loading the linked-worktree thread T1; \
 5255             before the fix, archive_thread used group_workspace instead of next.workspace, \
 5256             causing T1 to be loaded in the wrong panel"
 5257    );
 5258
 5259    // Thread 1 should still appear in the sidebar with its worktree chip
 5260    // (Thread 2 was archived so it is gone from the list).
 5261    let entries_after = visible_entries_as_strings(&sidebar, cx);
 5262    assert!(
 5263        entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
 5264        "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
 5265        entries_after
 5266    );
 5267}
 5268
 5269#[gpui::test]
 5270async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
 5271    // When the last non-archived thread for a linked worktree is archived,
 5272    // the linked worktree workspace should be removed from the multi-workspace.
 5273    // The main worktree workspace should remain (it's always reachable via
 5274    // the project header).
 5275    init_test(cx);
 5276    let fs = FakeFs::new(cx.executor());
 5277
 5278    fs.insert_tree(
 5279        "/project",
 5280        serde_json::json!({
 5281            ".git": {
 5282                "worktrees": {
 5283                    "feature-a": {
 5284                        "commondir": "../../",
 5285                        "HEAD": "ref: refs/heads/feature-a",
 5286                    },
 5287                },
 5288            },
 5289            "src": {},
 5290        }),
 5291    )
 5292    .await;
 5293
 5294    fs.insert_tree(
 5295        "/wt-feature-a",
 5296        serde_json::json!({
 5297            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5298            "src": {},
 5299        }),
 5300    )
 5301    .await;
 5302
 5303    fs.add_linked_worktree_for_repo(
 5304        Path::new("/project/.git"),
 5305        false,
 5306        git::repository::Worktree {
 5307            path: PathBuf::from("/wt-feature-a"),
 5308            ref_name: Some("refs/heads/feature-a".into()),
 5309            sha: "abc".into(),
 5310            is_main: false,
 5311        },
 5312    )
 5313    .await;
 5314
 5315    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5316
 5317    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5318    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5319
 5320    main_project
 5321        .update(cx, |p, cx| p.git_scans_complete(cx))
 5322        .await;
 5323    worktree_project
 5324        .update(cx, |p, cx| p.git_scans_complete(cx))
 5325        .await;
 5326
 5327    let (multi_workspace, cx) =
 5328        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5329    let sidebar = setup_sidebar(&multi_workspace, cx);
 5330
 5331    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5332        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5333    });
 5334
 5335    // Save a thread for the main project.
 5336    save_thread_metadata(
 5337        acp::SessionId::new(Arc::from("main-thread")),
 5338        Some("Main Thread".into()),
 5339        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5340        None,
 5341        &main_project,
 5342        cx,
 5343    );
 5344
 5345    // Save a thread for the linked worktree.
 5346    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 5347    save_thread_metadata(
 5348        wt_thread_id.clone(),
 5349        Some("Worktree Thread".into()),
 5350        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5351        None,
 5352        &worktree_project,
 5353        cx,
 5354    );
 5355    cx.run_until_parked();
 5356
 5357    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5358    cx.run_until_parked();
 5359
 5360    // Should have 2 workspaces.
 5361    assert_eq!(
 5362        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5363        2,
 5364        "should start with 2 workspaces (main + linked worktree)"
 5365    );
 5366
 5367    // Archive the worktree thread (the only thread for /wt-feature-a).
 5368    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 5369        sidebar.archive_thread(&wt_thread_id, window, cx);
 5370    });
 5371
 5372    // archive_thread spawns a multi-layered chain of tasks (workspace
 5373    // removal → git persist → disk removal), each of which may spawn
 5374    // further background work. Each run_until_parked() call drives one
 5375    // layer of pending work.
 5376    cx.run_until_parked();
 5377    cx.run_until_parked();
 5378    cx.run_until_parked();
 5379
 5380    // The linked worktree workspace should have been removed.
 5381    assert_eq!(
 5382        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5383        1,
 5384        "linked worktree workspace should be removed after archiving its last thread"
 5385    );
 5386
 5387    // The linked worktree checkout directory should also be removed from disk.
 5388    assert!(
 5389        !fs.is_dir(Path::new("/wt-feature-a")).await,
 5390        "linked worktree directory should be removed from disk after archiving its last thread"
 5391    );
 5392
 5393    // The main thread should still be visible.
 5394    let entries = visible_entries_as_strings(&sidebar, cx);
 5395    assert!(
 5396        entries.iter().any(|e| e.contains("Main Thread")),
 5397        "main thread should still be visible: {entries:?}"
 5398    );
 5399    assert!(
 5400        !entries.iter().any(|e| e.contains("Worktree Thread")),
 5401        "archived worktree thread should not be visible: {entries:?}"
 5402    );
 5403}
 5404
 5405#[gpui::test]
 5406async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
 5407    // When a multi-root workspace (e.g. [/other, /project]) shares a
 5408    // repo with a single-root workspace (e.g. [/project]), linked
 5409    // worktree threads from the shared repo should only appear under
 5410    // the dedicated group [project], not under [other, project].
 5411    agent_ui::test_support::init_test(cx);
 5412    cx.update(|cx| {
 5413        ThreadStore::init_global(cx);
 5414        ThreadMetadataStore::init_global(cx);
 5415        language_model::LanguageModelRegistry::test(cx);
 5416        prompt_store::init(cx);
 5417    });
 5418    let fs = FakeFs::new(cx.executor());
 5419
 5420    // Two independent repos, each with their own git history.
 5421    fs.insert_tree(
 5422        "/project",
 5423        serde_json::json!({
 5424            ".git": {},
 5425            "src": {},
 5426        }),
 5427    )
 5428    .await;
 5429    fs.insert_tree(
 5430        "/other",
 5431        serde_json::json!({
 5432            ".git": {},
 5433            "src": {},
 5434        }),
 5435    )
 5436    .await;
 5437
 5438    // Register the linked worktree in the main repo.
 5439    fs.add_linked_worktree_for_repo(
 5440        Path::new("/project/.git"),
 5441        false,
 5442        git::repository::Worktree {
 5443            path: std::path::PathBuf::from("/wt-feature-a"),
 5444            ref_name: Some("refs/heads/feature-a".into()),
 5445            sha: "aaa".into(),
 5446            is_main: false,
 5447        },
 5448    )
 5449    .await;
 5450
 5451    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5452
 5453    // Workspace 1: just /project.
 5454    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5455    project_only
 5456        .update(cx, |p, cx| p.git_scans_complete(cx))
 5457        .await;
 5458
 5459    // Workspace 2: /other and /project together (multi-root).
 5460    let multi_root =
 5461        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
 5462    multi_root
 5463        .update(cx, |p, cx| p.git_scans_complete(cx))
 5464        .await;
 5465
 5466    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5467    worktree_project
 5468        .update(cx, |p, cx| p.git_scans_complete(cx))
 5469        .await;
 5470
 5471    // Save a thread under the linked worktree path BEFORE setting up
 5472    // the sidebar and panels, so that reconciliation sees the [project]
 5473    // group as non-empty and doesn't create a spurious draft there.
 5474    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
 5475    save_thread_metadata(
 5476        wt_session_id,
 5477        Some("Worktree Thread".into()),
 5478        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5479        None,
 5480        &worktree_project,
 5481        cx,
 5482    );
 5483
 5484    let (multi_workspace, cx) =
 5485        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
 5486    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5487    let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5488        mw.test_add_workspace(multi_root.clone(), window, cx)
 5489    });
 5490    add_agent_panel(&multi_root_workspace, cx);
 5491    cx.run_until_parked();
 5492
 5493    // The thread should appear only under [project] (the dedicated
 5494    // group for the /project repo), not under [other, project].
 5495    assert_eq!(
 5496        visible_entries_as_strings(&sidebar, cx),
 5497        vec![
 5498            //
 5499            "v [other, project]",
 5500            "v [project]",
 5501            "  Worktree Thread {wt-feature-a}",
 5502        ]
 5503    );
 5504}
 5505
 5506#[gpui::test]
 5507async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
 5508    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 5509    let (multi_workspace, cx) =
 5510        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 5511    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5512
 5513    let switcher_ids =
 5514        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
 5515            sidebar.read_with(cx, |sidebar, cx| {
 5516                let switcher = sidebar
 5517                    .thread_switcher
 5518                    .as_ref()
 5519                    .expect("switcher should be open");
 5520                switcher
 5521                    .read(cx)
 5522                    .entries()
 5523                    .iter()
 5524                    .map(|e| e.session_id.clone())
 5525                    .collect()
 5526            })
 5527        };
 5528
 5529    let switcher_selected_id =
 5530        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
 5531            sidebar.read_with(cx, |sidebar, cx| {
 5532                let switcher = sidebar
 5533                    .thread_switcher
 5534                    .as_ref()
 5535                    .expect("switcher should be open");
 5536                let s = switcher.read(cx);
 5537                s.selected_entry()
 5538                    .expect("should have selection")
 5539                    .session_id
 5540                    .clone()
 5541            })
 5542        };
 5543
 5544    // ── Setup: create three threads with distinct created_at times ──────
 5545    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
 5546    // We send messages in each so they also get last_message_sent_or_queued timestamps.
 5547    let connection_c = StubAgentConnection::new();
 5548    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5549        acp::ContentChunk::new("Done C".into()),
 5550    )]);
 5551    open_thread_with_connection(&panel, connection_c, cx);
 5552    send_message(&panel, cx);
 5553    let session_id_c = active_session_id(&panel, cx);
 5554    save_thread_metadata(
 5555        session_id_c.clone(),
 5556        Some("Thread C".into()),
 5557        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5558        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
 5559        &project,
 5560        cx,
 5561    );
 5562
 5563    let connection_b = StubAgentConnection::new();
 5564    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5565        acp::ContentChunk::new("Done B".into()),
 5566    )]);
 5567    open_thread_with_connection(&panel, connection_b, cx);
 5568    send_message(&panel, cx);
 5569    let session_id_b = active_session_id(&panel, cx);
 5570    save_thread_metadata(
 5571        session_id_b.clone(),
 5572        Some("Thread B".into()),
 5573        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5574        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
 5575        &project,
 5576        cx,
 5577    );
 5578
 5579    let connection_a = StubAgentConnection::new();
 5580    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5581        acp::ContentChunk::new("Done A".into()),
 5582    )]);
 5583    open_thread_with_connection(&panel, connection_a, cx);
 5584    send_message(&panel, cx);
 5585    let session_id_a = active_session_id(&panel, cx);
 5586    save_thread_metadata(
 5587        session_id_a.clone(),
 5588        Some("Thread A".into()),
 5589        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
 5590        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
 5591        &project,
 5592        cx,
 5593    );
 5594
 5595    // All three threads are now live. Thread A was opened last, so it's
 5596    // the one being viewed. Opening each thread called record_thread_access,
 5597    // so all three have last_accessed_at set.
 5598    // Access order is: A (most recent), B, C (oldest).
 5599
 5600    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
 5601    focus_sidebar(&sidebar, cx);
 5602    sidebar.update_in(cx, |sidebar, window, cx| {
 5603        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5604    });
 5605    cx.run_until_parked();
 5606
 5607    // All three have last_accessed_at, so they sort by access time.
 5608    // A was accessed most recently (it's the currently viewed thread),
 5609    // then B, then C.
 5610    assert_eq!(
 5611        switcher_ids(&sidebar, cx),
 5612        vec![
 5613            session_id_a.clone(),
 5614            session_id_b.clone(),
 5615            session_id_c.clone()
 5616        ],
 5617    );
 5618    // First ctrl-tab selects the second entry (B).
 5619    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
 5620
 5621    // Dismiss the switcher without confirming.
 5622    sidebar.update_in(cx, |sidebar, _window, cx| {
 5623        sidebar.dismiss_thread_switcher(cx);
 5624    });
 5625    cx.run_until_parked();
 5626
 5627    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
 5628    sidebar.update_in(cx, |sidebar, window, cx| {
 5629        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5630    });
 5631    cx.run_until_parked();
 5632
 5633    // Cycle twice to land on Thread C (index 2).
 5634    sidebar.read_with(cx, |sidebar, cx| {
 5635        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5636        assert_eq!(switcher.read(cx).selected_index(), 1);
 5637    });
 5638    sidebar.update_in(cx, |sidebar, _window, cx| {
 5639        sidebar
 5640            .thread_switcher
 5641            .as_ref()
 5642            .unwrap()
 5643            .update(cx, |s, cx| s.cycle_selection(cx));
 5644    });
 5645    cx.run_until_parked();
 5646    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
 5647
 5648    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
 5649
 5650    // Confirm on Thread C.
 5651    sidebar.update_in(cx, |sidebar, window, cx| {
 5652        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5653        let focus = switcher.focus_handle(cx);
 5654        focus.dispatch_action(&menu::Confirm, window, cx);
 5655    });
 5656    cx.run_until_parked();
 5657
 5658    // Switcher should be dismissed after confirm.
 5659    sidebar.read_with(cx, |sidebar, _cx| {
 5660        assert!(
 5661            sidebar.thread_switcher.is_none(),
 5662            "switcher should be dismissed"
 5663        );
 5664    });
 5665
 5666    sidebar.update(cx, |sidebar, _cx| {
 5667        let last_accessed = sidebar
 5668            .thread_last_accessed
 5669            .keys()
 5670            .cloned()
 5671            .collect::<Vec<_>>();
 5672        assert_eq!(last_accessed.len(), 1);
 5673        assert!(last_accessed.contains(&session_id_c));
 5674        assert!(
 5675            is_active_session(&sidebar, &session_id_c),
 5676            "active_entry should be Thread({session_id_c:?})"
 5677        );
 5678    });
 5679
 5680    sidebar.update_in(cx, |sidebar, window, cx| {
 5681        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5682    });
 5683    cx.run_until_parked();
 5684
 5685    assert_eq!(
 5686        switcher_ids(&sidebar, cx),
 5687        vec![
 5688            session_id_c.clone(),
 5689            session_id_a.clone(),
 5690            session_id_b.clone()
 5691        ],
 5692    );
 5693
 5694    // Confirm on Thread A.
 5695    sidebar.update_in(cx, |sidebar, window, cx| {
 5696        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5697        let focus = switcher.focus_handle(cx);
 5698        focus.dispatch_action(&menu::Confirm, window, cx);
 5699    });
 5700    cx.run_until_parked();
 5701
 5702    sidebar.update(cx, |sidebar, _cx| {
 5703        let last_accessed = sidebar
 5704            .thread_last_accessed
 5705            .keys()
 5706            .cloned()
 5707            .collect::<Vec<_>>();
 5708        assert_eq!(last_accessed.len(), 2);
 5709        assert!(last_accessed.contains(&session_id_c));
 5710        assert!(last_accessed.contains(&session_id_a));
 5711        assert!(
 5712            is_active_session(&sidebar, &session_id_a),
 5713            "active_entry should be Thread({session_id_a:?})"
 5714        );
 5715    });
 5716
 5717    sidebar.update_in(cx, |sidebar, window, cx| {
 5718        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5719    });
 5720    cx.run_until_parked();
 5721
 5722    assert_eq!(
 5723        switcher_ids(&sidebar, cx),
 5724        vec![
 5725            session_id_a.clone(),
 5726            session_id_c.clone(),
 5727            session_id_b.clone(),
 5728        ],
 5729    );
 5730
 5731    sidebar.update_in(cx, |sidebar, _window, cx| {
 5732        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5733        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
 5734    });
 5735    cx.run_until_parked();
 5736
 5737    // Confirm on Thread B.
 5738    sidebar.update_in(cx, |sidebar, window, cx| {
 5739        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5740        let focus = switcher.focus_handle(cx);
 5741        focus.dispatch_action(&menu::Confirm, window, cx);
 5742    });
 5743    cx.run_until_parked();
 5744
 5745    sidebar.update(cx, |sidebar, _cx| {
 5746        let last_accessed = sidebar
 5747            .thread_last_accessed
 5748            .keys()
 5749            .cloned()
 5750            .collect::<Vec<_>>();
 5751        assert_eq!(last_accessed.len(), 3);
 5752        assert!(last_accessed.contains(&session_id_c));
 5753        assert!(last_accessed.contains(&session_id_a));
 5754        assert!(last_accessed.contains(&session_id_b));
 5755        assert!(
 5756            is_active_session(&sidebar, &session_id_b),
 5757            "active_entry should be Thread({session_id_b:?})"
 5758        );
 5759    });
 5760
 5761    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
 5762    // This thread was never opened in a panel — it only exists in metadata.
 5763    save_thread_metadata(
 5764        acp::SessionId::new(Arc::from("thread-historical")),
 5765        Some("Historical Thread".into()),
 5766        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 5767        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
 5768        &project,
 5769        cx,
 5770    );
 5771
 5772    sidebar.update_in(cx, |sidebar, window, cx| {
 5773        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5774    });
 5775    cx.run_until_parked();
 5776
 5777    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
 5778    // so it falls to tier 3 (sorted by created_at). It should appear after all
 5779    // accessed threads, even though its created_at (June 2024) is much later
 5780    // than the others.
 5781    //
 5782    // But the live threads (A, B, C) each had send_message called which sets
 5783    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
 5784    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
 5785    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
 5786
 5787    let ids = switcher_ids(&sidebar, cx);
 5788    assert_eq!(
 5789        ids,
 5790        vec![
 5791            session_id_b.clone(),
 5792            session_id_a.clone(),
 5793            session_id_c.clone(),
 5794            session_id_hist.clone()
 5795        ],
 5796    );
 5797
 5798    sidebar.update_in(cx, |sidebar, _window, cx| {
 5799        sidebar.dismiss_thread_switcher(cx);
 5800    });
 5801    cx.run_until_parked();
 5802
 5803    // ── 4. Add another historical thread with older created_at ─────────
 5804    save_thread_metadata(
 5805        acp::SessionId::new(Arc::from("thread-old-historical")),
 5806        Some("Old Historical Thread".into()),
 5807        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
 5808        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 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    // Both historical threads have no access or message times. They should
 5819    // appear after accessed threads, sorted by created_at (newest first).
 5820    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
 5821    let ids = switcher_ids(&sidebar, cx);
 5822    assert_eq!(
 5823        ids,
 5824        vec![
 5825            session_id_b,
 5826            session_id_a,
 5827            session_id_c,
 5828            session_id_hist,
 5829            session_id_old_hist,
 5830        ],
 5831    );
 5832
 5833    sidebar.update_in(cx, |sidebar, _window, cx| {
 5834        sidebar.dismiss_thread_switcher(cx);
 5835    });
 5836    cx.run_until_parked();
 5837}
 5838
 5839#[gpui::test]
 5840async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
 5841    let project = init_test_project("/my-project", cx).await;
 5842    let (multi_workspace, cx) =
 5843        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 5844    let sidebar = setup_sidebar(&multi_workspace, cx);
 5845
 5846    save_thread_metadata(
 5847        acp::SessionId::new(Arc::from("thread-to-archive")),
 5848        Some("Thread To Archive".into()),
 5849        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5850        None,
 5851        &project,
 5852        cx,
 5853    );
 5854    cx.run_until_parked();
 5855
 5856    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5857    cx.run_until_parked();
 5858
 5859    let entries = visible_entries_as_strings(&sidebar, cx);
 5860    assert!(
 5861        entries.iter().any(|e| e.contains("Thread To Archive")),
 5862        "expected thread to be visible before archiving, got: {entries:?}"
 5863    );
 5864
 5865    sidebar.update_in(cx, |sidebar, window, cx| {
 5866        sidebar.archive_thread(
 5867            &acp::SessionId::new(Arc::from("thread-to-archive")),
 5868            window,
 5869            cx,
 5870        );
 5871    });
 5872    cx.run_until_parked();
 5873
 5874    let entries = visible_entries_as_strings(&sidebar, cx);
 5875    assert!(
 5876        !entries.iter().any(|e| e.contains("Thread To Archive")),
 5877        "expected thread to be hidden after archiving, got: {entries:?}"
 5878    );
 5879
 5880    cx.update(|_, cx| {
 5881        let store = ThreadMetadataStore::global(cx);
 5882        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 5883        assert_eq!(archived.len(), 1);
 5884        assert_eq!(
 5885            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 5886            "thread-to-archive"
 5887        );
 5888        assert!(archived[0].archived);
 5889    });
 5890}
 5891
 5892#[gpui::test]
 5893async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
 5894    // Tests two archive scenarios:
 5895    // 1. Archiving a thread in a non-active workspace leaves active_entry
 5896    //    as the current draft.
 5897    // 2. Archiving the thread the user is looking at falls back to a draft
 5898    //    on the same workspace.
 5899    agent_ui::test_support::init_test(cx);
 5900    cx.update(|cx| {
 5901        ThreadStore::init_global(cx);
 5902        ThreadMetadataStore::init_global(cx);
 5903        language_model::LanguageModelRegistry::test(cx);
 5904        prompt_store::init(cx);
 5905    });
 5906
 5907    let fs = FakeFs::new(cx.executor());
 5908    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 5909        .await;
 5910    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 5911        .await;
 5912    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5913
 5914    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 5915    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 5916
 5917    let (multi_workspace, cx) =
 5918        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 5919    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5920
 5921    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 5922        mw.test_add_workspace(project_b.clone(), window, cx)
 5923    });
 5924    let panel_b = add_agent_panel(&workspace_b, cx);
 5925    cx.run_until_parked();
 5926
 5927    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 5928    sidebar.update_in(cx, |sidebar, window, cx| {
 5929        sidebar.create_new_thread(&workspace_b, window, cx);
 5930    });
 5931    cx.run_until_parked();
 5932
 5933    // --- Scenario 1: archive a thread in the non-active workspace ---
 5934
 5935    // Create a thread in project-a (non-active — project-b is active).
 5936    let connection = acp_thread::StubAgentConnection::new();
 5937    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5938        acp::ContentChunk::new("Done".into()),
 5939    )]);
 5940    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 5941    agent_ui::test_support::send_message(&panel_a, cx);
 5942    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 5943    cx.run_until_parked();
 5944
 5945    sidebar.update_in(cx, |sidebar, window, cx| {
 5946        sidebar.archive_thread(&thread_a, window, cx);
 5947    });
 5948    cx.run_until_parked();
 5949
 5950    // active_entry should still be a draft on workspace_b (the active one).
 5951    sidebar.read_with(cx, |sidebar, _| {
 5952        assert!(
 5953            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
 5954            "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
 5955            sidebar.active_entry,
 5956        );
 5957    });
 5958
 5959    // --- Scenario 2: archive the thread the user is looking at ---
 5960
 5961    // Create a thread in project-b (the active workspace) and verify it
 5962    // becomes the active entry.
 5963    let connection = acp_thread::StubAgentConnection::new();
 5964    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5965        acp::ContentChunk::new("Done".into()),
 5966    )]);
 5967    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 5968    agent_ui::test_support::send_message(&panel_b, cx);
 5969    let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
 5970    cx.run_until_parked();
 5971
 5972    sidebar.read_with(cx, |sidebar, _| {
 5973        assert!(
 5974            is_active_session(&sidebar, &thread_b),
 5975            "expected active_entry to be Thread({thread_b}), got: {:?}",
 5976            sidebar.active_entry,
 5977        );
 5978    });
 5979
 5980    sidebar.update_in(cx, |sidebar, window, cx| {
 5981        sidebar.archive_thread(&thread_b, window, cx);
 5982    });
 5983    cx.run_until_parked();
 5984
 5985    // Archiving the active thread clears active_entry (no draft is created).
 5986    sidebar.read_with(cx, |sidebar, _| {
 5987        assert!(
 5988            sidebar.active_entry.is_none(),
 5989            "expected None after archiving active thread, got: {:?}",
 5990            sidebar.active_entry,
 5991        );
 5992    });
 5993}
 5994
 5995#[gpui::test]
 5996async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
 5997    // Full flow: create a thread, archive it (removing the workspace),
 5998    // then unarchive. Only the restored thread should appear — no
 5999    // leftover drafts or previously-serialized threads.
 6000    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 6001    let (multi_workspace, cx) =
 6002        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6003    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6004    cx.run_until_parked();
 6005
 6006    // Create a thread and send a message so it's a real thread.
 6007    let connection = acp_thread::StubAgentConnection::new();
 6008    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6009        acp::ContentChunk::new("Hello".into()),
 6010    )]);
 6011    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6012    agent_ui::test_support::send_message(&panel, cx);
 6013    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6014    cx.run_until_parked();
 6015
 6016    // Archive it.
 6017    sidebar.update_in(cx, |sidebar, window, cx| {
 6018        sidebar.archive_thread(&session_id, window, cx);
 6019    });
 6020    cx.run_until_parked();
 6021
 6022    // Grab metadata for unarchive.
 6023    let thread_id = cx.update(|_, cx| {
 6024        ThreadMetadataStore::global(cx)
 6025            .read(cx)
 6026            .entries()
 6027            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6028            .map(|e| e.thread_id)
 6029            .expect("thread should exist")
 6030    });
 6031    let metadata = cx.update(|_, cx| {
 6032        ThreadMetadataStore::global(cx)
 6033            .read(cx)
 6034            .entry(thread_id)
 6035            .cloned()
 6036            .expect("metadata should exist")
 6037    });
 6038
 6039    // Unarchive it — the draft should be replaced by the restored thread.
 6040    sidebar.update_in(cx, |sidebar, window, cx| {
 6041        sidebar.activate_archived_thread(metadata, window, cx);
 6042    });
 6043    cx.run_until_parked();
 6044
 6045    // Only the unarchived thread should be visible — no drafts, no other threads.
 6046    let entries = visible_entries_as_strings(&sidebar, cx);
 6047    let thread_count = entries
 6048        .iter()
 6049        .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
 6050        .count();
 6051    assert_eq!(
 6052        thread_count, 1,
 6053        "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
 6054    );
 6055    assert!(
 6056        !entries.iter().any(|e| e.contains("Draft")),
 6057        "expected no drafts after restoring, got entries: {entries:?}"
 6058    );
 6059}
 6060
 6061#[gpui::test]
 6062async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
 6063    cx: &mut TestAppContext,
 6064) {
 6065    // When a thread is unarchived into a project group that has no open
 6066    // workspace, the sidebar opens a new workspace and loads the thread.
 6067    // No spurious draft should appear alongside the unarchived thread.
 6068    agent_ui::test_support::init_test(cx);
 6069    cx.update(|cx| {
 6070        ThreadStore::init_global(cx);
 6071        ThreadMetadataStore::init_global(cx);
 6072        language_model::LanguageModelRegistry::test(cx);
 6073        prompt_store::init(cx);
 6074    });
 6075
 6076    let fs = FakeFs::new(cx.executor());
 6077    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6078        .await;
 6079    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6080        .await;
 6081    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6082
 6083    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6084    let (multi_workspace, cx) =
 6085        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6086    let sidebar = setup_sidebar(&multi_workspace, cx);
 6087    cx.run_until_parked();
 6088
 6089    // Save an archived thread whose folder_paths point to project-b,
 6090    // which has no open workspace.
 6091    let session_id = acp::SessionId::new(Arc::from("archived-thread"));
 6092    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 6093    let thread_id = ThreadId::new();
 6094    cx.update(|_, cx| {
 6095        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6096            store.save(
 6097                ThreadMetadata {
 6098                    thread_id,
 6099                    session_id: Some(session_id.clone()),
 6100                    agent_id: agent::ZED_AGENT_ID.clone(),
 6101                    title: Some("Unarchived Thread".into()),
 6102                    updated_at: Utc::now(),
 6103                    created_at: None,
 6104                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6105                    archived: true,
 6106                    remote_connection: None,
 6107                },
 6108                cx,
 6109            )
 6110        });
 6111    });
 6112    cx.run_until_parked();
 6113
 6114    // Verify no workspace for project-b exists yet.
 6115    assert_eq!(
 6116        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6117        1,
 6118        "should start with only the project-a workspace"
 6119    );
 6120
 6121    // Un-archive the thread — should open project-b workspace and load it.
 6122    let metadata = cx.update(|_, cx| {
 6123        ThreadMetadataStore::global(cx)
 6124            .read(cx)
 6125            .entry(thread_id)
 6126            .cloned()
 6127            .expect("metadata should exist")
 6128    });
 6129
 6130    sidebar.update_in(cx, |sidebar, window, cx| {
 6131        sidebar.activate_archived_thread(metadata, window, cx);
 6132    });
 6133    cx.run_until_parked();
 6134
 6135    // A second workspace should have been created for project-b.
 6136    assert_eq!(
 6137        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6138        2,
 6139        "should have opened a workspace for the unarchived thread"
 6140    );
 6141
 6142    // The sidebar should show the unarchived thread without a spurious draft
 6143    // in the project-b group.
 6144    let entries = visible_entries_as_strings(&sidebar, cx);
 6145    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6146    // project-a gets a draft (it's the active workspace with no threads),
 6147    // but project-b should NOT have one — only the unarchived thread.
 6148    assert!(
 6149        draft_count <= 1,
 6150        "expected at most one draft (for project-a), got entries: {entries:?}"
 6151    );
 6152    assert!(
 6153        entries.iter().any(|e| e.contains("Unarchived Thread")),
 6154        "expected unarchived thread to appear, got entries: {entries:?}"
 6155    );
 6156}
 6157
 6158#[gpui::test]
 6159async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
 6160    cx: &mut TestAppContext,
 6161) {
 6162    agent_ui::test_support::init_test(cx);
 6163    cx.update(|cx| {
 6164        ThreadStore::init_global(cx);
 6165        ThreadMetadataStore::init_global(cx);
 6166        language_model::LanguageModelRegistry::test(cx);
 6167        prompt_store::init(cx);
 6168    });
 6169
 6170    let fs = FakeFs::new(cx.executor());
 6171    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6172        .await;
 6173    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6174        .await;
 6175    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6176
 6177    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6178    let (multi_workspace, cx) =
 6179        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6180    let sidebar = setup_sidebar(&multi_workspace, cx);
 6181    cx.run_until_parked();
 6182
 6183    let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
 6184    let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
 6185    let original_thread_id = ThreadId::new();
 6186    cx.update(|_, cx| {
 6187        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6188            store.save(
 6189                ThreadMetadata {
 6190                    thread_id: original_thread_id,
 6191                    session_id: Some(session_id.clone()),
 6192                    agent_id: agent::ZED_AGENT_ID.clone(),
 6193                    title: Some("Unarchived Thread".into()),
 6194                    updated_at: Utc::now(),
 6195                    created_at: None,
 6196                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6197                    archived: true,
 6198                    remote_connection: None,
 6199                },
 6200                cx,
 6201            )
 6202        });
 6203    });
 6204    cx.run_until_parked();
 6205
 6206    let metadata = cx.update(|_, cx| {
 6207        ThreadMetadataStore::global(cx)
 6208            .read(cx)
 6209            .entry(original_thread_id)
 6210            .cloned()
 6211            .expect("metadata should exist before unarchive")
 6212    });
 6213
 6214    sidebar.update_in(cx, |sidebar, window, cx| {
 6215        sidebar.activate_archived_thread(metadata, window, cx);
 6216    });
 6217    cx.run_until_parked();
 6218    cx.run_until_parked();
 6219    cx.run_until_parked();
 6220
 6221    assert_eq!(
 6222        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6223        2,
 6224        "expected unarchive to open the target workspace"
 6225    );
 6226
 6227    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6228        mw.workspaces()
 6229            .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
 6230            .cloned()
 6231            .expect("expected restored workspace for unarchived thread")
 6232    });
 6233    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6234        workspace
 6235            .panel::<AgentPanel>(cx)
 6236            .expect("expected unarchive to install an agent panel in the new workspace")
 6237    });
 6238
 6239    let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6240    assert_eq!(
 6241        restored_thread_id,
 6242        Some(original_thread_id),
 6243        "expected the new workspace's agent panel to target the restored archived thread id"
 6244    );
 6245
 6246    let session_entries = cx.update(|_, cx| {
 6247        ThreadMetadataStore::global(cx)
 6248            .read(cx)
 6249            .entries()
 6250            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6251            .cloned()
 6252            .collect::<Vec<_>>()
 6253    });
 6254    assert_eq!(
 6255        session_entries.len(),
 6256        1,
 6257        "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
 6258    );
 6259    assert_eq!(
 6260        session_entries[0].thread_id, original_thread_id,
 6261        "expected restore into a new workspace to reuse the original thread id"
 6262    );
 6263    assert!(
 6264        !session_entries[0].archived,
 6265        "expected restored thread metadata to be unarchived, got: {:?}",
 6266        session_entries[0]
 6267    );
 6268
 6269    let mapped_thread_id = cx.update(|_, cx| {
 6270        ThreadMetadataStore::global(cx)
 6271            .read(cx)
 6272            .entries()
 6273            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6274            .map(|e| e.thread_id)
 6275    });
 6276    assert_eq!(
 6277        mapped_thread_id,
 6278        Some(original_thread_id),
 6279        "expected session mapping to remain stable after opening the new workspace"
 6280    );
 6281
 6282    let entries = visible_entries_as_strings(&sidebar, cx);
 6283    let real_thread_rows = entries
 6284        .iter()
 6285        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6286        .filter(|entry| !entry.contains("Draft"))
 6287        .count();
 6288    assert_eq!(
 6289        real_thread_rows, 1,
 6290        "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
 6291    );
 6292    assert!(
 6293        entries
 6294            .iter()
 6295            .any(|entry| entry.contains("Unarchived Thread")),
 6296        "expected restored thread row to be visible, got entries: {entries:?}"
 6297    );
 6298}
 6299
 6300#[gpui::test]
 6301async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
 6302    // When a workspace already exists with an empty draft and a thread
 6303    // is unarchived into it, the draft should be replaced — not kept
 6304    // alongside the loaded thread.
 6305    agent_ui::test_support::init_test(cx);
 6306    cx.update(|cx| {
 6307        ThreadStore::init_global(cx);
 6308        ThreadMetadataStore::init_global(cx);
 6309        language_model::LanguageModelRegistry::test(cx);
 6310        prompt_store::init(cx);
 6311    });
 6312
 6313    let fs = FakeFs::new(cx.executor());
 6314    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6315        .await;
 6316    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6317
 6318    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6319    let (multi_workspace, cx) =
 6320        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6321    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6322    cx.run_until_parked();
 6323
 6324    // Create a thread and send a message so it's no longer a draft.
 6325    let connection = acp_thread::StubAgentConnection::new();
 6326    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6327        acp::ContentChunk::new("Done".into()),
 6328    )]);
 6329    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6330    agent_ui::test_support::send_message(&panel, cx);
 6331    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6332    cx.run_until_parked();
 6333
 6334    // Archive the thread — the group is left empty (no draft created).
 6335    sidebar.update_in(cx, |sidebar, window, cx| {
 6336        sidebar.archive_thread(&session_id, window, cx);
 6337    });
 6338    cx.run_until_parked();
 6339
 6340    // Un-archive the thread.
 6341    let thread_id = cx.update(|_, cx| {
 6342        ThreadMetadataStore::global(cx)
 6343            .read(cx)
 6344            .entries()
 6345            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6346            .map(|e| e.thread_id)
 6347            .expect("thread should exist in store")
 6348    });
 6349    let metadata = cx.update(|_, cx| {
 6350        ThreadMetadataStore::global(cx)
 6351            .read(cx)
 6352            .entry(thread_id)
 6353            .cloned()
 6354            .expect("metadata should exist")
 6355    });
 6356
 6357    sidebar.update_in(cx, |sidebar, window, cx| {
 6358        sidebar.activate_archived_thread(metadata, window, cx);
 6359    });
 6360    cx.run_until_parked();
 6361
 6362    // The draft should be gone — only the unarchived thread remains.
 6363    let entries = visible_entries_as_strings(&sidebar, cx);
 6364    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6365    assert_eq!(
 6366        draft_count, 0,
 6367        "expected no drafts after unarchiving, got entries: {entries:?}"
 6368    );
 6369}
 6370
 6371#[gpui::test]
 6372async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
 6373    cx: &mut TestAppContext,
 6374) {
 6375    agent_ui::test_support::init_test(cx);
 6376    cx.update(|cx| {
 6377        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6378        ThreadStore::init_global(cx);
 6379        ThreadMetadataStore::init_global(cx);
 6380        language_model::LanguageModelRegistry::test(cx);
 6381        prompt_store::init(cx);
 6382    });
 6383
 6384    let fs = FakeFs::new(cx.executor());
 6385    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6386        .await;
 6387    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6388        .await;
 6389    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6390
 6391    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6392    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6393
 6394    let (multi_workspace, cx) =
 6395        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6396    let sidebar = setup_sidebar(&multi_workspace, cx);
 6397
 6398    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 6399    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6400        mw.test_add_workspace(project_b.clone(), window, cx)
 6401    });
 6402    let _panel_b = add_agent_panel(&workspace_b, cx);
 6403    cx.run_until_parked();
 6404
 6405    multi_workspace.update_in(cx, |mw, window, cx| {
 6406        mw.activate(workspace_a.clone(), window, cx);
 6407    });
 6408    cx.run_until_parked();
 6409
 6410    let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
 6411    let thread_id = ThreadId::new();
 6412    cx.update(|_, cx| {
 6413        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6414            store.save(
 6415                ThreadMetadata {
 6416                    thread_id,
 6417                    session_id: Some(session_id.clone()),
 6418                    agent_id: agent::ZED_AGENT_ID.clone(),
 6419                    title: Some("Restored In Inactive Workspace".into()),
 6420                    updated_at: Utc::now(),
 6421                    created_at: None,
 6422                    worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 6423                        PathBuf::from("/project-b"),
 6424                    ])),
 6425                    archived: true,
 6426                    remote_connection: None,
 6427                },
 6428                cx,
 6429            )
 6430        });
 6431    });
 6432    cx.run_until_parked();
 6433
 6434    let metadata = cx.update(|_, cx| {
 6435        ThreadMetadataStore::global(cx)
 6436            .read(cx)
 6437            .entry(thread_id)
 6438            .cloned()
 6439            .expect("archived metadata should exist before restore")
 6440    });
 6441
 6442    sidebar.update_in(cx, |sidebar, window, cx| {
 6443        sidebar.activate_archived_thread(metadata, window, cx);
 6444    });
 6445
 6446    let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
 6447        workspace.panel::<AgentPanel>(cx).expect(
 6448            "target workspace should still have an agent panel immediately after activation",
 6449        )
 6450    });
 6451    let immediate_active_thread_id =
 6452        panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6453    let immediate_draft_ids =
 6454        panel_b_before_settle.read_with(cx, |panel, cx| panel.draft_thread_ids(cx));
 6455
 6456    cx.run_until_parked();
 6457    cx.run_until_parked();
 6458    cx.run_until_parked();
 6459
 6460    sidebar.read_with(cx, |sidebar, _cx| {
 6461        assert_active_thread(
 6462            sidebar,
 6463            &session_id,
 6464            "unarchiving into an inactive existing workspace should end on the restored thread",
 6465        );
 6466    });
 6467
 6468    let panel_b = workspace_b.read_with(cx, |workspace, cx| {
 6469        workspace
 6470            .panel::<AgentPanel>(cx)
 6471            .expect("target workspace should still have an agent panel")
 6472    });
 6473    assert_eq!(
 6474        panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6475        Some(thread_id),
 6476        "expected target panel to activate the restored thread id"
 6477    );
 6478    assert!(
 6479        immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
 6480        "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:?}"
 6481    );
 6482
 6483    let entries = visible_entries_as_strings(&sidebar, cx);
 6484    let target_rows: Vec<_> = entries
 6485        .iter()
 6486        .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
 6487        .cloned()
 6488        .collect();
 6489    assert_eq!(
 6490        target_rows.len(),
 6491        1,
 6492        "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
 6493    );
 6494    assert!(
 6495        target_rows[0].contains("Restored In Inactive Workspace"),
 6496        "expected the remaining row to be the restored thread, got entries: {entries:?}"
 6497    );
 6498    assert!(
 6499        !target_rows[0].contains("Draft"),
 6500        "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
 6501    );
 6502}
 6503
 6504#[gpui::test]
 6505async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
 6506    cx: &mut TestAppContext,
 6507) {
 6508    agent_ui::test_support::init_test(cx);
 6509    cx.update(|cx| {
 6510        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6511        ThreadStore::init_global(cx);
 6512        ThreadMetadataStore::init_global(cx);
 6513        language_model::LanguageModelRegistry::test(cx);
 6514        prompt_store::init(cx);
 6515    });
 6516
 6517    let fs = FakeFs::new(cx.executor());
 6518    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6519        .await;
 6520    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6521        .await;
 6522    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6523
 6524    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6525    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6526
 6527    let (multi_workspace, cx) =
 6528        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6529    let sidebar = setup_sidebar(&multi_workspace, cx);
 6530
 6531    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6532        mw.test_add_workspace(project_b.clone(), window, cx)
 6533    });
 6534    let panel_b = add_agent_panel(&workspace_b, cx);
 6535    cx.run_until_parked();
 6536
 6537    let connection = acp_thread::StubAgentConnection::new();
 6538    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6539        acp::ContentChunk::new("Done".into()),
 6540    )]);
 6541    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6542    agent_ui::test_support::send_message(&panel_b, cx);
 6543    let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
 6544    save_test_thread_metadata(&session_id, &project_b, cx).await;
 6545    cx.run_until_parked();
 6546
 6547    sidebar.update_in(cx, |sidebar, window, cx| {
 6548        sidebar.archive_thread(&session_id, window, cx);
 6549    });
 6550    cx.run_until_parked();
 6551    cx.run_until_parked();
 6552    cx.run_until_parked();
 6553
 6554    let archived_metadata = cx.update(|_, cx| {
 6555        let store = ThreadMetadataStore::global(cx).read(cx);
 6556        let thread_id = store
 6557            .entries()
 6558            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6559            .map(|e| e.thread_id)
 6560            .expect("archived thread should still exist in metadata store");
 6561        let metadata = store
 6562            .entry(thread_id)
 6563            .cloned()
 6564            .expect("archived metadata should still exist after archive");
 6565        assert!(
 6566            metadata.archived,
 6567            "thread should be archived before project removal"
 6568        );
 6569        metadata
 6570    });
 6571
 6572    let group_key_b =
 6573        project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
 6574    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 6575        mw.remove_project_group(&group_key_b, window, cx)
 6576    });
 6577    remove_task
 6578        .await
 6579        .expect("remove project group task should complete");
 6580    cx.run_until_parked();
 6581    cx.run_until_parked();
 6582
 6583    assert_eq!(
 6584        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6585        1,
 6586        "removing the archived thread's parent project group should remove its workspace"
 6587    );
 6588
 6589    sidebar.update_in(cx, |sidebar, window, cx| {
 6590        sidebar.activate_archived_thread(archived_metadata.clone(), window, cx);
 6591    });
 6592    cx.run_until_parked();
 6593    cx.run_until_parked();
 6594    cx.run_until_parked();
 6595
 6596    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6597        mw.workspaces()
 6598            .find(|workspace| {
 6599                PathList::new(&workspace.read(cx).root_paths(cx))
 6600                    == PathList::new(&[PathBuf::from("/project-b")])
 6601            })
 6602            .cloned()
 6603            .expect("expected unarchive to recreate the removed project workspace")
 6604    });
 6605    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6606        workspace
 6607            .panel::<AgentPanel>(cx)
 6608            .expect("expected restored workspace to bootstrap an agent panel")
 6609    });
 6610
 6611    let restored_thread_id = cx.update(|_, cx| {
 6612        ThreadMetadataStore::global(cx)
 6613            .read(cx)
 6614            .entries()
 6615            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6616            .map(|e| e.thread_id)
 6617            .expect("session should still map to restored thread id")
 6618    });
 6619    assert_eq!(
 6620        restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6621        Some(restored_thread_id),
 6622        "expected unarchive after project removal to activate the restored real thread"
 6623    );
 6624
 6625    sidebar.read_with(cx, |sidebar, _cx| {
 6626        assert_active_thread(
 6627            sidebar,
 6628            &session_id,
 6629            "expected sidebar active entry to track the restored thread after project removal",
 6630        );
 6631    });
 6632
 6633    let entries = visible_entries_as_strings(&sidebar, cx);
 6634    let restored_title = archived_metadata.display_title().to_string();
 6635    let matching_rows: Vec<_> = entries
 6636        .iter()
 6637        .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
 6638        .cloned()
 6639        .collect();
 6640    assert_eq!(
 6641        matching_rows.len(),
 6642        1,
 6643        "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
 6644    );
 6645    assert!(
 6646        !matching_rows[0].contains("Draft"),
 6647        "expected no draft row after unarchive following project removal, got entries: {entries:?}"
 6648    );
 6649}
 6650
 6651#[gpui::test]
 6652async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
 6653    agent_ui::test_support::init_test(cx);
 6654    cx.update(|cx| {
 6655        ThreadStore::init_global(cx);
 6656        ThreadMetadataStore::init_global(cx);
 6657        language_model::LanguageModelRegistry::test(cx);
 6658        prompt_store::init(cx);
 6659    });
 6660
 6661    let fs = FakeFs::new(cx.executor());
 6662    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6663        .await;
 6664    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6665
 6666    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6667    let (multi_workspace, cx) =
 6668        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6669    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6670    cx.run_until_parked();
 6671
 6672    let connection = acp_thread::StubAgentConnection::new();
 6673    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6674        acp::ContentChunk::new("Done".into()),
 6675    )]);
 6676    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6677    agent_ui::test_support::send_message(&panel, cx);
 6678    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6679    cx.run_until_parked();
 6680
 6681    let original_thread_id = cx.update(|_, cx| {
 6682        ThreadMetadataStore::global(cx)
 6683            .read(cx)
 6684            .entries()
 6685            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6686            .map(|e| e.thread_id)
 6687            .expect("thread should exist in store before archiving")
 6688    });
 6689
 6690    sidebar.update_in(cx, |sidebar, window, cx| {
 6691        sidebar.archive_thread(&session_id, window, cx);
 6692    });
 6693    cx.run_until_parked();
 6694
 6695    let metadata = cx.update(|_, cx| {
 6696        ThreadMetadataStore::global(cx)
 6697            .read(cx)
 6698            .entry(original_thread_id)
 6699            .cloned()
 6700            .expect("metadata should exist after archiving")
 6701    });
 6702
 6703    sidebar.update_in(cx, |sidebar, window, cx| {
 6704        sidebar.activate_archived_thread(metadata, window, cx);
 6705    });
 6706    cx.run_until_parked();
 6707
 6708    let session_entries = cx.update(|_, cx| {
 6709        ThreadMetadataStore::global(cx)
 6710            .read(cx)
 6711            .entries()
 6712            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6713            .cloned()
 6714            .collect::<Vec<_>>()
 6715    });
 6716
 6717    assert_eq!(
 6718        session_entries.len(),
 6719        1,
 6720        "expected exactly one metadata row for the restored session, got: {session_entries:?}"
 6721    );
 6722    assert_eq!(
 6723        session_entries[0].thread_id, original_thread_id,
 6724        "expected unarchive to reuse the original thread id instead of creating a duplicate row"
 6725    );
 6726    assert!(
 6727        !session_entries[0].is_draft(),
 6728        "expected restored metadata to be a real thread, got: {:?}",
 6729        session_entries[0]
 6730    );
 6731
 6732    let entries = visible_entries_as_strings(&sidebar, cx);
 6733    let real_thread_rows = entries
 6734        .iter()
 6735        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6736        .filter(|entry| !entry.contains("Draft"))
 6737        .count();
 6738    assert_eq!(
 6739        real_thread_rows, 1,
 6740        "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
 6741    );
 6742    assert!(
 6743        !entries.iter().any(|entry| entry.contains("Draft")),
 6744        "expected no draft rows after restoring, got entries: {entries:?}"
 6745    );
 6746}
 6747
 6748#[gpui::test]
 6749async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
 6750    cx: &mut TestAppContext,
 6751) {
 6752    // When a thread is archived while the user is in a different workspace,
 6753    // the group is left empty (no draft is created). Switching back to that
 6754    // workspace should show no active entry.
 6755    agent_ui::test_support::init_test(cx);
 6756    cx.update(|cx| {
 6757        ThreadStore::init_global(cx);
 6758        ThreadMetadataStore::init_global(cx);
 6759        language_model::LanguageModelRegistry::test(cx);
 6760        prompt_store::init(cx);
 6761    });
 6762
 6763    let fs = FakeFs::new(cx.executor());
 6764    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6765        .await;
 6766    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6767        .await;
 6768    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6769
 6770    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6771    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6772
 6773    let (multi_workspace, cx) =
 6774        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6775    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6776
 6777    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6778        mw.test_add_workspace(project_b.clone(), window, cx)
 6779    });
 6780    let _panel_b = add_agent_panel(&workspace_b, cx);
 6781    cx.run_until_parked();
 6782
 6783    // Create a thread in project-a's panel (currently non-active).
 6784    let connection = acp_thread::StubAgentConnection::new();
 6785    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6786        acp::ContentChunk::new("Done".into()),
 6787    )]);
 6788    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 6789    agent_ui::test_support::send_message(&panel_a, cx);
 6790    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 6791    cx.run_until_parked();
 6792
 6793    // Archive it while project-b is active.
 6794    sidebar.update_in(cx, |sidebar, window, cx| {
 6795        sidebar.archive_thread(&thread_a, window, cx);
 6796    });
 6797    cx.run_until_parked();
 6798
 6799    // Switch back to project-a. Its panel was cleared during archiving,
 6800    // so active_entry should be None (no draft is created).
 6801    let workspace_a =
 6802        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 6803    multi_workspace.update_in(cx, |mw, window, cx| {
 6804        mw.activate(workspace_a.clone(), window, cx);
 6805    });
 6806    cx.run_until_parked();
 6807
 6808    sidebar.update_in(cx, |sidebar, _window, cx| {
 6809        sidebar.update_entries(cx);
 6810    });
 6811    cx.run_until_parked();
 6812
 6813    sidebar.read_with(cx, |sidebar, _| {
 6814        assert!(
 6815            sidebar.active_entry.is_none(),
 6816            "expected no active entry after switching to workspace with archived thread, got: {:?}",
 6817            sidebar.active_entry,
 6818        );
 6819    });
 6820}
 6821
 6822#[gpui::test]
 6823async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
 6824    let project = init_test_project("/my-project", cx).await;
 6825    let (multi_workspace, cx) =
 6826        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6827    let sidebar = setup_sidebar(&multi_workspace, cx);
 6828
 6829    save_thread_metadata(
 6830        acp::SessionId::new(Arc::from("visible-thread")),
 6831        Some("Visible Thread".into()),
 6832        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 6833        None,
 6834        &project,
 6835        cx,
 6836    );
 6837
 6838    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
 6839    save_thread_metadata(
 6840        archived_thread_session_id.clone(),
 6841        Some("Archived Thread".into()),
 6842        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 6843        None,
 6844        &project,
 6845        cx,
 6846    );
 6847
 6848    cx.update(|_, cx| {
 6849        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6850            let thread_id = store
 6851                .entries()
 6852                .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
 6853                .map(|e| e.thread_id)
 6854                .unwrap();
 6855            store.archive(thread_id, None, cx)
 6856        })
 6857    });
 6858    cx.run_until_parked();
 6859
 6860    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 6861    cx.run_until_parked();
 6862
 6863    let entries = visible_entries_as_strings(&sidebar, cx);
 6864    assert!(
 6865        entries.iter().any(|e| e.contains("Visible Thread")),
 6866        "expected visible thread in sidebar, got: {entries:?}"
 6867    );
 6868    assert!(
 6869        !entries.iter().any(|e| e.contains("Archived Thread")),
 6870        "expected archived thread to be hidden from sidebar, got: {entries:?}"
 6871    );
 6872
 6873    cx.update(|_, cx| {
 6874        let store = ThreadMetadataStore::global(cx);
 6875        let all: Vec<_> = store.read(cx).entries().collect();
 6876        assert_eq!(
 6877            all.len(),
 6878            2,
 6879            "expected 2 total entries in the store, got: {}",
 6880            all.len()
 6881        );
 6882
 6883        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 6884        assert_eq!(archived.len(), 1);
 6885        assert_eq!(
 6886            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 6887            "archived-thread"
 6888        );
 6889    });
 6890}
 6891
 6892#[gpui::test]
 6893async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
 6894    cx: &mut TestAppContext,
 6895) {
 6896    // When a linked worktree has a single thread and that thread is archived,
 6897    // the sidebar must NOT create a new thread on the same worktree (which
 6898    // would prevent the worktree from being cleaned up on disk). Instead,
 6899    // archive_thread switches to a sibling thread on the main workspace (or
 6900    // creates a draft there) before archiving the metadata.
 6901    agent_ui::test_support::init_test(cx);
 6902    cx.update(|cx| {
 6903        ThreadStore::init_global(cx);
 6904        ThreadMetadataStore::init_global(cx);
 6905        language_model::LanguageModelRegistry::test(cx);
 6906        prompt_store::init(cx);
 6907    });
 6908
 6909    let fs = FakeFs::new(cx.executor());
 6910
 6911    fs.insert_tree(
 6912        "/project",
 6913        serde_json::json!({
 6914            ".git": {},
 6915            "src": {},
 6916        }),
 6917    )
 6918    .await;
 6919
 6920    fs.add_linked_worktree_for_repo(
 6921        Path::new("/project/.git"),
 6922        false,
 6923        git::repository::Worktree {
 6924            path: std::path::PathBuf::from("/wt-ochre-drift"),
 6925            ref_name: Some("refs/heads/ochre-drift".into()),
 6926            sha: "aaa".into(),
 6927            is_main: false,
 6928        },
 6929    )
 6930    .await;
 6931
 6932    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6933
 6934    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 6935    let worktree_project =
 6936        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 6937
 6938    main_project
 6939        .update(cx, |p, cx| p.git_scans_complete(cx))
 6940        .await;
 6941    worktree_project
 6942        .update(cx, |p, cx| p.git_scans_complete(cx))
 6943        .await;
 6944
 6945    let (multi_workspace, cx) =
 6946        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 6947
 6948    let sidebar = setup_sidebar(&multi_workspace, cx);
 6949
 6950    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 6951        mw.test_add_workspace(worktree_project.clone(), window, cx)
 6952    });
 6953
 6954    // Set up both workspaces with agent panels.
 6955    let main_workspace =
 6956        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 6957    let _main_panel = add_agent_panel(&main_workspace, cx);
 6958    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 6959
 6960    // Activate the linked worktree workspace so the sidebar tracks it.
 6961    multi_workspace.update_in(cx, |mw, window, cx| {
 6962        mw.activate(worktree_workspace.clone(), window, cx);
 6963    });
 6964
 6965    // Open a thread in the linked worktree panel and send a message
 6966    // so it becomes the active thread.
 6967    let connection = StubAgentConnection::new();
 6968    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 6969    send_message(&worktree_panel, cx);
 6970
 6971    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 6972
 6973    // Give the thread a response chunk so it has content.
 6974    cx.update(|_, cx| {
 6975        connection.send_update(
 6976            worktree_thread_id.clone(),
 6977            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 6978            cx,
 6979        );
 6980    });
 6981
 6982    // Save the worktree thread's metadata.
 6983    save_thread_metadata(
 6984        worktree_thread_id.clone(),
 6985        Some("Ochre Drift Thread".into()),
 6986        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 6987        None,
 6988        &worktree_project,
 6989        cx,
 6990    );
 6991
 6992    // Also save a thread on the main project so there's a sibling in the
 6993    // group that can be selected after archiving.
 6994    save_thread_metadata(
 6995        acp::SessionId::new(Arc::from("main-project-thread")),
 6996        Some("Main Project Thread".into()),
 6997        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 6998        None,
 6999        &main_project,
 7000        cx,
 7001    );
 7002
 7003    cx.run_until_parked();
 7004
 7005    // Verify the linked worktree thread appears with its chip.
 7006    // The live thread title comes from the message text ("Hello"), not
 7007    // the metadata title we saved.
 7008    let entries_before = visible_entries_as_strings(&sidebar, cx);
 7009    assert!(
 7010        entries_before
 7011            .iter()
 7012            .any(|s| s.contains("{wt-ochre-drift}")),
 7013        "expected worktree thread with chip before archiving, got: {entries_before:?}"
 7014    );
 7015    assert!(
 7016        entries_before
 7017            .iter()
 7018            .any(|s| s.contains("Main Project Thread")),
 7019        "expected main project thread before archiving, got: {entries_before:?}"
 7020    );
 7021
 7022    // Confirm the worktree thread is the active entry.
 7023    sidebar.read_with(cx, |s, _| {
 7024        assert_active_thread(
 7025            s,
 7026            &worktree_thread_id,
 7027            "worktree thread should be active before archiving",
 7028        );
 7029    });
 7030
 7031    // Archive the worktree thread — it's the only thread using ochre-drift.
 7032    sidebar.update_in(cx, |sidebar, window, cx| {
 7033        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7034    });
 7035
 7036    cx.run_until_parked();
 7037
 7038    // The archived thread should no longer appear in the sidebar.
 7039    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7040    assert!(
 7041        !entries_after
 7042            .iter()
 7043            .any(|s| s.contains("Ochre Drift Thread")),
 7044        "archived thread should be hidden, got: {entries_after:?}"
 7045    );
 7046
 7047    // No "+ New Thread" entry should appear with the ochre-drift worktree
 7048    // chip — that would keep the worktree alive and prevent cleanup.
 7049    assert!(
 7050        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7051        "no entry should reference the archived worktree, got: {entries_after:?}"
 7052    );
 7053
 7054    // The main project thread should still be visible.
 7055    assert!(
 7056        entries_after
 7057            .iter()
 7058            .any(|s| s.contains("Main Project Thread")),
 7059        "main project thread should still be visible, got: {entries_after:?}"
 7060    );
 7061}
 7062
 7063#[gpui::test]
 7064async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
 7065    cx: &mut TestAppContext,
 7066) {
 7067    // When a linked worktree thread is the ONLY thread in the project group
 7068    // (no threads on the main repo either), archiving it should leave the
 7069    // group empty with no active entry.
 7070    agent_ui::test_support::init_test(cx);
 7071    cx.update(|cx| {
 7072        ThreadStore::init_global(cx);
 7073        ThreadMetadataStore::init_global(cx);
 7074        language_model::LanguageModelRegistry::test(cx);
 7075        prompt_store::init(cx);
 7076    });
 7077
 7078    let fs = FakeFs::new(cx.executor());
 7079
 7080    fs.insert_tree(
 7081        "/project",
 7082        serde_json::json!({
 7083            ".git": {},
 7084            "src": {},
 7085        }),
 7086    )
 7087    .await;
 7088
 7089    fs.add_linked_worktree_for_repo(
 7090        Path::new("/project/.git"),
 7091        false,
 7092        git::repository::Worktree {
 7093            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7094            ref_name: Some("refs/heads/ochre-drift".into()),
 7095            sha: "aaa".into(),
 7096            is_main: false,
 7097        },
 7098    )
 7099    .await;
 7100
 7101    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7102
 7103    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7104    let worktree_project =
 7105        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7106
 7107    main_project
 7108        .update(cx, |p, cx| p.git_scans_complete(cx))
 7109        .await;
 7110    worktree_project
 7111        .update(cx, |p, cx| p.git_scans_complete(cx))
 7112        .await;
 7113
 7114    let (multi_workspace, cx) =
 7115        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7116
 7117    let sidebar = setup_sidebar(&multi_workspace, cx);
 7118
 7119    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7120        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7121    });
 7122
 7123    let main_workspace =
 7124        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7125    let _main_panel = add_agent_panel(&main_workspace, cx);
 7126    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7127
 7128    // Activate the linked worktree workspace.
 7129    multi_workspace.update_in(cx, |mw, window, cx| {
 7130        mw.activate(worktree_workspace.clone(), window, cx);
 7131    });
 7132
 7133    // Open a thread on the linked worktree — this is the ONLY thread.
 7134    let connection = StubAgentConnection::new();
 7135    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7136    send_message(&worktree_panel, cx);
 7137
 7138    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7139
 7140    cx.update(|_, cx| {
 7141        connection.send_update(
 7142            worktree_thread_id.clone(),
 7143            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7144            cx,
 7145        );
 7146    });
 7147
 7148    save_thread_metadata(
 7149        worktree_thread_id.clone(),
 7150        Some("Ochre Drift Thread".into()),
 7151        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7152        None,
 7153        &worktree_project,
 7154        cx,
 7155    );
 7156
 7157    cx.run_until_parked();
 7158
 7159    // Archive it — there are no other threads in the group.
 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    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7167
 7168    // No entry should reference the linked worktree.
 7169    assert!(
 7170        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7171        "no entry should reference the archived worktree, got: {entries_after:?}"
 7172    );
 7173
 7174    // The active entry should be None — no draft is created.
 7175    sidebar.read_with(cx, |s, _| {
 7176        assert!(
 7177            s.active_entry.is_none(),
 7178            "expected no active entry after archiving the last thread, got: {:?}",
 7179            s.active_entry,
 7180        );
 7181    });
 7182}
 7183
 7184#[gpui::test]
 7185async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
 7186    cx: &mut TestAppContext,
 7187) {
 7188    // When an archived thread belongs to a linked worktree whose main repo is
 7189    // already open, unarchiving should reopen the linked workspace into the
 7190    // same project group and show only the restored real thread row.
 7191    agent_ui::test_support::init_test(cx);
 7192    cx.update(|cx| {
 7193        ThreadStore::init_global(cx);
 7194        ThreadMetadataStore::init_global(cx);
 7195        language_model::LanguageModelRegistry::test(cx);
 7196        prompt_store::init(cx);
 7197    });
 7198
 7199    let fs = FakeFs::new(cx.executor());
 7200
 7201    fs.insert_tree(
 7202        "/project",
 7203        serde_json::json!({
 7204            ".git": {},
 7205            "src": {},
 7206        }),
 7207    )
 7208    .await;
 7209
 7210    fs.insert_tree(
 7211        "/wt-ochre-drift",
 7212        serde_json::json!({
 7213            ".git": "gitdir: /project/.git/worktrees/ochre-drift",
 7214            "src": {},
 7215        }),
 7216    )
 7217    .await;
 7218
 7219    fs.add_linked_worktree_for_repo(
 7220        Path::new("/project/.git"),
 7221        false,
 7222        git::repository::Worktree {
 7223            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7224            ref_name: Some("refs/heads/ochre-drift".into()),
 7225            sha: "aaa".into(),
 7226            is_main: false,
 7227        },
 7228    )
 7229    .await;
 7230
 7231    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7232
 7233    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7234    let worktree_project =
 7235        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7236
 7237    main_project
 7238        .update(cx, |p, cx| p.git_scans_complete(cx))
 7239        .await;
 7240    worktree_project
 7241        .update(cx, |p, cx| p.git_scans_complete(cx))
 7242        .await;
 7243
 7244    let (multi_workspace, cx) =
 7245        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7246
 7247    let sidebar = setup_sidebar(&multi_workspace, cx);
 7248    let main_workspace =
 7249        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7250    let _main_panel = add_agent_panel(&main_workspace, cx);
 7251    cx.run_until_parked();
 7252
 7253    let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
 7254    let original_thread_id = ThreadId::new();
 7255    let main_paths = PathList::new(&[PathBuf::from("/project")]);
 7256    let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
 7257
 7258    cx.update(|_, cx| {
 7259        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7260            store.save(
 7261                ThreadMetadata {
 7262                    thread_id: original_thread_id,
 7263                    session_id: Some(session_id.clone()),
 7264                    agent_id: agent::ZED_AGENT_ID.clone(),
 7265                    title: Some("Unarchived Linked Thread".into()),
 7266                    updated_at: Utc::now(),
 7267                    created_at: None,
 7268                    worktree_paths: WorktreePaths::from_path_lists(
 7269                        main_paths.clone(),
 7270                        folder_paths.clone(),
 7271                    )
 7272                    .expect("main and folder paths should be well-formed"),
 7273                    archived: true,
 7274                    remote_connection: None,
 7275                },
 7276                cx,
 7277            )
 7278        });
 7279    });
 7280    cx.run_until_parked();
 7281
 7282    let metadata = cx.update(|_, cx| {
 7283        ThreadMetadataStore::global(cx)
 7284            .read(cx)
 7285            .entry(original_thread_id)
 7286            .cloned()
 7287            .expect("archived linked-worktree metadata should exist before restore")
 7288    });
 7289
 7290    sidebar.update_in(cx, |sidebar, window, cx| {
 7291        sidebar.activate_archived_thread(metadata, window, cx);
 7292    });
 7293
 7294    cx.run_until_parked();
 7295    cx.run_until_parked();
 7296    cx.run_until_parked();
 7297
 7298    assert_eq!(
 7299        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7300        2,
 7301        "expected unarchive to open the linked worktree workspace into the project group"
 7302    );
 7303
 7304    let session_entries = cx.update(|_, cx| {
 7305        ThreadMetadataStore::global(cx)
 7306            .read(cx)
 7307            .entries()
 7308            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7309            .cloned()
 7310            .collect::<Vec<_>>()
 7311    });
 7312    assert_eq!(
 7313        session_entries.len(),
 7314        1,
 7315        "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
 7316    );
 7317    assert_eq!(
 7318        session_entries[0].thread_id, original_thread_id,
 7319        "expected unarchive to reuse the original linked worktree thread id"
 7320    );
 7321    assert!(
 7322        !session_entries[0].archived,
 7323        "expected restored linked worktree metadata to be unarchived, got: {:?}",
 7324        session_entries[0]
 7325    );
 7326
 7327    let assert_no_extra_rows = |entries: &[String]| {
 7328        let real_thread_rows = entries
 7329            .iter()
 7330            .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7331            .filter(|entry| !entry.contains("Draft"))
 7332            .count();
 7333        assert_eq!(
 7334            real_thread_rows, 1,
 7335            "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
 7336        );
 7337        assert!(
 7338            !entries.iter().any(|entry| entry.contains("Draft")),
 7339            "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
 7340        );
 7341        assert!(
 7342            !entries
 7343                .iter()
 7344                .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
 7345            "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
 7346        );
 7347        assert!(
 7348            entries
 7349                .iter()
 7350                .any(|entry| entry.contains("Unarchived Linked Thread")),
 7351            "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
 7352        );
 7353    };
 7354
 7355    let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
 7356    assert_no_extra_rows(&entries_after_restore);
 7357
 7358    // The reported bug may only appear after an extra scheduling turn.
 7359    cx.run_until_parked();
 7360    cx.run_until_parked();
 7361
 7362    let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
 7363    assert_no_extra_rows(&entries_after_extra_turns);
 7364}
 7365
 7366#[gpui::test]
 7367async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
 7368    // When a linked worktree thread is archived but the group has other
 7369    // threads (e.g. on the main project), archive_thread should select
 7370    // the nearest sibling.
 7371    agent_ui::test_support::init_test(cx);
 7372    cx.update(|cx| {
 7373        ThreadStore::init_global(cx);
 7374        ThreadMetadataStore::init_global(cx);
 7375        language_model::LanguageModelRegistry::test(cx);
 7376        prompt_store::init(cx);
 7377    });
 7378
 7379    let fs = FakeFs::new(cx.executor());
 7380
 7381    fs.insert_tree(
 7382        "/project",
 7383        serde_json::json!({
 7384            ".git": {},
 7385            "src": {},
 7386        }),
 7387    )
 7388    .await;
 7389
 7390    fs.add_linked_worktree_for_repo(
 7391        Path::new("/project/.git"),
 7392        false,
 7393        git::repository::Worktree {
 7394            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7395            ref_name: Some("refs/heads/ochre-drift".into()),
 7396            sha: "aaa".into(),
 7397            is_main: false,
 7398        },
 7399    )
 7400    .await;
 7401
 7402    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7403
 7404    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7405    let worktree_project =
 7406        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7407
 7408    main_project
 7409        .update(cx, |p, cx| p.git_scans_complete(cx))
 7410        .await;
 7411    worktree_project
 7412        .update(cx, |p, cx| p.git_scans_complete(cx))
 7413        .await;
 7414
 7415    let (multi_workspace, cx) =
 7416        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7417
 7418    let sidebar = setup_sidebar(&multi_workspace, cx);
 7419
 7420    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7421        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7422    });
 7423
 7424    let main_workspace =
 7425        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7426    let _main_panel = add_agent_panel(&main_workspace, cx);
 7427    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7428
 7429    // Activate the linked worktree workspace.
 7430    multi_workspace.update_in(cx, |mw, window, cx| {
 7431        mw.activate(worktree_workspace.clone(), window, cx);
 7432    });
 7433
 7434    // Open a thread on the linked worktree.
 7435    let connection = StubAgentConnection::new();
 7436    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7437    send_message(&worktree_panel, cx);
 7438
 7439    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7440
 7441    cx.update(|_, cx| {
 7442        connection.send_update(
 7443            worktree_thread_id.clone(),
 7444            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7445            cx,
 7446        );
 7447    });
 7448
 7449    save_thread_metadata(
 7450        worktree_thread_id.clone(),
 7451        Some("Ochre Drift Thread".into()),
 7452        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7453        None,
 7454        &worktree_project,
 7455        cx,
 7456    );
 7457
 7458    // Save a sibling thread on the main project.
 7459    let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
 7460    save_thread_metadata(
 7461        main_thread_id,
 7462        Some("Main Project Thread".into()),
 7463        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7464        None,
 7465        &main_project,
 7466        cx,
 7467    );
 7468
 7469    cx.run_until_parked();
 7470
 7471    // Confirm the worktree thread is active.
 7472    sidebar.read_with(cx, |s, _| {
 7473        assert_active_thread(
 7474            s,
 7475            &worktree_thread_id,
 7476            "worktree thread should be active before archiving",
 7477        );
 7478    });
 7479
 7480    // Archive the worktree thread.
 7481    sidebar.update_in(cx, |sidebar, window, cx| {
 7482        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7483    });
 7484
 7485    cx.run_until_parked();
 7486
 7487    // The worktree workspace was removed and a draft was created on the
 7488    // main workspace. No entry should reference the linked worktree.
 7489    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7490    assert!(
 7491        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7492        "no entry should reference the archived worktree, got: {entries_after:?}"
 7493    );
 7494
 7495    // The main project thread should still be visible.
 7496    assert!(
 7497        entries_after
 7498            .iter()
 7499            .any(|s| s.contains("Main Project Thread")),
 7500        "main project thread should still be visible, got: {entries_after:?}"
 7501    );
 7502}
 7503
 7504#[gpui::test]
 7505async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
 7506    // When a linked worktree is opened as its own workspace and the user
 7507    // creates a draft thread from it, then switches away, the workspace must
 7508    // still be reachable from that DraftThread sidebar entry. Pressing
 7509    // RemoveSelectedThread (shift-backspace) on that entry should remove the
 7510    // workspace.
 7511    init_test(cx);
 7512    let fs = FakeFs::new(cx.executor());
 7513
 7514    fs.insert_tree(
 7515        "/project",
 7516        serde_json::json!({
 7517            ".git": {
 7518                "worktrees": {
 7519                    "feature-a": {
 7520                        "commondir": "../../",
 7521                        "HEAD": "ref: refs/heads/feature-a",
 7522                    },
 7523                },
 7524            },
 7525            "src": {},
 7526        }),
 7527    )
 7528    .await;
 7529
 7530    fs.insert_tree(
 7531        "/wt-feature-a",
 7532        serde_json::json!({
 7533            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7534            "src": {},
 7535        }),
 7536    )
 7537    .await;
 7538
 7539    fs.add_linked_worktree_for_repo(
 7540        Path::new("/project/.git"),
 7541        false,
 7542        git::repository::Worktree {
 7543            path: PathBuf::from("/wt-feature-a"),
 7544            ref_name: Some("refs/heads/feature-a".into()),
 7545            sha: "aaa".into(),
 7546            is_main: false,
 7547        },
 7548    )
 7549    .await;
 7550
 7551    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7552
 7553    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7554    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7555
 7556    main_project
 7557        .update(cx, |p, cx| p.git_scans_complete(cx))
 7558        .await;
 7559    worktree_project
 7560        .update(cx, |p, cx| p.git_scans_complete(cx))
 7561        .await;
 7562
 7563    let (multi_workspace, cx) =
 7564        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7565    let sidebar = setup_sidebar(&multi_workspace, cx);
 7566
 7567    // Open the linked worktree as a separate workspace (simulates cmd-o).
 7568    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7569        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7570    });
 7571    add_agent_panel(&worktree_workspace, cx);
 7572    cx.run_until_parked();
 7573
 7574    // Explicitly create a draft thread from the linked worktree workspace.
 7575    // Auto-created drafts use the group's first workspace (the main one),
 7576    // so a user-created draft is needed to make the linked worktree reachable.
 7577    sidebar.update_in(cx, |sidebar, window, cx| {
 7578        sidebar.create_new_thread(&worktree_workspace, window, cx);
 7579    });
 7580    cx.run_until_parked();
 7581
 7582    // Switch back to the main workspace.
 7583    multi_workspace.update_in(cx, |mw, window, cx| {
 7584        let main_ws = mw.workspaces().next().unwrap().clone();
 7585        mw.activate(main_ws, window, cx);
 7586    });
 7587    cx.run_until_parked();
 7588
 7589    sidebar.update_in(cx, |sidebar, _window, cx| {
 7590        sidebar.update_entries(cx);
 7591    });
 7592    cx.run_until_parked();
 7593
 7594    // The linked worktree workspace must be reachable from some sidebar entry.
 7595    let worktree_ws_id = worktree_workspace.entity_id();
 7596    let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
 7597        let mw = multi_workspace.read(cx);
 7598        sidebar
 7599            .contents
 7600            .entries
 7601            .iter()
 7602            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 7603            .map(|ws| ws.entity_id())
 7604            .collect()
 7605    });
 7606    assert!(
 7607        reachable.contains(&worktree_ws_id),
 7608        "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
 7609    );
 7610
 7611    // Find the draft Thread entry whose workspace is the linked worktree.
 7612    let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
 7613        sidebar
 7614            .contents
 7615            .entries
 7616            .iter()
 7617            .position(|entry| match entry {
 7618                ListEntry::Thread(thread) if thread.is_draft => matches!(
 7619                    &thread.workspace,
 7620                    ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
 7621                ),
 7622                _ => false,
 7623            })
 7624            .expect("expected a draft thread entry for the linked worktree")
 7625    });
 7626
 7627    assert_eq!(
 7628        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7629        2
 7630    );
 7631
 7632    sidebar.update_in(cx, |sidebar, window, cx| {
 7633        sidebar.selection = Some(new_thread_ix);
 7634        sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
 7635    });
 7636    cx.run_until_parked();
 7637
 7638    assert_eq!(
 7639        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7640        2,
 7641        "dismissing a draft no longer removes the linked worktree workspace"
 7642    );
 7643
 7644    let has_draft_for_worktree = sidebar.read_with(cx, |sidebar, _| {
 7645        sidebar.contents.entries.iter().any(|entry| match entry {
 7646            ListEntry::Thread(thread) if thread.is_draft => matches!(
 7647                &thread.workspace,
 7648                ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
 7649            ),
 7650            _ => false,
 7651        })
 7652    });
 7653    assert!(
 7654        !has_draft_for_worktree,
 7655        "draft thread entry for the linked worktree should be removed after dismiss"
 7656    );
 7657}
 7658
 7659#[gpui::test]
 7660async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
 7661    // When only a linked worktree workspace is open (not the main repo),
 7662    // threads saved against the main repo should still appear in the sidebar.
 7663    init_test(cx);
 7664    let fs = FakeFs::new(cx.executor());
 7665
 7666    // Create the main repo with a linked worktree.
 7667    fs.insert_tree(
 7668        "/project",
 7669        serde_json::json!({
 7670            ".git": {
 7671                "worktrees": {
 7672                    "feature-a": {
 7673                        "commondir": "../../",
 7674                        "HEAD": "ref: refs/heads/feature-a",
 7675                    },
 7676                },
 7677            },
 7678            "src": {},
 7679        }),
 7680    )
 7681    .await;
 7682
 7683    fs.insert_tree(
 7684        "/wt-feature-a",
 7685        serde_json::json!({
 7686            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7687            "src": {},
 7688        }),
 7689    )
 7690    .await;
 7691
 7692    fs.add_linked_worktree_for_repo(
 7693        std::path::Path::new("/project/.git"),
 7694        false,
 7695        git::repository::Worktree {
 7696            path: std::path::PathBuf::from("/wt-feature-a"),
 7697            ref_name: Some("refs/heads/feature-a".into()),
 7698            sha: "abc".into(),
 7699            is_main: false,
 7700        },
 7701    )
 7702    .await;
 7703
 7704    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7705
 7706    // Only open the linked worktree as a workspace — NOT the main repo.
 7707    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7708    worktree_project
 7709        .update(cx, |p, cx| p.git_scans_complete(cx))
 7710        .await;
 7711
 7712    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7713    main_project
 7714        .update(cx, |p, cx| p.git_scans_complete(cx))
 7715        .await;
 7716
 7717    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 7718        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 7719    });
 7720    let sidebar = setup_sidebar(&multi_workspace, cx);
 7721
 7722    // Save a thread against the MAIN repo path.
 7723    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
 7724
 7725    // Save a thread against the linked worktree path.
 7726    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
 7727
 7728    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7729    cx.run_until_parked();
 7730
 7731    // Both threads should be visible: the worktree thread by direct lookup,
 7732    // and the main repo thread because the workspace is a linked worktree
 7733    // and we also query the main repo path.
 7734    let entries = visible_entries_as_strings(&sidebar, cx);
 7735    assert!(
 7736        entries.iter().any(|e| e.contains("Main Repo Thread")),
 7737        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
 7738    );
 7739    assert!(
 7740        entries.iter().any(|e| e.contains("Worktree Thread")),
 7741        "expected worktree thread to be visible, got: {entries:?}"
 7742    );
 7743}
 7744
 7745async fn init_multi_project_test(
 7746    paths: &[&str],
 7747    cx: &mut TestAppContext,
 7748) -> (Arc<FakeFs>, Entity<project::Project>) {
 7749    agent_ui::test_support::init_test(cx);
 7750    cx.update(|cx| {
 7751        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 7752        ThreadStore::init_global(cx);
 7753        ThreadMetadataStore::init_global(cx);
 7754        language_model::LanguageModelRegistry::test(cx);
 7755        prompt_store::init(cx);
 7756    });
 7757    let fs = FakeFs::new(cx.executor());
 7758    for path in paths {
 7759        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
 7760            .await;
 7761    }
 7762    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7763    let project =
 7764        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
 7765    (fs, project)
 7766}
 7767
 7768async fn add_test_project(
 7769    path: &str,
 7770    fs: &Arc<FakeFs>,
 7771    multi_workspace: &Entity<MultiWorkspace>,
 7772    cx: &mut gpui::VisualTestContext,
 7773) -> Entity<Workspace> {
 7774    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
 7775    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7776        mw.test_add_workspace(project, window, cx)
 7777    });
 7778    cx.run_until_parked();
 7779    workspace
 7780}
 7781
 7782#[gpui::test]
 7783async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
 7784    let (fs, project_a) =
 7785        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 7786    let (multi_workspace, cx) =
 7787        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 7788    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
 7789
 7790    // Sidebar starts closed. Initial workspace A is transient.
 7791    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 7792    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 7793    assert_eq!(
 7794        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7795        1
 7796    );
 7797    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
 7798
 7799    // Add B — replaces A as the transient workspace.
 7800    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 7801    assert_eq!(
 7802        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7803        1
 7804    );
 7805    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 7806
 7807    // Add C — replaces B as the transient workspace.
 7808    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 7809    assert_eq!(
 7810        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7811        1
 7812    );
 7813    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 7814}
 7815
 7816#[gpui::test]
 7817async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
 7818    let (fs, project_a) = init_multi_project_test(
 7819        &["/project-a", "/project-b", "/project-c", "/project-d"],
 7820        cx,
 7821    )
 7822    .await;
 7823    let (multi_workspace, cx) =
 7824        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 7825    let _sidebar = setup_sidebar(&multi_workspace, cx);
 7826    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 7827
 7828    // Add B — retained since sidebar is open.
 7829    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 7830    assert_eq!(
 7831        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7832        2
 7833    );
 7834
 7835    // Switch to A — B survives. (Switching from one internal workspace, to another)
 7836    multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
 7837    cx.run_until_parked();
 7838    assert_eq!(
 7839        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7840        2
 7841    );
 7842
 7843    // Close sidebar — both A and B remain retained.
 7844    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
 7845    cx.run_until_parked();
 7846    assert_eq!(
 7847        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7848        2
 7849    );
 7850
 7851    // Add C — added as new transient workspace. (switching from retained, to transient)
 7852    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 7853    assert_eq!(
 7854        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7855        3
 7856    );
 7857    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 7858
 7859    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
 7860    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
 7861    assert_eq!(
 7862        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7863        3
 7864    );
 7865    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
 7866}
 7867
 7868#[gpui::test]
 7869async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
 7870    let (fs, project_a) =
 7871        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 7872    let (multi_workspace, cx) =
 7873        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 7874    setup_sidebar_closed(&multi_workspace, cx);
 7875
 7876    // Add B — replaces A as the transient workspace (A is discarded).
 7877    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 7878    assert_eq!(
 7879        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7880        1
 7881    );
 7882    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 7883
 7884    // Open sidebar — promotes the transient B to retained.
 7885    multi_workspace.update_in(cx, |mw, window, cx| {
 7886        mw.toggle_sidebar(window, cx);
 7887    });
 7888    cx.run_until_parked();
 7889    assert_eq!(
 7890        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7891        1
 7892    );
 7893    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
 7894
 7895    // Close sidebar — the retained B remains.
 7896    multi_workspace.update_in(cx, |mw, window, cx| {
 7897        mw.toggle_sidebar(window, cx);
 7898    });
 7899
 7900    // Add C — added as new transient workspace.
 7901    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 7902    assert_eq!(
 7903        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7904        2
 7905    );
 7906    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 7907}
 7908
 7909#[gpui::test]
 7910async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
 7911    init_test(cx);
 7912    let fs = FakeFs::new(cx.executor());
 7913
 7914    fs.insert_tree(
 7915        "/project",
 7916        serde_json::json!({
 7917            ".git": {
 7918                "worktrees": {
 7919                    "feature-a": {
 7920                        "commondir": "../../",
 7921                        "HEAD": "ref: refs/heads/feature-a",
 7922                    },
 7923                },
 7924            },
 7925            "src": {},
 7926        }),
 7927    )
 7928    .await;
 7929
 7930    fs.insert_tree(
 7931        "/wt-feature-a",
 7932        serde_json::json!({
 7933            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7934            "src": {},
 7935        }),
 7936    )
 7937    .await;
 7938
 7939    fs.add_linked_worktree_for_repo(
 7940        Path::new("/project/.git"),
 7941        false,
 7942        git::repository::Worktree {
 7943            path: PathBuf::from("/wt-feature-a"),
 7944            ref_name: Some("refs/heads/feature-a".into()),
 7945            sha: "abc".into(),
 7946            is_main: false,
 7947        },
 7948    )
 7949    .await;
 7950
 7951    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7952
 7953    // Only a linked worktree workspace is open — no workspace for /project.
 7954    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7955    worktree_project
 7956        .update(cx, |p, cx| p.git_scans_complete(cx))
 7957        .await;
 7958
 7959    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 7960        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 7961    });
 7962    let sidebar = setup_sidebar(&multi_workspace, cx);
 7963
 7964    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
 7965    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
 7966    cx.update(|_, cx| {
 7967        let metadata = ThreadMetadata {
 7968            thread_id: ThreadId::new(),
 7969            session_id: Some(legacy_session.clone()),
 7970            agent_id: agent::ZED_AGENT_ID.clone(),
 7971            title: Some("Legacy Main Thread".into()),
 7972            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7973            created_at: None,
 7974            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 7975                "/project",
 7976            )])),
 7977            archived: false,
 7978            remote_connection: None,
 7979        };
 7980        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
 7981    });
 7982    cx.run_until_parked();
 7983
 7984    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7985    cx.run_until_parked();
 7986
 7987    // The legacy thread should appear in the sidebar under the project group.
 7988    let entries = visible_entries_as_strings(&sidebar, cx);
 7989    assert!(
 7990        entries.iter().any(|e| e.contains("Legacy Main Thread")),
 7991        "legacy thread should be visible: {entries:?}",
 7992    );
 7993
 7994    // Verify only 1 workspace before clicking.
 7995    assert_eq!(
 7996        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7997        1,
 7998    );
 7999
 8000    // Focus and select the legacy thread, then confirm.
 8001    focus_sidebar(&sidebar, cx);
 8002    let thread_index = sidebar.read_with(cx, |sidebar, _| {
 8003        sidebar
 8004            .contents
 8005            .entries
 8006            .iter()
 8007            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
 8008            .expect("legacy thread should be in entries")
 8009    });
 8010    sidebar.update_in(cx, |sidebar, _window, _cx| {
 8011        sidebar.selection = Some(thread_index);
 8012    });
 8013    cx.dispatch_action(Confirm);
 8014    cx.run_until_parked();
 8015
 8016    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8017    let new_path_list =
 8018        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 8019    assert_eq!(
 8020        new_path_list,
 8021        PathList::new(&[PathBuf::from("/project")]),
 8022        "the new workspace should be for the main repo, not the linked worktree",
 8023    );
 8024}
 8025
 8026#[gpui::test]
 8027async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
 8028    cx: &mut TestAppContext,
 8029) {
 8030    // Regression test for a property-test finding:
 8031    //   AddLinkedWorktree { project_group_index: 0 }
 8032    //   AddProject { use_worktree: true }
 8033    //   AddProject { use_worktree: false }
 8034    // After these three steps, the linked-worktree workspace was not
 8035    // reachable from any sidebar entry.
 8036    agent_ui::test_support::init_test(cx);
 8037    cx.update(|cx| {
 8038        ThreadStore::init_global(cx);
 8039        ThreadMetadataStore::init_global(cx);
 8040        language_model::LanguageModelRegistry::test(cx);
 8041        prompt_store::init(cx);
 8042
 8043        cx.observe_new(
 8044            |workspace: &mut Workspace,
 8045             window: Option<&mut Window>,
 8046             cx: &mut gpui::Context<Workspace>| {
 8047                if let Some(window) = window {
 8048                    let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 8049                    workspace.add_panel(panel, window, cx);
 8050                }
 8051            },
 8052        )
 8053        .detach();
 8054    });
 8055
 8056    let fs = FakeFs::new(cx.executor());
 8057    fs.insert_tree(
 8058        "/my-project",
 8059        serde_json::json!({
 8060            ".git": {},
 8061            "src": {},
 8062        }),
 8063    )
 8064    .await;
 8065    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8066    let project =
 8067        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
 8068    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8069
 8070    let (multi_workspace, cx) =
 8071        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8072    let sidebar = setup_sidebar(&multi_workspace, cx);
 8073
 8074    // Step 1: Create a linked worktree for the main project.
 8075    let worktree_name = "wt-0";
 8076    let worktree_path = "/worktrees/wt-0";
 8077
 8078    fs.insert_tree(
 8079        worktree_path,
 8080        serde_json::json!({
 8081            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8082            "src": {},
 8083        }),
 8084    )
 8085    .await;
 8086    fs.insert_tree(
 8087        "/my-project/.git/worktrees/wt-0",
 8088        serde_json::json!({
 8089            "commondir": "../../",
 8090            "HEAD": "ref: refs/heads/wt-0",
 8091        }),
 8092    )
 8093    .await;
 8094    fs.add_linked_worktree_for_repo(
 8095        Path::new("/my-project/.git"),
 8096        false,
 8097        git::repository::Worktree {
 8098            path: PathBuf::from(worktree_path),
 8099            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 8100            sha: "aaa".into(),
 8101            is_main: false,
 8102        },
 8103    )
 8104    .await;
 8105
 8106    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8107    let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 8108    main_project
 8109        .update(cx, |p, cx| p.git_scans_complete(cx))
 8110        .await;
 8111    cx.run_until_parked();
 8112
 8113    // Step 2: Open the linked worktree as its own workspace.
 8114    let worktree_project =
 8115        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
 8116    worktree_project
 8117        .update(cx, |p, cx| p.git_scans_complete(cx))
 8118        .await;
 8119    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8120        mw.test_add_workspace(worktree_project.clone(), window, cx)
 8121    });
 8122    cx.run_until_parked();
 8123
 8124    // Step 3: Add an unrelated project.
 8125    fs.insert_tree(
 8126        "/other-project",
 8127        serde_json::json!({
 8128            ".git": {},
 8129            "src": {},
 8130        }),
 8131    )
 8132    .await;
 8133    let other_project = project::Project::test(
 8134        fs.clone() as Arc<dyn fs::Fs>,
 8135        ["/other-project".as_ref()],
 8136        cx,
 8137    )
 8138    .await;
 8139    other_project
 8140        .update(cx, |p, cx| p.git_scans_complete(cx))
 8141        .await;
 8142    multi_workspace.update_in(cx, |mw, window, cx| {
 8143        mw.test_add_workspace(other_project.clone(), window, cx);
 8144    });
 8145    cx.run_until_parked();
 8146
 8147    // Force a full sidebar rebuild with all groups expanded.
 8148    sidebar.update_in(cx, |sidebar, _window, cx| {
 8149        if let Some(mw) = sidebar.multi_workspace.upgrade() {
 8150            mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 8151        }
 8152        sidebar.update_entries(cx);
 8153    });
 8154    cx.run_until_parked();
 8155
 8156    // The linked-worktree workspace must be reachable from at least one
 8157    // sidebar entry — otherwise the user has no way to navigate to it.
 8158    let worktree_ws_id = worktree_workspace.entity_id();
 8159    let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
 8160        let mw = multi_workspace.read(cx);
 8161
 8162        let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
 8163        let reachable: HashSet<gpui::EntityId> = sidebar
 8164            .contents
 8165            .entries
 8166            .iter()
 8167            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 8168            .map(|ws| ws.entity_id())
 8169            .collect();
 8170        (all, reachable)
 8171    });
 8172
 8173    let unreachable = &all_ids - &reachable_ids;
 8174    eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
 8175
 8176    assert!(
 8177        unreachable.is_empty(),
 8178        "workspaces not reachable from any sidebar entry: {:?}\n\
 8179         (linked-worktree workspace id: {:?})",
 8180        unreachable,
 8181        worktree_ws_id,
 8182    );
 8183}
 8184
 8185#[gpui::test]
 8186async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
 8187    // Empty project groups no longer auto-create drafts via reconciliation.
 8188    // A fresh startup with no restorable thread should show only the header.
 8189    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8190    let (multi_workspace, cx) =
 8191        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8192    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8193
 8194    let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8195
 8196    let entries = visible_entries_as_strings(&sidebar, cx);
 8197    assert_eq!(
 8198        entries,
 8199        vec!["v [my-project]"],
 8200        "empty group should show only the header, no auto-created draft"
 8201    );
 8202}
 8203
 8204#[gpui::test]
 8205async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
 8206    // Rule 5: When the app starts and the AgentPanel successfully loads
 8207    // a thread, no spurious draft should appear.
 8208    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8209    let (multi_workspace, cx) =
 8210        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8211    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8212
 8213    // Create and send a message to make a real thread.
 8214    let connection = StubAgentConnection::new();
 8215    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8216        acp::ContentChunk::new("Done".into()),
 8217    )]);
 8218    open_thread_with_connection(&panel, connection, cx);
 8219    send_message(&panel, cx);
 8220    let session_id = active_session_id(&panel, cx);
 8221    save_test_thread_metadata(&session_id, &project, cx).await;
 8222    cx.run_until_parked();
 8223
 8224    // Should show the thread, NOT a spurious draft.
 8225    let entries = visible_entries_as_strings(&sidebar, cx);
 8226    assert_eq!(entries, vec!["v [my-project]", "  Hello *"]);
 8227
 8228    // active_entry should be Thread, not Draft.
 8229    sidebar.read_with(cx, |sidebar, _| {
 8230        assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
 8231    });
 8232}
 8233
 8234#[gpui::test]
 8235async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
 8236    // Rule 9: Clicking a project header should restore whatever the
 8237    // user was last looking at in that group, not create new drafts
 8238    // or jump to the first entry.
 8239    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 8240    let (multi_workspace, cx) =
 8241        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8242    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8243
 8244    // Create two threads in project-a.
 8245    let conn1 = StubAgentConnection::new();
 8246    conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8247        acp::ContentChunk::new("Done".into()),
 8248    )]);
 8249    open_thread_with_connection(&panel_a, conn1, cx);
 8250    send_message(&panel_a, cx);
 8251    let thread_a1 = active_session_id(&panel_a, cx);
 8252    save_test_thread_metadata(&thread_a1, &project_a, cx).await;
 8253
 8254    let conn2 = StubAgentConnection::new();
 8255    conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8256        acp::ContentChunk::new("Done".into()),
 8257    )]);
 8258    open_thread_with_connection(&panel_a, conn2, cx);
 8259    send_message(&panel_a, cx);
 8260    let thread_a2 = active_session_id(&panel_a, cx);
 8261    save_test_thread_metadata(&thread_a2, &project_a, cx).await;
 8262    cx.run_until_parked();
 8263
 8264    // The user is now looking at thread_a2.
 8265    sidebar.read_with(cx, |sidebar, _| {
 8266        assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
 8267    });
 8268
 8269    // Add project-b and switch to it.
 8270    let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
 8271    fs.as_fake()
 8272        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 8273        .await;
 8274    let project_b =
 8275        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8276    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8277        mw.test_add_workspace(project_b.clone(), window, cx)
 8278    });
 8279    let _panel_b = add_agent_panel(&workspace_b, cx);
 8280    cx.run_until_parked();
 8281
 8282    // Now switch BACK to project-a by activating its workspace.
 8283    let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
 8284        mw.workspaces()
 8285            .find(|ws| {
 8286                ws.read(cx)
 8287                    .project()
 8288                    .read(cx)
 8289                    .visible_worktrees(cx)
 8290                    .any(|wt| {
 8291                        wt.read(cx)
 8292                            .abs_path()
 8293                            .to_string_lossy()
 8294                            .contains("project-a")
 8295                    })
 8296            })
 8297            .unwrap()
 8298            .clone()
 8299    });
 8300    multi_workspace.update_in(cx, |mw, window, cx| {
 8301        mw.activate(workspace_a.clone(), window, cx);
 8302    });
 8303    cx.run_until_parked();
 8304
 8305    // The panel should still show thread_a2 (the last thing the user
 8306    // was viewing in project-a), not a draft or thread_a1.
 8307    sidebar.read_with(cx, |sidebar, _| {
 8308        assert_active_thread(
 8309            sidebar,
 8310            &thread_a2,
 8311            "switching back to project-a should restore thread_a2",
 8312        );
 8313    });
 8314
 8315    // No spurious draft entries should have been created in
 8316    // project-a's group (project-b may have a placeholder).
 8317    let entries = visible_entries_as_strings(&sidebar, cx);
 8318    // Find project-a's section and check it has no drafts.
 8319    let project_a_start = entries
 8320        .iter()
 8321        .position(|e| e.contains("project-a"))
 8322        .unwrap();
 8323    let project_a_end = entries[project_a_start + 1..]
 8324        .iter()
 8325        .position(|e| e.starts_with("v "))
 8326        .map(|i| i + project_a_start + 1)
 8327        .unwrap_or(entries.len());
 8328    let project_a_drafts = entries[project_a_start..project_a_end]
 8329        .iter()
 8330        .filter(|e| e.contains("Draft"))
 8331        .count();
 8332    assert_eq!(
 8333        project_a_drafts, 0,
 8334        "switching back to project-a should not create drafts in its group"
 8335    );
 8336}
 8337
 8338#[gpui::test]
 8339async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
 8340    // Clicking the + button when an empty draft already exists should
 8341    // focus the existing draft rather than creating a new one.
 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    // Start: no drafts from reconciliation.
 8348    let entries = visible_entries_as_strings(&sidebar, cx);
 8349    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8350    assert_eq!(draft_count, 0, "should start with 0 drafts");
 8351
 8352    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8353    let simulate_plus_button =
 8354        |sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context<Sidebar>| {
 8355            sidebar.create_new_thread(&workspace, window, cx);
 8356        };
 8357
 8358    // First + click: should create a draft.
 8359    sidebar.update_in(cx, |sidebar, window, cx| {
 8360        simulate_plus_button(sidebar, window, cx);
 8361    });
 8362    cx.run_until_parked();
 8363
 8364    let entries = visible_entries_as_strings(&sidebar, cx);
 8365    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8366    assert_eq!(draft_count, 1, "first + click should create a draft");
 8367
 8368    // Second + click with empty draft: should reuse it, not create a new one.
 8369    sidebar.update_in(cx, |sidebar, window, cx| {
 8370        simulate_plus_button(sidebar, window, cx);
 8371    });
 8372    cx.run_until_parked();
 8373
 8374    let entries = visible_entries_as_strings(&sidebar, cx);
 8375    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8376    assert_eq!(
 8377        draft_count, 1,
 8378        "second + click should reuse the existing empty draft, not create a new one"
 8379    );
 8380
 8381    // The draft should be active.
 8382    assert_eq!(entries[1], "  [~ Draft] *");
 8383}
 8384
 8385#[gpui::test]
 8386async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
 8387    // When a workspace has a draft (from the panel's load fallback)
 8388    // and the user activates it (e.g. by clicking the placeholder or
 8389    // the project header), no extra drafts should be created.
 8390    init_test(cx);
 8391    let fs = FakeFs::new(cx.executor());
 8392    fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
 8393        .await;
 8394    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8395        .await;
 8396    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8397
 8398    let project_a =
 8399        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
 8400    let (multi_workspace, cx) =
 8401        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8402    let sidebar = setup_sidebar(&multi_workspace, cx);
 8403    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8404    let _panel_a = add_agent_panel(&workspace_a, cx);
 8405    cx.run_until_parked();
 8406
 8407    // Add project-b with its own workspace and agent panel.
 8408    let project_b =
 8409        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8410    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8411        mw.test_add_workspace(project_b.clone(), window, cx)
 8412    });
 8413    let _panel_b = add_agent_panel(&workspace_b, cx);
 8414    cx.run_until_parked();
 8415
 8416    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 8417    sidebar.update_in(cx, |sidebar, window, cx| {
 8418        sidebar.create_new_thread(&workspace_b, window, cx);
 8419    });
 8420    cx.run_until_parked();
 8421
 8422    // Count project-b's drafts.
 8423    let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
 8424        let entries = visible_entries_as_strings(&sidebar, cx);
 8425        entries
 8426            .iter()
 8427            .skip_while(|e| !e.contains("project-b"))
 8428            .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
 8429            .filter(|e| e.contains("Draft"))
 8430            .count()
 8431    };
 8432    let drafts_before = count_b_drafts(cx);
 8433
 8434    // Switch away from project-b, then back.
 8435    multi_workspace.update_in(cx, |mw, window, cx| {
 8436        mw.activate(workspace_a.clone(), window, cx);
 8437    });
 8438    cx.run_until_parked();
 8439    multi_workspace.update_in(cx, |mw, window, cx| {
 8440        mw.activate(workspace_b.clone(), window, cx);
 8441    });
 8442    cx.run_until_parked();
 8443
 8444    let drafts_after = count_b_drafts(cx);
 8445    assert_eq!(
 8446        drafts_before, drafts_after,
 8447        "activating workspace should not create extra drafts"
 8448    );
 8449
 8450    // The draft should be highlighted as active after switching back.
 8451    sidebar.read_with(cx, |sidebar, _| {
 8452        assert_active_draft(
 8453            sidebar,
 8454            &workspace_b,
 8455            "draft should be active after switching back to its workspace",
 8456        );
 8457    });
 8458}
 8459
 8460#[gpui::test]
 8461async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
 8462    // Historical threads (not open in any agent panel) should have their
 8463    // worktree paths updated when a folder is added to or removed from the
 8464    // project.
 8465    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
 8466    let (multi_workspace, cx) =
 8467        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8468    let sidebar = setup_sidebar(&multi_workspace, cx);
 8469
 8470    // Save two threads directly into the metadata store (not via the agent
 8471    // panel), so they are purely historical — no open views hold them.
 8472    // Use different timestamps so sort order is deterministic.
 8473    save_thread_metadata(
 8474        acp::SessionId::new(Arc::from("hist-1")),
 8475        Some("Historical 1".into()),
 8476        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8477        None,
 8478        &project,
 8479        cx,
 8480    );
 8481    save_thread_metadata(
 8482        acp::SessionId::new(Arc::from("hist-2")),
 8483        Some("Historical 2".into()),
 8484        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 8485        None,
 8486        &project,
 8487        cx,
 8488    );
 8489    cx.run_until_parked();
 8490    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8491    cx.run_until_parked();
 8492
 8493    // Sanity-check: both threads exist under the initial key [/project-a].
 8494    let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
 8495    cx.update(|_window, cx| {
 8496        let store = ThreadMetadataStore::global(cx).read(cx);
 8497        assert_eq!(
 8498            store.entries_for_main_worktree_path(&old_key_paths).count(),
 8499            2,
 8500            "should have 2 historical threads under old key before worktree add"
 8501        );
 8502    });
 8503
 8504    // Add a second worktree to the project.
 8505    // TODO: Should there be different behavior for calling Project::find_or_create_worktree,
 8506    //       or MultiWorkspace::add_folders_to_project_group?
 8507    project
 8508        .update(cx, |project, cx| {
 8509            project.find_or_create_worktree("/project-b", true, cx)
 8510        })
 8511        .await
 8512        .expect("should add worktree");
 8513    cx.run_until_parked();
 8514
 8515    // The historical threads should now be indexed under the new combined
 8516    // key [/project-a, /project-b].
 8517    let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
 8518    cx.update(|_window, cx| {
 8519        let store = ThreadMetadataStore::global(cx).read(cx);
 8520        assert_eq!(
 8521            store.entries_for_main_worktree_path(&old_key_paths).count(),
 8522            0,
 8523            "should have 0 historical threads under old key after worktree add"
 8524        );
 8525        assert_eq!(
 8526            store.entries_for_main_worktree_path(&new_key_paths).count(),
 8527            2,
 8528            "should have 2 historical threads under new key after worktree add"
 8529        );
 8530    });
 8531
 8532    // Sidebar should show threads under the new header.
 8533    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8534    cx.run_until_parked();
 8535    assert_eq!(
 8536        visible_entries_as_strings(&sidebar, cx),
 8537        vec![
 8538            "v [project-a, project-b]",
 8539            "  Historical 2",
 8540            "  Historical 1",
 8541        ]
 8542    );
 8543
 8544    // Now remove the second worktree.
 8545    let worktree_id = project.read_with(cx, |project, cx| {
 8546        project
 8547            .visible_worktrees(cx)
 8548            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
 8549            .map(|wt| wt.read(cx).id())
 8550            .expect("should find project-b worktree")
 8551    });
 8552    project.update(cx, |project, cx| {
 8553        project.remove_worktree(worktree_id, cx);
 8554    });
 8555    cx.run_until_parked();
 8556
 8557    // Historical threads should migrate back to the original key.
 8558    cx.update(|_window, cx| {
 8559        let store = ThreadMetadataStore::global(cx).read(cx);
 8560        assert_eq!(
 8561            store.entries_for_main_worktree_path(&new_key_paths).count(),
 8562            0,
 8563            "should have 0 historical threads under new key after worktree remove"
 8564        );
 8565        assert_eq!(
 8566            store.entries_for_main_worktree_path(&old_key_paths).count(),
 8567            2,
 8568            "should have 2 historical threads under old key after worktree remove"
 8569        );
 8570    });
 8571
 8572    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8573    cx.run_until_parked();
 8574    assert_eq!(
 8575        visible_entries_as_strings(&sidebar, cx),
 8576        vec!["v [project-a]", "  Historical 2", "  Historical 1",]
 8577    );
 8578}
 8579
 8580#[gpui::test]
 8581async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut TestAppContext) {
 8582    // When two workspaces share the same project group (same main path)
 8583    // but have different folder paths (main repo vs linked worktree),
 8584    // adding a worktree to the main workspace should only migrate threads
 8585    // whose folder paths match that workspace — not the linked worktree's
 8586    // threads.
 8587    agent_ui::test_support::init_test(cx);
 8588    cx.update(|cx| {
 8589        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 8590        ThreadStore::init_global(cx);
 8591        ThreadMetadataStore::init_global(cx);
 8592        language_model::LanguageModelRegistry::test(cx);
 8593        prompt_store::init(cx);
 8594    });
 8595
 8596    let fs = FakeFs::new(cx.executor());
 8597    fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
 8598        .await;
 8599    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8600        .await;
 8601    fs.add_linked_worktree_for_repo(
 8602        Path::new("/project/.git"),
 8603        false,
 8604        git::repository::Worktree {
 8605            path: std::path::PathBuf::from("/wt-feature"),
 8606            ref_name: Some("refs/heads/feature".into()),
 8607            sha: "aaa".into(),
 8608            is_main: false,
 8609        },
 8610    )
 8611    .await;
 8612    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8613
 8614    // Workspace A: main repo at /project.
 8615    let main_project =
 8616        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
 8617    // Workspace B: linked worktree of the same repo (same group, different folder).
 8618    let worktree_project =
 8619        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
 8620
 8621    main_project
 8622        .update(cx, |p, cx| p.git_scans_complete(cx))
 8623        .await;
 8624    worktree_project
 8625        .update(cx, |p, cx| p.git_scans_complete(cx))
 8626        .await;
 8627
 8628    let (multi_workspace, cx) =
 8629        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 8630    let _sidebar = setup_sidebar(&multi_workspace, cx);
 8631    multi_workspace.update_in(cx, |mw, window, cx| {
 8632        mw.test_add_workspace(worktree_project.clone(), window, cx);
 8633    });
 8634    cx.run_until_parked();
 8635
 8636    // Save a thread for each workspace's folder paths.
 8637    let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
 8638    let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
 8639    save_thread_metadata(
 8640        acp::SessionId::new(Arc::from("thread-main")),
 8641        Some("Main Thread".into()),
 8642        time_main,
 8643        Some(time_main),
 8644        &main_project,
 8645        cx,
 8646    );
 8647    save_thread_metadata(
 8648        acp::SessionId::new(Arc::from("thread-wt")),
 8649        Some("Worktree Thread".into()),
 8650        time_wt,
 8651        Some(time_wt),
 8652        &worktree_project,
 8653        cx,
 8654    );
 8655    cx.run_until_parked();
 8656
 8657    let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
 8658    let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
 8659
 8660    // Sanity-check: each thread is indexed under its own folder paths.
 8661    cx.update(|_window, cx| {
 8662        let store = ThreadMetadataStore::global(cx).read(cx);
 8663        assert_eq!(
 8664            store.entries_for_path(&folder_paths_main).count(),
 8665            1,
 8666            "one thread under [/project]"
 8667        );
 8668        assert_eq!(
 8669            store.entries_for_path(&folder_paths_wt).count(),
 8670            1,
 8671            "one thread under [/wt-feature]"
 8672        );
 8673    });
 8674
 8675    // Add /project-b to the main project only.
 8676    main_project
 8677        .update(cx, |project, cx| {
 8678            project.find_or_create_worktree("/project-b", true, cx)
 8679        })
 8680        .await
 8681        .expect("should add worktree");
 8682    cx.run_until_parked();
 8683
 8684    // Main Thread (folder paths [/project]) should have migrated to
 8685    // [/project, /project-b]. Worktree Thread should be unchanged.
 8686    let folder_paths_main_b =
 8687        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
 8688    cx.update(|_window, cx| {
 8689        let store = ThreadMetadataStore::global(cx).read(cx);
 8690        assert_eq!(
 8691            store.entries_for_path(&folder_paths_main).count(),
 8692            0,
 8693            "main thread should no longer be under old folder paths [/project]"
 8694        );
 8695        assert_eq!(
 8696            store.entries_for_path(&folder_paths_main_b).count(),
 8697            1,
 8698            "main thread should now be under [/project, /project-b]"
 8699        );
 8700        assert_eq!(
 8701            store.entries_for_path(&folder_paths_wt).count(),
 8702            1,
 8703            "worktree thread should remain unchanged under [/wt-feature]"
 8704        );
 8705    });
 8706}
 8707
 8708#[gpui::test]
 8709async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
 8710    cx: &mut TestAppContext,
 8711) {
 8712    // When a linked worktree is opened as its own workspace and then a new
 8713    // folder is added to the main project group, the linked worktree
 8714    // workspace must still be reachable from some sidebar entry.
 8715    let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
 8716    let fs = _fs.clone();
 8717
 8718    // Set up git worktree infrastructure.
 8719    fs.insert_tree(
 8720        "/my-project/.git/worktrees/wt-0",
 8721        serde_json::json!({
 8722            "commondir": "../../",
 8723            "HEAD": "ref: refs/heads/wt-0",
 8724        }),
 8725    )
 8726    .await;
 8727    fs.insert_tree(
 8728        "/worktrees/wt-0",
 8729        serde_json::json!({
 8730            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8731            "src": {},
 8732        }),
 8733    )
 8734    .await;
 8735    fs.add_linked_worktree_for_repo(
 8736        Path::new("/my-project/.git"),
 8737        false,
 8738        git::repository::Worktree {
 8739            path: PathBuf::from("/worktrees/wt-0"),
 8740            ref_name: Some("refs/heads/wt-0".into()),
 8741            sha: "aaa".into(),
 8742            is_main: false,
 8743        },
 8744    )
 8745    .await;
 8746
 8747    // Re-scan so the main project discovers the linked worktree.
 8748    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8749
 8750    let (multi_workspace, cx) =
 8751        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8752    let sidebar = setup_sidebar(&multi_workspace, cx);
 8753
 8754    // Open the linked worktree as its own workspace.
 8755    let worktree_project = project::Project::test(
 8756        fs.clone() as Arc<dyn fs::Fs>,
 8757        ["/worktrees/wt-0".as_ref()],
 8758        cx,
 8759    )
 8760    .await;
 8761    worktree_project
 8762        .update(cx, |p, cx| p.git_scans_complete(cx))
 8763        .await;
 8764    multi_workspace.update_in(cx, |mw, window, cx| {
 8765        mw.test_add_workspace(worktree_project.clone(), window, cx);
 8766    });
 8767    cx.run_until_parked();
 8768
 8769    // Both workspaces should be reachable.
 8770    let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 8771    assert_eq!(workspace_count, 2, "should have 2 workspaces");
 8772
 8773    // Add a new folder to the main project, changing the project group key.
 8774    fs.insert_tree(
 8775        "/other-project",
 8776        serde_json::json!({ ".git": {}, "src": {} }),
 8777    )
 8778    .await;
 8779    project
 8780        .update(cx, |project, cx| {
 8781            project.find_or_create_worktree("/other-project", true, cx)
 8782        })
 8783        .await
 8784        .expect("should add worktree");
 8785    cx.run_until_parked();
 8786
 8787    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8788    cx.run_until_parked();
 8789
 8790    // The linked worktree workspace must still be reachable.
 8791    let entries = visible_entries_as_strings(&sidebar, cx);
 8792    let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
 8793        mw.workspaces().map(|ws| ws.entity_id()).collect()
 8794    });
 8795    sidebar.read_with(cx, |sidebar, cx| {
 8796        let multi_workspace = multi_workspace.read(cx);
 8797        let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
 8798            .contents
 8799            .entries
 8800            .iter()
 8801            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 8802            .map(|ws| ws.entity_id())
 8803            .collect();
 8804        let all: std::collections::HashSet<gpui::EntityId> =
 8805            mw_workspaces.iter().copied().collect();
 8806        let unreachable = &all - &reachable;
 8807        assert!(
 8808            unreachable.is_empty(),
 8809            "all workspaces should be reachable after adding folder; \
 8810             unreachable: {:?}, entries: {:?}",
 8811            unreachable,
 8812            entries,
 8813        );
 8814    });
 8815}
 8816
 8817mod property_test {
 8818    use super::*;
 8819    use gpui::proptest::prelude::*;
 8820
 8821    struct UnopenedWorktree {
 8822        path: String,
 8823        main_workspace_path: String,
 8824    }
 8825
 8826    struct TestState {
 8827        fs: Arc<FakeFs>,
 8828        thread_counter: u32,
 8829        workspace_counter: u32,
 8830        worktree_counter: u32,
 8831        saved_thread_ids: Vec<acp::SessionId>,
 8832        unopened_worktrees: Vec<UnopenedWorktree>,
 8833    }
 8834
 8835    impl TestState {
 8836        fn new(fs: Arc<FakeFs>) -> Self {
 8837            Self {
 8838                fs,
 8839                thread_counter: 0,
 8840                workspace_counter: 1,
 8841                worktree_counter: 0,
 8842                saved_thread_ids: Vec::new(),
 8843                unopened_worktrees: Vec::new(),
 8844            }
 8845        }
 8846
 8847        fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
 8848            let id = self.thread_counter;
 8849            self.thread_counter += 1;
 8850            acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
 8851        }
 8852
 8853        fn next_workspace_path(&mut self) -> String {
 8854            let id = self.workspace_counter;
 8855            self.workspace_counter += 1;
 8856            format!("/prop-project-{id}")
 8857        }
 8858
 8859        fn next_worktree_name(&mut self) -> String {
 8860            let id = self.worktree_counter;
 8861            self.worktree_counter += 1;
 8862            format!("wt-{id}")
 8863        }
 8864    }
 8865
 8866    #[derive(Debug)]
 8867    enum Operation {
 8868        SaveThread { project_group_index: usize },
 8869        SaveWorktreeThread { worktree_index: usize },
 8870        ToggleAgentPanel,
 8871        CreateDraftThread,
 8872        AddProject { use_worktree: bool },
 8873        ArchiveThread { index: usize },
 8874        SwitchToThread { index: usize },
 8875        SwitchToProjectGroup { index: usize },
 8876        AddLinkedWorktree { project_group_index: usize },
 8877        AddWorktreeToProject { project_group_index: usize },
 8878        RemoveWorktreeFromProject { project_group_index: usize },
 8879    }
 8880
 8881    // Distribution (out of 24 slots):
 8882    //   SaveThread:                5 slots (~21%)
 8883    //   SaveWorktreeThread:        2 slots (~8%)
 8884    //   ToggleAgentPanel:          1 slot  (~4%)
 8885    //   CreateDraftThread:         1 slot  (~4%)
 8886    //   AddProject:                1 slot  (~4%)
 8887    //   ArchiveThread:             2 slots (~8%)
 8888    //   SwitchToThread:            2 slots (~8%)
 8889    //   SwitchToProjectGroup:      2 slots (~8%)
 8890    //   AddLinkedWorktree:         4 slots (~17%)
 8891    //   AddWorktreeToProject:      2 slots (~8%)
 8892    //   RemoveWorktreeFromProject: 2 slots (~8%)
 8893    const DISTRIBUTION_SLOTS: u32 = 24;
 8894
 8895    impl TestState {
 8896        fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
 8897            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
 8898
 8899            match raw % DISTRIBUTION_SLOTS {
 8900                0..=4 => Operation::SaveThread {
 8901                    project_group_index: extra % project_group_count,
 8902                },
 8903                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
 8904                    worktree_index: extra % self.unopened_worktrees.len(),
 8905                },
 8906                5..=6 => Operation::SaveThread {
 8907                    project_group_index: extra % project_group_count,
 8908                },
 8909                7 => Operation::ToggleAgentPanel,
 8910                8 => Operation::CreateDraftThread,
 8911                9 => Operation::AddProject {
 8912                    use_worktree: !self.unopened_worktrees.is_empty(),
 8913                },
 8914                10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
 8915                    index: extra % self.saved_thread_ids.len(),
 8916                },
 8917                10..=11 => Operation::AddProject {
 8918                    use_worktree: !self.unopened_worktrees.is_empty(),
 8919                },
 8920                12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
 8921                    index: extra % self.saved_thread_ids.len(),
 8922                },
 8923                12..=13 => Operation::SwitchToProjectGroup {
 8924                    index: extra % project_group_count,
 8925                },
 8926                14..=15 => Operation::SwitchToProjectGroup {
 8927                    index: extra % project_group_count,
 8928                },
 8929                16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
 8930                    project_group_index: extra % project_group_count,
 8931                },
 8932                16..=19 => Operation::SaveThread {
 8933                    project_group_index: extra % project_group_count,
 8934                },
 8935                20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
 8936                    project_group_index: extra % project_group_count,
 8937                },
 8938                20..=21 => Operation::SaveThread {
 8939                    project_group_index: extra % project_group_count,
 8940                },
 8941                22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
 8942                    project_group_index: extra % project_group_count,
 8943                },
 8944                22..=23 => Operation::SaveThread {
 8945                    project_group_index: extra % project_group_count,
 8946                },
 8947                _ => unreachable!(),
 8948            }
 8949        }
 8950    }
 8951
 8952    fn save_thread_to_path_with_main(
 8953        state: &mut TestState,
 8954        path_list: PathList,
 8955        main_worktree_paths: PathList,
 8956        cx: &mut gpui::VisualTestContext,
 8957    ) {
 8958        let session_id = state.next_metadata_only_thread_id();
 8959        let title: SharedString = format!("Thread {}", session_id).into();
 8960        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 8961            .unwrap()
 8962            + chrono::Duration::seconds(state.thread_counter as i64);
 8963        let metadata = ThreadMetadata {
 8964            thread_id: ThreadId::new(),
 8965            session_id: Some(session_id),
 8966            agent_id: agent::ZED_AGENT_ID.clone(),
 8967            title: Some(title),
 8968            updated_at,
 8969            created_at: None,
 8970            worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
 8971            archived: false,
 8972            remote_connection: None,
 8973        };
 8974        cx.update(|_, cx| {
 8975            ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
 8976        });
 8977        cx.run_until_parked();
 8978    }
 8979
 8980    async fn perform_operation(
 8981        operation: Operation,
 8982        state: &mut TestState,
 8983        multi_workspace: &Entity<MultiWorkspace>,
 8984        sidebar: &Entity<Sidebar>,
 8985        cx: &mut gpui::VisualTestContext,
 8986    ) {
 8987        match operation {
 8988            Operation::SaveThread {
 8989                project_group_index,
 8990            } => {
 8991                // Find a workspace for this project group and create a real
 8992                // thread via its agent panel.
 8993                let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
 8994                    let keys = mw.project_group_keys();
 8995                    let key = &keys[project_group_index];
 8996                    let ws = mw
 8997                        .workspaces_for_project_group(key, cx)
 8998                        .and_then(|ws| ws.first().cloned())
 8999                        .unwrap_or_else(|| mw.workspace().clone());
 9000                    let project = ws.read(cx).project().clone();
 9001                    (ws, project)
 9002                });
 9003
 9004                let panel =
 9005                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9006                if let Some(panel) = panel {
 9007                    let connection = StubAgentConnection::new();
 9008                    connection.set_next_prompt_updates(vec![
 9009                        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
 9010                            "Done".into(),
 9011                        )),
 9012                    ]);
 9013                    open_thread_with_connection(&panel, connection, cx);
 9014                    send_message(&panel, cx);
 9015                    let session_id = active_session_id(&panel, cx);
 9016                    state.saved_thread_ids.push(session_id.clone());
 9017
 9018                    let title: SharedString = format!("Thread {}", state.thread_counter).into();
 9019                    state.thread_counter += 1;
 9020                    let updated_at =
 9021                        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9022                            .unwrap()
 9023                            + chrono::Duration::seconds(state.thread_counter as i64);
 9024                    save_thread_metadata(session_id, Some(title), updated_at, None, &project, cx);
 9025                }
 9026            }
 9027            Operation::SaveWorktreeThread { worktree_index } => {
 9028                let worktree = &state.unopened_worktrees[worktree_index];
 9029                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
 9030                let main_worktree_paths =
 9031                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
 9032                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
 9033            }
 9034
 9035            Operation::ToggleAgentPanel => {
 9036                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9037                let panel_open =
 9038                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
 9039                workspace.update_in(cx, |workspace, window, cx| {
 9040                    if panel_open {
 9041                        workspace.close_panel::<AgentPanel>(window, cx);
 9042                    } else {
 9043                        workspace.open_panel::<AgentPanel>(window, cx);
 9044                    }
 9045                });
 9046            }
 9047            Operation::CreateDraftThread => {
 9048                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9049                let panel =
 9050                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9051                if let Some(panel) = panel {
 9052                    panel.update_in(cx, |panel, window, cx| {
 9053                        panel.new_thread(&NewThread, window, cx);
 9054                    });
 9055                    cx.run_until_parked();
 9056                }
 9057                workspace.update_in(cx, |workspace, window, cx| {
 9058                    workspace.focus_panel::<AgentPanel>(window, cx);
 9059                });
 9060            }
 9061            Operation::AddProject { use_worktree } => {
 9062                let path = if use_worktree {
 9063                    // Open an existing linked worktree as a project (simulates Cmd+O
 9064                    // on a worktree directory).
 9065                    state.unopened_worktrees.remove(0).path
 9066                } else {
 9067                    // Create a brand new project.
 9068                    let path = state.next_workspace_path();
 9069                    state
 9070                        .fs
 9071                        .insert_tree(
 9072                            &path,
 9073                            serde_json::json!({
 9074                                ".git": {},
 9075                                "src": {},
 9076                            }),
 9077                        )
 9078                        .await;
 9079                    path
 9080                };
 9081                let project = project::Project::test(
 9082                    state.fs.clone() as Arc<dyn fs::Fs>,
 9083                    [path.as_ref()],
 9084                    cx,
 9085                )
 9086                .await;
 9087                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9088                multi_workspace.update_in(cx, |mw, window, cx| {
 9089                    mw.test_add_workspace(project.clone(), window, cx)
 9090                });
 9091            }
 9092
 9093            Operation::ArchiveThread { index } => {
 9094                let session_id = state.saved_thread_ids[index].clone();
 9095                sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 9096                    sidebar.archive_thread(&session_id, window, cx);
 9097                });
 9098                cx.run_until_parked();
 9099                state.saved_thread_ids.remove(index);
 9100            }
 9101            Operation::SwitchToThread { index } => {
 9102                let session_id = state.saved_thread_ids[index].clone();
 9103                // Find the thread's position in the sidebar entries and select it.
 9104                let thread_index = sidebar.read_with(cx, |sidebar, _| {
 9105                    sidebar.contents.entries.iter().position(|entry| {
 9106                        matches!(
 9107                            entry,
 9108                            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
 9109                        )
 9110                    })
 9111                });
 9112                if let Some(ix) = thread_index {
 9113                    sidebar.update_in(cx, |sidebar, window, cx| {
 9114                        sidebar.selection = Some(ix);
 9115                        sidebar.confirm(&Confirm, window, cx);
 9116                    });
 9117                    cx.run_until_parked();
 9118                }
 9119            }
 9120            Operation::SwitchToProjectGroup { index } => {
 9121                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9122                    let keys = mw.project_group_keys();
 9123                    let key = &keys[index];
 9124                    mw.workspaces_for_project_group(key, cx)
 9125                        .and_then(|ws| ws.first().cloned())
 9126                        .unwrap_or_else(|| mw.workspace().clone())
 9127                });
 9128                multi_workspace.update_in(cx, |mw, window, cx| {
 9129                    mw.activate(workspace, window, cx);
 9130                });
 9131            }
 9132            Operation::AddLinkedWorktree {
 9133                project_group_index,
 9134            } => {
 9135                // Get the main worktree path from the project group key.
 9136                let main_path = multi_workspace.read_with(cx, |mw, _| {
 9137                    let keys = mw.project_group_keys();
 9138                    let key = &keys[project_group_index];
 9139                    key.path_list()
 9140                        .paths()
 9141                        .first()
 9142                        .unwrap()
 9143                        .to_string_lossy()
 9144                        .to_string()
 9145                });
 9146                let dot_git = format!("{}/.git", main_path);
 9147                let worktree_name = state.next_worktree_name();
 9148                let worktree_path = format!("/worktrees/{}", worktree_name);
 9149
 9150                state.fs
 9151                    .insert_tree(
 9152                        &worktree_path,
 9153                        serde_json::json!({
 9154                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
 9155                            "src": {},
 9156                        }),
 9157                    )
 9158                    .await;
 9159
 9160                // Also create the worktree metadata dir inside the main repo's .git
 9161                state
 9162                    .fs
 9163                    .insert_tree(
 9164                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
 9165                        serde_json::json!({
 9166                            "commondir": "../../",
 9167                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
 9168                        }),
 9169                    )
 9170                    .await;
 9171
 9172                let dot_git_path = std::path::Path::new(&dot_git);
 9173                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
 9174                state
 9175                    .fs
 9176                    .add_linked_worktree_for_repo(
 9177                        dot_git_path,
 9178                        false,
 9179                        git::repository::Worktree {
 9180                            path: worktree_pathbuf,
 9181                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 9182                            sha: "aaa".into(),
 9183                            is_main: false,
 9184                        },
 9185                    )
 9186                    .await;
 9187
 9188                // Re-scan the main workspace's project so it discovers the new worktree.
 9189                let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
 9190                    let keys = mw.project_group_keys();
 9191                    let key = &keys[project_group_index];
 9192                    mw.workspaces_for_project_group(key, cx)
 9193                        .and_then(|ws| ws.first().cloned())
 9194                        .unwrap()
 9195                });
 9196                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 9197                main_project
 9198                    .update(cx, |p, cx| p.git_scans_complete(cx))
 9199                    .await;
 9200
 9201                state.unopened_worktrees.push(UnopenedWorktree {
 9202                    path: worktree_path,
 9203                    main_workspace_path: main_path.clone(),
 9204                });
 9205            }
 9206            Operation::AddWorktreeToProject {
 9207                project_group_index,
 9208            } => {
 9209                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9210                    let keys = mw.project_group_keys();
 9211                    let key = &keys[project_group_index];
 9212                    mw.workspaces_for_project_group(key, cx)
 9213                        .and_then(|ws| ws.first().cloned())
 9214                });
 9215                let Some(workspace) = workspace else { return };
 9216                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9217
 9218                let new_path = state.next_workspace_path();
 9219                state
 9220                    .fs
 9221                    .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
 9222                    .await;
 9223
 9224                let result = project
 9225                    .update(cx, |project, cx| {
 9226                        project.find_or_create_worktree(&new_path, true, cx)
 9227                    })
 9228                    .await;
 9229                if result.is_err() {
 9230                    return;
 9231                }
 9232                cx.run_until_parked();
 9233            }
 9234            Operation::RemoveWorktreeFromProject {
 9235                project_group_index,
 9236            } => {
 9237                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9238                    let keys = mw.project_group_keys();
 9239                    let key = &keys[project_group_index];
 9240                    mw.workspaces_for_project_group(key, cx)
 9241                        .and_then(|ws| ws.first().cloned())
 9242                });
 9243                let Some(workspace) = workspace else { return };
 9244                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9245
 9246                let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
 9247                if worktree_count <= 1 {
 9248                    return;
 9249                }
 9250
 9251                let worktree_id = project.read_with(cx, |p, cx| {
 9252                    p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
 9253                });
 9254                if let Some(worktree_id) = worktree_id {
 9255                    project.update(cx, |project, cx| {
 9256                        project.remove_worktree(worktree_id, cx);
 9257                    });
 9258                    cx.run_until_parked();
 9259                }
 9260            }
 9261        }
 9262    }
 9263
 9264    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
 9265        sidebar.update_in(cx, |sidebar, _window, cx| {
 9266            if let Some(mw) = sidebar.multi_workspace.upgrade() {
 9267                mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 9268            }
 9269            sidebar.update_entries(cx);
 9270        });
 9271    }
 9272
 9273    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9274        verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
 9275        verify_no_duplicate_threads(sidebar)?;
 9276        verify_all_threads_are_shown(sidebar, cx)?;
 9277        verify_active_state_matches_current_workspace(sidebar, cx)?;
 9278        verify_all_workspaces_are_reachable(sidebar, cx)?;
 9279        verify_workspace_group_key_integrity(sidebar, cx)?;
 9280        Ok(())
 9281    }
 9282
 9283    fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
 9284        let mut seen: HashSet<acp::SessionId> = HashSet::default();
 9285        let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
 9286
 9287        for entry in &sidebar.contents.entries {
 9288            if let Some(session_id) = entry.session_id() {
 9289                if !seen.insert(session_id.clone()) {
 9290                    let title = match entry {
 9291                        ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
 9292                        _ => "<unknown>".to_string(),
 9293                    };
 9294                    duplicates.push((session_id.clone(), title));
 9295                }
 9296            }
 9297        }
 9298
 9299        anyhow::ensure!(
 9300            duplicates.is_empty(),
 9301            "threads appear more than once in sidebar: {:?}",
 9302            duplicates,
 9303        );
 9304        Ok(())
 9305    }
 9306
 9307    fn verify_every_group_in_multiworkspace_is_shown(
 9308        sidebar: &Sidebar,
 9309        cx: &App,
 9310    ) -> anyhow::Result<()> {
 9311        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9312            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9313        };
 9314
 9315        let mw = multi_workspace.read(cx);
 9316
 9317        // Every project group key in the multi-workspace that has a
 9318        // non-empty path list should appear as a ProjectHeader in the
 9319        // sidebar.
 9320        let all_keys = mw.project_group_keys();
 9321        let expected_keys: HashSet<&ProjectGroupKey> = all_keys
 9322            .iter()
 9323            .filter(|k| !k.path_list().paths().is_empty())
 9324            .collect();
 9325
 9326        let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
 9327            .contents
 9328            .entries
 9329            .iter()
 9330            .filter_map(|entry| match entry {
 9331                ListEntry::ProjectHeader { key, .. } => Some(key),
 9332                _ => None,
 9333            })
 9334            .collect();
 9335
 9336        let missing = &expected_keys - &sidebar_keys;
 9337        let stray = &sidebar_keys - &expected_keys;
 9338
 9339        anyhow::ensure!(
 9340            missing.is_empty() && stray.is_empty(),
 9341            "sidebar project groups don't match multi-workspace.\n\
 9342             Only in multi-workspace (missing): {:?}\n\
 9343             Only in sidebar (stray): {:?}",
 9344            missing,
 9345            stray,
 9346        );
 9347
 9348        Ok(())
 9349    }
 9350
 9351    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9352        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9353            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9354        };
 9355        let workspaces = multi_workspace
 9356            .read(cx)
 9357            .workspaces()
 9358            .cloned()
 9359            .collect::<Vec<_>>();
 9360        let thread_store = ThreadMetadataStore::global(cx);
 9361
 9362        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
 9363            .contents
 9364            .entries
 9365            .iter()
 9366            .filter_map(|entry| entry.session_id().cloned())
 9367            .collect();
 9368
 9369        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
 9370
 9371        // Query using the same approach as the sidebar: iterate project
 9372        // group keys, then do main + legacy queries per group.
 9373        let mw = multi_workspace.read(cx);
 9374        let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
 9375            HashMap::default();
 9376        for workspace in &workspaces {
 9377            let key = workspace.read(cx).project_group_key(cx);
 9378            workspaces_by_group
 9379                .entry(key)
 9380                .or_default()
 9381                .push(workspace.clone());
 9382        }
 9383
 9384        for group_key in mw.project_group_keys() {
 9385            let path_list = group_key.path_list().clone();
 9386            if path_list.paths().is_empty() {
 9387                continue;
 9388            }
 9389
 9390            let group_workspaces = workspaces_by_group
 9391                .get(&group_key)
 9392                .map(|ws| ws.as_slice())
 9393                .unwrap_or_default();
 9394
 9395            // Main code path queries (run for all groups, even without workspaces).
 9396            // Skip drafts (session_id: None) — they are shown via the
 9397            // panel's draft_thread_ids, not by session_id matching.
 9398            for metadata in thread_store
 9399                .read(cx)
 9400                .entries_for_main_worktree_path(&path_list)
 9401            {
 9402                if let Some(sid) = metadata.session_id.clone() {
 9403                    metadata_thread_ids.insert(sid);
 9404                }
 9405            }
 9406            for metadata in thread_store.read(cx).entries_for_path(&path_list) {
 9407                if let Some(sid) = metadata.session_id.clone() {
 9408                    metadata_thread_ids.insert(sid);
 9409                }
 9410            }
 9411
 9412            // Legacy: per-workspace queries for different root paths.
 9413            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
 9414                .iter()
 9415                .flat_map(|ws| {
 9416                    ws.read(cx)
 9417                        .root_paths(cx)
 9418                        .into_iter()
 9419                        .map(|p| p.to_path_buf())
 9420                })
 9421                .collect();
 9422
 9423            for workspace in group_workspaces {
 9424                let ws_path_list = workspace_path_list(workspace, cx);
 9425                if ws_path_list != path_list {
 9426                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list) {
 9427                        if let Some(sid) = metadata.session_id.clone() {
 9428                            metadata_thread_ids.insert(sid);
 9429                        }
 9430                    }
 9431                }
 9432            }
 9433
 9434            for workspace in group_workspaces {
 9435                for snapshot in root_repository_snapshots(workspace, cx) {
 9436                    let repo_path_list =
 9437                        PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
 9438                    if repo_path_list != path_list {
 9439                        continue;
 9440                    }
 9441                    for linked_worktree in snapshot.linked_worktrees() {
 9442                        if covered_paths.contains(&*linked_worktree.path) {
 9443                            continue;
 9444                        }
 9445                        let worktree_path_list =
 9446                            PathList::new(std::slice::from_ref(&linked_worktree.path));
 9447                        for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list)
 9448                        {
 9449                            if let Some(sid) = metadata.session_id.clone() {
 9450                                metadata_thread_ids.insert(sid);
 9451                            }
 9452                        }
 9453                    }
 9454                }
 9455            }
 9456        }
 9457
 9458        anyhow::ensure!(
 9459            sidebar_thread_ids == metadata_thread_ids,
 9460            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
 9461            sidebar_thread_ids,
 9462            metadata_thread_ids,
 9463        );
 9464        Ok(())
 9465    }
 9466
 9467    fn verify_active_state_matches_current_workspace(
 9468        sidebar: &Sidebar,
 9469        cx: &App,
 9470    ) -> anyhow::Result<()> {
 9471        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9472            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9473        };
 9474
 9475        let active_workspace = multi_workspace.read(cx).workspace();
 9476
 9477        // 1. active_entry should be Some when the panel has content.
 9478        //    It may be None when the panel is uninitialized (no drafts,
 9479        //    no threads), which is fine.
 9480        //    It may also temporarily point at a different workspace
 9481        //    when the workspace just changed and the new panel has no
 9482        //    content yet.
 9483        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
 9484        let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
 9485            || panel.read(cx).active_conversation_view().is_some();
 9486
 9487        let Some(entry) = sidebar.active_entry.as_ref() else {
 9488            if panel_has_content {
 9489                anyhow::bail!("active_entry is None but panel has content (draft or thread)");
 9490            }
 9491            return Ok(());
 9492        };
 9493
 9494        // If the entry workspace doesn't match the active workspace
 9495        // and the panel has no content, this is a transient state that
 9496        // will resolve when the panel gets content.
 9497        if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
 9498            return Ok(());
 9499        }
 9500
 9501        // 2. The entry's workspace must agree with the multi-workspace's
 9502        //    active workspace.
 9503        anyhow::ensure!(
 9504            entry.workspace().entity_id() == active_workspace.entity_id(),
 9505            "active_entry workspace ({:?}) != active workspace ({:?})",
 9506            entry.workspace().entity_id(),
 9507            active_workspace.entity_id(),
 9508        );
 9509
 9510        // 3. The entry must match the agent panel's current state.
 9511        if panel.read(cx).active_thread_id(cx).is_some() {
 9512            anyhow::ensure!(
 9513                matches!(entry, ActiveEntry { .. }),
 9514                "panel shows a tracked draft but active_entry is {:?}",
 9515                entry,
 9516            );
 9517        } else if let Some(thread_id) = panel
 9518            .read(cx)
 9519            .active_conversation_view()
 9520            .map(|cv| cv.read(cx).parent_id())
 9521        {
 9522            anyhow::ensure!(
 9523                matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
 9524                "panel has thread {:?} but active_entry is {:?}",
 9525                thread_id,
 9526                entry,
 9527            );
 9528        }
 9529
 9530        // 4. Exactly one entry in sidebar contents must be uniquely
 9531        //    identified by the active_entry.
 9532        let matching_count = sidebar
 9533            .contents
 9534            .entries
 9535            .iter()
 9536            .filter(|e| entry.matches_entry(e))
 9537            .count();
 9538        if matching_count != 1 {
 9539            let thread_entries: Vec<_> = sidebar
 9540                .contents
 9541                .entries
 9542                .iter()
 9543                .filter_map(|e| match e {
 9544                    ListEntry::Thread(t) => Some(format!(
 9545                        "tid={:?} sid={:?} draft={}",
 9546                        t.metadata.thread_id, t.metadata.session_id, t.is_draft
 9547                    )),
 9548                    _ => None,
 9549                })
 9550                .collect();
 9551            let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
 9552            let store_entries: Vec<_> = store
 9553                .entries()
 9554                .map(|m| {
 9555                    format!(
 9556                        "tid={:?} sid={:?} archived={} paths={:?}",
 9557                        m.thread_id,
 9558                        m.session_id,
 9559                        m.archived,
 9560                        m.folder_paths()
 9561                    )
 9562                })
 9563                .collect();
 9564            anyhow::bail!(
 9565                "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
 9566                entry,
 9567                matching_count,
 9568                thread_entries,
 9569                store_entries,
 9570            );
 9571        }
 9572
 9573        Ok(())
 9574    }
 9575
 9576    /// Every workspace in the multi-workspace should be "reachable" from
 9577    /// the sidebar — meaning there is at least one entry (thread, draft,
 9578    /// new-thread, or project header) that, when clicked, would activate
 9579    /// that workspace.
 9580    fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9581        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9582            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9583        };
 9584
 9585        let multi_workspace = multi_workspace.read(cx);
 9586
 9587        let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
 9588            .contents
 9589            .entries
 9590            .iter()
 9591            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 9592            .map(|ws| ws.entity_id())
 9593            .collect();
 9594
 9595        let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
 9596            .workspaces()
 9597            .map(|ws| ws.entity_id())
 9598            .collect();
 9599
 9600        let unreachable = &all_workspace_ids - &reachable_workspaces;
 9601
 9602        anyhow::ensure!(
 9603            unreachable.is_empty(),
 9604            "The following workspaces are not reachable from any sidebar entry: {:?}",
 9605            unreachable,
 9606        );
 9607
 9608        Ok(())
 9609    }
 9610
 9611    fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9612        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9613            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9614        };
 9615        multi_workspace
 9616            .read(cx)
 9617            .assert_project_group_key_integrity(cx)
 9618    }
 9619
 9620    #[gpui::property_test(config = ProptestConfig {
 9621        cases: 20,
 9622        ..Default::default()
 9623    })]
 9624    async fn test_sidebar_invariants(
 9625        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
 9626        raw_operations: Vec<u32>,
 9627        cx: &mut TestAppContext,
 9628    ) {
 9629        use std::sync::atomic::{AtomicUsize, Ordering};
 9630        static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
 9631
 9632        agent_ui::test_support::init_test(cx);
 9633        cx.update(|cx| {
 9634            cx.set_global(db::AppDatabase::test_new());
 9635            cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 9636            cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
 9637                format!(
 9638                    "PROPTEST_THREAD_METADATA_{}",
 9639                    NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
 9640                ),
 9641            ));
 9642
 9643            ThreadStore::init_global(cx);
 9644            ThreadMetadataStore::init_global(cx);
 9645            language_model::LanguageModelRegistry::test(cx);
 9646            prompt_store::init(cx);
 9647
 9648            // Auto-add an AgentPanel to every workspace so that implicitly
 9649            // created workspaces (e.g. from thread activation) also have one.
 9650            cx.observe_new(
 9651                |workspace: &mut Workspace,
 9652                 window: Option<&mut Window>,
 9653                 cx: &mut gpui::Context<Workspace>| {
 9654                    if let Some(window) = window {
 9655                        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 9656                        workspace.add_panel(panel, window, cx);
 9657                    }
 9658                },
 9659            )
 9660            .detach();
 9661        });
 9662
 9663        let fs = FakeFs::new(cx.executor());
 9664        fs.insert_tree(
 9665            "/my-project",
 9666            serde_json::json!({
 9667                ".git": {},
 9668                "src": {},
 9669            }),
 9670        )
 9671        .await;
 9672        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 9673        let project =
 9674            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
 9675                .await;
 9676        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9677
 9678        let (multi_workspace, cx) =
 9679            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9680        let sidebar = setup_sidebar(&multi_workspace, cx);
 9681
 9682        let mut state = TestState::new(fs);
 9683        let mut executed: Vec<String> = Vec::new();
 9684
 9685        for &raw_op in &raw_operations {
 9686            let project_group_count =
 9687                multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
 9688            let operation = state.generate_operation(raw_op, project_group_count);
 9689            executed.push(format!("{:?}", operation));
 9690            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
 9691            cx.run_until_parked();
 9692
 9693            update_sidebar(&sidebar, cx);
 9694            cx.run_until_parked();
 9695
 9696            let result =
 9697                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
 9698            if let Err(err) = result {
 9699                let log = executed.join("\n  ");
 9700                panic!(
 9701                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
 9702                    executed.len(),
 9703                );
 9704            }
 9705        }
 9706    }
 9707}
 9708
 9709#[gpui::test]
 9710async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
 9711    cx: &mut TestAppContext,
 9712    server_cx: &mut TestAppContext,
 9713) {
 9714    init_test(cx);
 9715
 9716    cx.update(|cx| {
 9717        release_channel::init(semver::Version::new(0, 0, 0), cx);
 9718    });
 9719
 9720    let app_state = cx.update(|cx| {
 9721        let app_state = workspace::AppState::test(cx);
 9722        workspace::init(app_state.clone(), cx);
 9723        app_state
 9724    });
 9725
 9726    // Set up the remote server side.
 9727    let server_fs = FakeFs::new(server_cx.executor());
 9728    server_fs
 9729        .insert_tree(
 9730            "/project",
 9731            serde_json::json!({
 9732                ".git": {},
 9733                "src": { "main.rs": "fn main() {}" }
 9734            }),
 9735        )
 9736        .await;
 9737    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
 9738
 9739    // Create the linked worktree checkout path on the remote server,
 9740    // but do not yet register it as a git-linked worktree. The real
 9741    // regrouping update in this test should happen only after the
 9742    // sidebar opens the closed remote thread.
 9743    server_fs
 9744        .insert_tree(
 9745            "/project-wt-1",
 9746            serde_json::json!({
 9747                "src": { "main.rs": "fn main() {}" }
 9748            }),
 9749        )
 9750        .await;
 9751
 9752    server_cx.update(|cx| {
 9753        release_channel::init(semver::Version::new(0, 0, 0), cx);
 9754    });
 9755
 9756    let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
 9757
 9758    server_cx.update(remote_server::HeadlessProject::init);
 9759    let server_executor = server_cx.executor();
 9760    let _headless = server_cx.new(|cx| {
 9761        remote_server::HeadlessProject::new(
 9762            remote_server::HeadlessAppState {
 9763                session: server_session,
 9764                fs: server_fs.clone(),
 9765                http_client: Arc::new(http_client::BlockedHttpClient),
 9766                node_runtime: node_runtime::NodeRuntime::unavailable(),
 9767                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
 9768                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
 9769                startup_time: std::time::Instant::now(),
 9770            },
 9771            false,
 9772            cx,
 9773        )
 9774    });
 9775
 9776    // Connect the client side and build a remote project.
 9777    let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
 9778    let project = cx.update(|cx| {
 9779        let project_client = client::Client::new(
 9780            Arc::new(clock::FakeSystemClock::new()),
 9781            http_client::FakeHttpClient::with_404_response(),
 9782            cx,
 9783        );
 9784        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
 9785        project::Project::remote(
 9786            remote_client,
 9787            project_client,
 9788            node_runtime::NodeRuntime::unavailable(),
 9789            user_store,
 9790            app_state.languages.clone(),
 9791            app_state.fs.clone(),
 9792            false,
 9793            cx,
 9794        )
 9795    });
 9796
 9797    // Open the remote worktree.
 9798    project
 9799        .update(cx, |project, cx| {
 9800            project.find_or_create_worktree(Path::new("/project"), true, cx)
 9801        })
 9802        .await
 9803        .expect("should open remote worktree");
 9804    cx.run_until_parked();
 9805
 9806    // Verify the project is remote.
 9807    project.read_with(cx, |project, cx| {
 9808        assert!(!project.is_local(), "project should be remote");
 9809        assert!(
 9810            project.remote_connection_options(cx).is_some(),
 9811            "project should have remote connection options"
 9812        );
 9813    });
 9814
 9815    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
 9816
 9817    // Create MultiWorkspace with the remote project.
 9818    let (multi_workspace, cx) =
 9819        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9820    let sidebar = setup_sidebar(&multi_workspace, cx);
 9821
 9822    cx.run_until_parked();
 9823
 9824    // Save a thread for the main remote workspace (folder_paths match
 9825    // the open workspace, so it will be classified as Open).
 9826    let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
 9827    save_thread_metadata(
 9828        main_thread_id.clone(),
 9829        Some("Main Thread".into()),
 9830        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 9831        None,
 9832        &project,
 9833        cx,
 9834    );
 9835    cx.run_until_parked();
 9836
 9837    // Save a thread whose folder_paths point to a linked worktree path
 9838    // that doesn't have an open workspace ("/project-wt-1"), but whose
 9839    // main_worktree_paths match the project group key so it appears
 9840    // in the sidebar under the same remote group. This simulates a
 9841    // linked worktree workspace that was closed.
 9842    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
 9843    let main_worktree_paths =
 9844        project.read_with(cx, |p, cx| p.project_group_key(cx).path_list().clone());
 9845    cx.update(|_window, cx| {
 9846        let metadata = ThreadMetadata {
 9847            thread_id: ThreadId::new(),
 9848            session_id: Some(remote_thread_id.clone()),
 9849            agent_id: agent::ZED_AGENT_ID.clone(),
 9850            title: Some("Worktree Thread".into()),
 9851            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 9852            created_at: None,
 9853            worktree_paths: WorktreePaths::from_path_lists(
 9854                main_worktree_paths,
 9855                PathList::new(&[PathBuf::from("/project-wt-1")]),
 9856            )
 9857            .unwrap(),
 9858            archived: false,
 9859            remote_connection: None,
 9860        };
 9861        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
 9862    });
 9863    cx.run_until_parked();
 9864
 9865    focus_sidebar(&sidebar, cx);
 9866    sidebar.update_in(cx, |sidebar, _window, _cx| {
 9867        sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
 9868            matches!(
 9869                entry,
 9870                ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
 9871            )
 9872        });
 9873    });
 9874
 9875    let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
 9876    let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
 9877
 9878    sidebar
 9879        .update(cx, |_, cx| {
 9880            cx.observe_self(move |sidebar, _cx| {
 9881                let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
 9882                    if let ListEntry::ProjectHeader { label, .. } = entry {
 9883                        Some(label.as_ref())
 9884                    } else {
 9885                        None
 9886                    }
 9887                });
 9888
 9889                let Some(project_header) = project_headers.next() else {
 9890                    saw_separate_project_header_for_observer
 9891                        .store(true, std::sync::atomic::Ordering::SeqCst);
 9892                    return;
 9893                };
 9894
 9895                if project_header != "project" || project_headers.next().is_some() {
 9896                    saw_separate_project_header_for_observer
 9897                        .store(true, std::sync::atomic::Ordering::SeqCst);
 9898                }
 9899            })
 9900        })
 9901        .detach();
 9902
 9903    multi_workspace.update(cx, |multi_workspace, cx| {
 9904        let workspace = multi_workspace.workspace().clone();
 9905        workspace.update(cx, |workspace: &mut Workspace, cx| {
 9906            let remote_client = workspace
 9907                .project()
 9908                .read(cx)
 9909                .remote_client()
 9910                .expect("main remote project should have a remote client");
 9911            remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
 9912                remote_client.force_server_not_running(cx);
 9913            });
 9914        });
 9915    });
 9916    cx.run_until_parked();
 9917
 9918    let (server_session_2, connect_guard_2) =
 9919        remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
 9920    let _headless_2 = server_cx.new(|cx| {
 9921        remote_server::HeadlessProject::new(
 9922            remote_server::HeadlessAppState {
 9923                session: server_session_2,
 9924                fs: server_fs.clone(),
 9925                http_client: Arc::new(http_client::BlockedHttpClient),
 9926                node_runtime: node_runtime::NodeRuntime::unavailable(),
 9927                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
 9928                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
 9929                startup_time: std::time::Instant::now(),
 9930            },
 9931            false,
 9932            cx,
 9933        )
 9934    });
 9935    drop(connect_guard_2);
 9936
 9937    let window = cx.windows()[0];
 9938    cx.update_window(window, |_, window, cx| {
 9939        window.dispatch_action(Confirm.boxed_clone(), cx);
 9940    })
 9941    .unwrap();
 9942
 9943    cx.run_until_parked();
 9944
 9945    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
 9946        assert_eq!(
 9947            mw.workspaces().count(),
 9948            2,
 9949            "confirming a closed remote thread should open a second workspace"
 9950        );
 9951        mw.workspaces()
 9952            .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
 9953            .unwrap()
 9954            .clone()
 9955    });
 9956
 9957    server_fs
 9958        .add_linked_worktree_for_repo(
 9959            Path::new("/project/.git"),
 9960            true,
 9961            git::repository::Worktree {
 9962                path: PathBuf::from("/project-wt-1"),
 9963                ref_name: Some("refs/heads/feature-wt".into()),
 9964                sha: "abc123".into(),
 9965                is_main: false,
 9966            },
 9967        )
 9968        .await;
 9969
 9970    server_cx.run_until_parked();
 9971    cx.run_until_parked();
 9972    server_cx.run_until_parked();
 9973    cx.run_until_parked();
 9974
 9975    let entries_after_update = visible_entries_as_strings(&sidebar, cx);
 9976    let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
 9977        workspace.project().read(cx).project_group_key(cx)
 9978    });
 9979
 9980    assert_eq!(
 9981        group_after_update,
 9982        project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
 9983        "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
 9984         final sidebar entries: {:?}",
 9985        entries_after_update,
 9986    );
 9987
 9988    sidebar.update(cx, |sidebar, _cx| {
 9989        assert_remote_project_integration_sidebar_state(
 9990            sidebar,
 9991            &main_thread_id,
 9992            &remote_thread_id,
 9993        );
 9994    });
 9995
 9996    assert!(
 9997        !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
 9998        "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
 9999         final group: {:?}; final sidebar entries: {:?}",
10000        group_after_update,
10001        entries_after_update,
10002    );
10003}
10004
10005#[gpui::test]
10006async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10007    // When the thread's folder_paths don't exactly match any workspace's
10008    // root paths (e.g. because a folder was added to the workspace after
10009    // the thread was created), workspace_to_remove is None. But the linked
10010    // worktree workspace still needs to be removed so that its worktree
10011    // entities are released, allowing git worktree removal to proceed.
10012    //
10013    // With the fix, archive_thread scans roots_to_archive for any linked
10014    // worktree workspaces and includes them in the removal set, even when
10015    // the thread's folder_paths don't match the workspace's root paths.
10016    init_test(cx);
10017    let fs = FakeFs::new(cx.executor());
10018
10019    fs.insert_tree(
10020        "/project",
10021        serde_json::json!({
10022            ".git": {
10023                "worktrees": {
10024                    "feature-a": {
10025                        "commondir": "../../",
10026                        "HEAD": "ref: refs/heads/feature-a",
10027                    },
10028                },
10029            },
10030            "src": {},
10031        }),
10032    )
10033    .await;
10034
10035    fs.insert_tree(
10036        "/wt-feature-a",
10037        serde_json::json!({
10038            ".git": "gitdir: /project/.git/worktrees/feature-a",
10039            "src": {
10040                "main.rs": "fn main() {}",
10041            },
10042        }),
10043    )
10044    .await;
10045
10046    fs.add_linked_worktree_for_repo(
10047        Path::new("/project/.git"),
10048        false,
10049        git::repository::Worktree {
10050            path: PathBuf::from("/wt-feature-a"),
10051            ref_name: Some("refs/heads/feature-a".into()),
10052            sha: "abc".into(),
10053            is_main: false,
10054        },
10055    )
10056    .await;
10057
10058    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10059
10060    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10061    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
10062
10063    main_project
10064        .update(cx, |p, cx| p.git_scans_complete(cx))
10065        .await;
10066    worktree_project
10067        .update(cx, |p, cx| p.git_scans_complete(cx))
10068        .await;
10069
10070    let (multi_workspace, cx) =
10071        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10072    let sidebar = setup_sidebar(&multi_workspace, cx);
10073
10074    multi_workspace.update_in(cx, |mw, window, cx| {
10075        mw.test_add_workspace(worktree_project.clone(), window, cx)
10076    });
10077
10078    // Save thread metadata using folder_paths that DON'T match the
10079    // workspace's root paths. This simulates the case where the workspace's
10080    // paths diverged (e.g. a folder was added after thread creation).
10081    // This causes workspace_to_remove to be None because
10082    // workspace_for_paths can't find a workspace with these exact paths.
10083    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10084    save_thread_metadata_with_main_paths(
10085        "worktree-thread",
10086        "Worktree Thread",
10087        PathList::new(&[
10088            PathBuf::from("/wt-feature-a"),
10089            PathBuf::from("/nonexistent"),
10090        ]),
10091        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10092        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10093        cx,
10094    );
10095
10096    // Also save a main thread so the sidebar has something to show.
10097    save_thread_metadata(
10098        acp::SessionId::new(Arc::from("main-thread")),
10099        Some("Main Thread".into()),
10100        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10101        None,
10102        &main_project,
10103        cx,
10104    );
10105    cx.run_until_parked();
10106
10107    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10108    cx.run_until_parked();
10109
10110    assert_eq!(
10111        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10112        2,
10113        "should start with 2 workspaces (main + linked worktree)"
10114    );
10115
10116    // Archive the worktree thread.
10117    sidebar.update_in(cx, |sidebar, window, cx| {
10118        sidebar.archive_thread(&wt_thread_id, window, cx);
10119    });
10120
10121    cx.run_until_parked();
10122
10123    // The linked worktree workspace should have been removed, even though
10124    // workspace_to_remove was None (paths didn't match).
10125    assert_eq!(
10126        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10127        1,
10128        "linked worktree workspace should be removed after archiving, \
10129         even when folder_paths don't match workspace root paths"
10130    );
10131
10132    // The thread should still be archived (not unarchived due to an error).
10133    let still_archived = cx.update(|_, cx| {
10134        ThreadMetadataStore::global(cx)
10135            .read(cx)
10136            .entry_by_session(&wt_thread_id)
10137            .map(|t| t.archived)
10138    });
10139    assert_eq!(
10140        still_archived,
10141        Some(true),
10142        "thread should still be archived (not rolled back due to error)"
10143    );
10144
10145    // The linked worktree directory should be removed from disk.
10146    assert!(
10147        !fs.is_dir(Path::new("/wt-feature-a")).await,
10148        "linked worktree directory should be removed from disk"
10149    );
10150}
10151
10152#[gpui::test]
10153async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10154    // When a workspace contains both a worktree being archived and other
10155    // worktrees that should remain, only the editor items referencing the
10156    // archived worktree should be closed — the workspace itself must be
10157    // preserved.
10158    init_test(cx);
10159    let fs = FakeFs::new(cx.executor());
10160
10161    fs.insert_tree(
10162        "/main-repo",
10163        serde_json::json!({
10164            ".git": {
10165                "worktrees": {
10166                    "feature-b": {
10167                        "commondir": "../../",
10168                        "HEAD": "ref: refs/heads/feature-b",
10169                    },
10170                },
10171            },
10172            "src": {
10173                "lib.rs": "pub fn hello() {}",
10174            },
10175        }),
10176    )
10177    .await;
10178
10179    fs.insert_tree(
10180        "/wt-feature-b",
10181        serde_json::json!({
10182            ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10183            "src": {
10184                "main.rs": "fn main() { hello(); }",
10185            },
10186        }),
10187    )
10188    .await;
10189
10190    fs.add_linked_worktree_for_repo(
10191        Path::new("/main-repo/.git"),
10192        false,
10193        git::repository::Worktree {
10194            path: PathBuf::from("/wt-feature-b"),
10195            ref_name: Some("refs/heads/feature-b".into()),
10196            sha: "def".into(),
10197            is_main: false,
10198        },
10199    )
10200    .await;
10201
10202    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10203
10204    // Create a single project that contains BOTH the main repo and the
10205    // linked worktree — this makes it a "mixed" workspace.
10206    let mixed_project = project::Project::test(
10207        fs.clone(),
10208        ["/main-repo".as_ref(), "/wt-feature-b".as_ref()],
10209        cx,
10210    )
10211    .await;
10212
10213    mixed_project
10214        .update(cx, |p, cx| p.git_scans_complete(cx))
10215        .await;
10216
10217    let (multi_workspace, cx) = cx
10218        .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10219    let sidebar = setup_sidebar(&multi_workspace, cx);
10220
10221    // Open editor items in both worktrees so we can verify which ones
10222    // get closed.
10223    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10224
10225    let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10226        ws.project()
10227            .read(cx)
10228            .visible_worktrees(cx)
10229            .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10230            .collect()
10231    });
10232
10233    let main_repo_wt_id = worktree_ids
10234        .iter()
10235        .find(|(_, path)| path.ends_with("main-repo"))
10236        .map(|(id, _)| *id)
10237        .expect("should find main-repo worktree");
10238
10239    let feature_b_wt_id = worktree_ids
10240        .iter()
10241        .find(|(_, path)| path.ends_with("wt-feature-b"))
10242        .map(|(id, _)| *id)
10243        .expect("should find wt-feature-b worktree");
10244
10245    // Open files from both worktrees.
10246    let main_repo_path = project::ProjectPath {
10247        worktree_id: main_repo_wt_id,
10248        path: Arc::from(rel_path("src/lib.rs")),
10249    };
10250    let feature_b_path = project::ProjectPath {
10251        worktree_id: feature_b_wt_id,
10252        path: Arc::from(rel_path("src/main.rs")),
10253    };
10254
10255    workspace
10256        .update_in(cx, |ws, window, cx| {
10257            ws.open_path(main_repo_path.clone(), None, true, window, cx)
10258        })
10259        .await
10260        .expect("should open main-repo file");
10261    workspace
10262        .update_in(cx, |ws, window, cx| {
10263            ws.open_path(feature_b_path.clone(), None, true, window, cx)
10264        })
10265        .await
10266        .expect("should open feature-b file");
10267
10268    cx.run_until_parked();
10269
10270    // Verify both items are open.
10271    let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10272        ws.panes()
10273            .iter()
10274            .flat_map(|pane| {
10275                pane.read(cx)
10276                    .items()
10277                    .filter_map(|item| item.project_path(cx))
10278            })
10279            .collect()
10280    });
10281    assert!(
10282        open_paths_before
10283            .iter()
10284            .any(|pp| pp.worktree_id == main_repo_wt_id),
10285        "main-repo file should be open"
10286    );
10287    assert!(
10288        open_paths_before
10289            .iter()
10290            .any(|pp| pp.worktree_id == feature_b_wt_id),
10291        "feature-b file should be open"
10292    );
10293
10294    // Save thread metadata for the linked worktree with deliberately
10295    // mismatched folder_paths to trigger the scan-based detection.
10296    save_thread_metadata_with_main_paths(
10297        "feature-b-thread",
10298        "Feature B Thread",
10299        PathList::new(&[
10300            PathBuf::from("/wt-feature-b"),
10301            PathBuf::from("/nonexistent"),
10302        ]),
10303        PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10304        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10305        cx,
10306    );
10307
10308    // Save another thread that references only the main repo (not the
10309    // linked worktree) so archiving the feature-b thread's worktree isn't
10310    // blocked by another unarchived thread referencing the same path.
10311    save_thread_metadata_with_main_paths(
10312        "other-thread",
10313        "Other Thread",
10314        PathList::new(&[PathBuf::from("/main-repo")]),
10315        PathList::new(&[PathBuf::from("/main-repo")]),
10316        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10317        cx,
10318    );
10319    cx.run_until_parked();
10320
10321    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10322    cx.run_until_parked();
10323
10324    // There should still be exactly 1 workspace.
10325    assert_eq!(
10326        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10327        1,
10328        "should have 1 workspace (the mixed workspace)"
10329    );
10330
10331    // Archive the feature-b thread.
10332    let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10333    sidebar.update_in(cx, |sidebar, window, cx| {
10334        sidebar.archive_thread(&fb_session_id, window, cx);
10335    });
10336
10337    cx.run_until_parked();
10338
10339    // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10340    assert_eq!(
10341        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10342        1,
10343        "mixed workspace should be preserved"
10344    );
10345
10346    // Only the feature-b editor item should have been closed.
10347    let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10348        ws.panes()
10349            .iter()
10350            .flat_map(|pane| {
10351                pane.read(cx)
10352                    .items()
10353                    .filter_map(|item| item.project_path(cx))
10354            })
10355            .collect()
10356    });
10357    assert!(
10358        open_paths_after
10359            .iter()
10360            .any(|pp| pp.worktree_id == main_repo_wt_id),
10361        "main-repo file should still be open"
10362    );
10363    assert!(
10364        !open_paths_after
10365            .iter()
10366            .any(|pp| pp.worktree_id == feature_b_wt_id),
10367        "feature-b file should have been closed"
10368    );
10369}