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