sidebar_tests.rs

    1use super::*;
    2use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
    3use agent::ThreadStore;
    4use agent_ui::{
    5    ThreadId,
    6    test_support::{active_session_id, open_thread_with_connection, send_message},
    7    thread_metadata_store::{ThreadMetadata, WorktreePaths},
    8};
    9use chrono::DateTime;
   10use fs::{FakeFs, Fs};
   11use gpui::TestAppContext;
   12use pretty_assertions::assert_eq;
   13use project::AgentId;
   14use settings::SettingsStore;
   15use std::{
   16    path::{Path, PathBuf},
   17    sync::Arc,
   18};
   19use util::{path_list::PathList, rel_path::rel_path};
   20
   21fn init_test(cx: &mut TestAppContext) {
   22    cx.update(|cx| {
   23        let settings_store = SettingsStore::test(cx);
   24        cx.set_global(settings_store);
   25        theme_settings::init(theme::LoadThemes::JustBase, cx);
   26        editor::init(cx);
   27        ThreadStore::init_global(cx);
   28        ThreadMetadataStore::init_global(cx);
   29        language_model::LanguageModelRegistry::test(cx);
   30        prompt_store::init(cx);
   31    });
   32}
   33
   34#[track_caller]
   35fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
   36    let active = sidebar.active_entry.as_ref();
   37    let matches = active.is_some_and(|entry| {
   38        // Match by session_id directly on active_entry.
   39        entry.session_id.as_ref() == Some(session_id)
   40            // Or match by finding the thread in sidebar entries.
   41            || sidebar.contents.entries.iter().any(|list_entry| {
   42                matches!(list_entry, ListEntry::Thread(t)
   43                    if t.metadata.session_id.as_ref() == Some(session_id)
   44                        && entry.matches_entry(list_entry))
   45            })
   46    });
   47    assert!(
   48        matches,
   49        "{msg}: expected active_entry for session {session_id:?}, got {:?}",
   50        active,
   51    );
   52}
   53
   54#[track_caller]
   55fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   56    let thread_id = sidebar
   57        .contents
   58        .entries
   59        .iter()
   60        .find_map(|entry| match entry {
   61            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
   62                Some(t.metadata.thread_id)
   63            }
   64            _ => None,
   65        });
   66    match thread_id {
   67        Some(tid) => {
   68            matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid)
   69        }
   70        // Thread not in sidebar entries — can't confirm it's active.
   71        None => false,
   72    }
   73}
   74
   75#[track_caller]
   76fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
   77    assert!(
   78        matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace),
   79        "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
   80        workspace.entity_id(),
   81        sidebar.active_entry,
   82    );
   83}
   84
   85fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   86    sidebar
   87        .contents
   88        .entries
   89        .iter()
   90        .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
   91}
   92
   93#[track_caller]
   94fn assert_remote_project_integration_sidebar_state(
   95    sidebar: &mut Sidebar,
   96    main_thread_id: &acp::SessionId,
   97    remote_thread_id: &acp::SessionId,
   98) {
   99    let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
  100        if let ListEntry::ProjectHeader { label, .. } = entry {
  101            Some(label.as_ref())
  102        } else {
  103            None
  104        }
  105    });
  106
  107    let Some(project_header) = project_headers.next() else {
  108        panic!("expected exactly one sidebar project header named `project`, found none");
  109    };
  110    assert_eq!(
  111        project_header, "project",
  112        "expected the only sidebar project header to be `project`"
  113    );
  114    if let Some(unexpected_header) = project_headers.next() {
  115        panic!(
  116            "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
  117        );
  118    }
  119
  120    let mut saw_main_thread = false;
  121    let mut saw_remote_thread = false;
  122    for entry in &sidebar.contents.entries {
  123        match entry {
  124            ListEntry::ProjectHeader { label, .. } => {
  125                assert_eq!(
  126                    label.as_ref(),
  127                    "project",
  128                    "expected the only sidebar project header to be `project`"
  129                );
  130            }
  131            ListEntry::Thread(thread)
  132                if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
  133            {
  134                saw_main_thread = true;
  135            }
  136            ListEntry::Thread(thread)
  137                if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
  138            {
  139                saw_remote_thread = true;
  140            }
  141            ListEntry::Thread(thread) if thread.is_draft => {}
  142            ListEntry::Thread(thread) => {
  143                let title = thread.metadata.display_title();
  144                panic!(
  145                    "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
  146                    title
  147                );
  148            }
  149            ListEntry::ViewMore { .. } => {
  150                panic!(
  151                    "unexpected `View More` entry while simulating remote project integration flicker"
  152                );
  153            }
  154        }
  155    }
  156
  157    assert!(
  158        saw_main_thread,
  159        "expected the sidebar to keep showing `Main Thread` under `project`"
  160    );
  161    assert!(
  162        saw_remote_thread,
  163        "expected the sidebar to keep showing `Worktree Thread` under `project`"
  164    );
  165}
  166
  167async fn init_test_project(
  168    worktree_path: &str,
  169    cx: &mut TestAppContext,
  170) -> Entity<project::Project> {
  171    init_test(cx);
  172    let fs = FakeFs::new(cx.executor());
  173    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
  174        .await;
  175    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
  176    project::Project::test(fs, [worktree_path.as_ref()], cx).await
  177}
  178
  179fn setup_sidebar(
  180    multi_workspace: &Entity<MultiWorkspace>,
  181    cx: &mut gpui::VisualTestContext,
  182) -> Entity<Sidebar> {
  183    let sidebar = setup_sidebar_closed(multi_workspace, cx);
  184    multi_workspace.update_in(cx, |mw, window, cx| {
  185        mw.toggle_sidebar(window, cx);
  186    });
  187    cx.run_until_parked();
  188    sidebar
  189}
  190
  191fn setup_sidebar_closed(
  192    multi_workspace: &Entity<MultiWorkspace>,
  193    cx: &mut gpui::VisualTestContext,
  194) -> Entity<Sidebar> {
  195    let multi_workspace = multi_workspace.clone();
  196    let sidebar =
  197        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  198    multi_workspace.update(cx, |mw, cx| {
  199        mw.register_sidebar(sidebar.clone(), cx);
  200    });
  201    cx.run_until_parked();
  202    sidebar
  203}
  204
  205async fn save_n_test_threads(
  206    count: u32,
  207    project: &Entity<project::Project>,
  208    cx: &mut gpui::VisualTestContext,
  209) {
  210    for i in 0..count {
  211        save_thread_metadata(
  212            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
  213            Some(format!("Thread {}", i + 1).into()),
  214            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
  215            None,
  216            project,
  217            cx,
  218        )
  219    }
  220    cx.run_until_parked();
  221}
  222
  223async fn save_test_thread_metadata(
  224    session_id: &acp::SessionId,
  225    project: &Entity<project::Project>,
  226    cx: &mut TestAppContext,
  227) {
  228    save_thread_metadata(
  229        session_id.clone(),
  230        Some("Test".into()),
  231        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  232        None,
  233        project,
  234        cx,
  235    )
  236}
  237
  238async fn save_named_thread_metadata(
  239    session_id: &str,
  240    title: &str,
  241    project: &Entity<project::Project>,
  242    cx: &mut gpui::VisualTestContext,
  243) {
  244    save_thread_metadata(
  245        acp::SessionId::new(Arc::from(session_id)),
  246        Some(SharedString::from(title.to_string())),
  247        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  248        None,
  249        project,
  250        cx,
  251    );
  252    cx.run_until_parked();
  253}
  254
  255fn save_thread_metadata(
  256    session_id: acp::SessionId,
  257    title: Option<SharedString>,
  258    updated_at: DateTime<Utc>,
  259    created_at: Option<DateTime<Utc>>,
  260    project: &Entity<project::Project>,
  261    cx: &mut TestAppContext,
  262) {
  263    cx.update(|cx| {
  264        let worktree_paths = project.read(cx).worktree_paths(cx);
  265        let remote_connection = project.read(cx).remote_connection_options(cx);
  266        let thread_id = ThreadMetadataStore::global(cx)
  267            .read(cx)
  268            .entries()
  269            .find(|e| e.session_id.as_ref() == Some(&session_id))
  270            .map(|e| e.thread_id)
  271            .unwrap_or_else(ThreadId::new);
  272        let metadata = ThreadMetadata {
  273            thread_id,
  274            session_id: Some(session_id),
  275            agent_id: agent::ZED_AGENT_ID.clone(),
  276            title,
  277            updated_at,
  278            created_at,
  279            worktree_paths,
  280            archived: false,
  281            remote_connection,
  282        };
  283        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  284    });
  285    cx.run_until_parked();
  286}
  287
  288fn save_thread_metadata_with_main_paths(
  289    session_id: &str,
  290    title: &str,
  291    folder_paths: PathList,
  292    main_worktree_paths: PathList,
  293    updated_at: DateTime<Utc>,
  294    cx: &mut TestAppContext,
  295) {
  296    let session_id = acp::SessionId::new(Arc::from(session_id));
  297    let title = SharedString::from(title.to_string());
  298    let thread_id = cx.update(|cx| {
  299        ThreadMetadataStore::global(cx)
  300            .read(cx)
  301            .entries()
  302            .find(|e| e.session_id.as_ref() == Some(&session_id))
  303            .map(|e| e.thread_id)
  304            .unwrap_or_else(ThreadId::new)
  305    });
  306    let metadata = ThreadMetadata {
  307        thread_id,
  308        session_id: Some(session_id),
  309        agent_id: agent::ZED_AGENT_ID.clone(),
  310        title: Some(title),
  311        updated_at,
  312        created_at: None,
  313        worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
  314        archived: false,
  315        remote_connection: None,
  316    };
  317    cx.update(|cx| {
  318        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  319    });
  320    cx.run_until_parked();
  321}
  322
  323fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
  324    sidebar.update_in(cx, |_, window, cx| {
  325        cx.focus_self(window);
  326    });
  327    cx.run_until_parked();
  328}
  329
  330fn request_test_tool_authorization(
  331    thread: &Entity<AcpThread>,
  332    tool_call_id: &str,
  333    option_id: &str,
  334    cx: &mut gpui::VisualTestContext,
  335) {
  336    let tool_call_id = acp::ToolCallId::new(tool_call_id);
  337    let label = format!("Tool {tool_call_id}");
  338    let option_id = acp::PermissionOptionId::new(option_id);
  339    let _authorization_task = cx.update(|_, cx| {
  340        thread.update(cx, |thread, cx| {
  341            thread
  342                .request_tool_call_authorization(
  343                    acp::ToolCall::new(tool_call_id, label)
  344                        .kind(acp::ToolKind::Edit)
  345                        .into(),
  346                    PermissionOptions::Flat(vec![acp::PermissionOption::new(
  347                        option_id,
  348                        "Allow",
  349                        acp::PermissionOptionKind::AllowOnce,
  350                    )]),
  351                    cx,
  352                )
  353                .unwrap()
  354        })
  355    });
  356    cx.run_until_parked();
  357}
  358
  359fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
  360    let mut seen = Vec::new();
  361    let mut chips = Vec::new();
  362    for wt in worktrees {
  363        if wt.kind == ui::WorktreeKind::Main {
  364            continue;
  365        }
  366        if !seen.contains(&wt.name) {
  367            seen.push(wt.name.clone());
  368            chips.push(format!("{{{}}}", wt.name));
  369        }
  370    }
  371    if chips.is_empty() {
  372        String::new()
  373    } else {
  374        format!(" {}", chips.join(", "))
  375    }
  376}
  377
  378fn visible_entries_as_strings(
  379    sidebar: &Entity<Sidebar>,
  380    cx: &mut gpui::VisualTestContext,
  381) -> Vec<String> {
  382    sidebar.read_with(cx, |sidebar, cx| {
  383        sidebar
  384            .contents
  385            .entries
  386            .iter()
  387            .enumerate()
  388            .map(|(ix, entry)| {
  389                let selected = if sidebar.selection == Some(ix) {
  390                    "  <== selected"
  391                } else {
  392                    ""
  393                };
  394                match entry {
  395                    ListEntry::ProjectHeader {
  396                        label,
  397                        key,
  398                        highlight_positions: _,
  399                        ..
  400                    } => {
  401                        let icon = if sidebar.is_group_collapsed(key, cx) {
  402                            ">"
  403                        } else {
  404                            "v"
  405                        };
  406                        format!("{} [{}]{}", icon, label, selected)
  407                    }
  408                    ListEntry::Thread(thread) => {
  409                        let title = thread.metadata.display_title();
  410                        let worktree = format_linked_worktree_chips(&thread.worktrees);
  411
  412                        if thread.is_draft {
  413                            let is_active = sidebar
  414                                .active_entry
  415                                .as_ref()
  416                                .is_some_and(|e| e.matches_entry(entry));
  417                            let active_marker = if is_active { " *" } else { "" };
  418                            format!("  [~ Draft{worktree}]{active_marker}{selected}")
  419                        } else {
  420                            let live = if thread.is_live { " *" } else { "" };
  421                            let status_str = match thread.status {
  422                                AgentThreadStatus::Running => " (running)",
  423                                AgentThreadStatus::Error => " (error)",
  424                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
  425                                _ => "",
  426                            };
  427                            let notified = if sidebar
  428                                .contents
  429                                .is_thread_notified(&thread.metadata.thread_id)
  430                            {
  431                                " (!)"
  432                            } else {
  433                                ""
  434                            };
  435                            format!("  {title}{worktree}{live}{status_str}{notified}{selected}")
  436                        }
  437                    }
  438                    ListEntry::ViewMore {
  439                        is_fully_expanded, ..
  440                    } => {
  441                        if *is_fully_expanded {
  442                            format!("  - Collapse{}", selected)
  443                        } else {
  444                            format!("  + View More{}", selected)
  445                        }
  446                    }
  447                }
  448            })
  449            .collect()
  450    })
  451}
  452
  453#[gpui::test]
  454async fn test_serialization_round_trip(cx: &mut TestAppContext) {
  455    let project = init_test_project("/my-project", cx).await;
  456    let (multi_workspace, cx) =
  457        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  458    let sidebar = setup_sidebar(&multi_workspace, cx);
  459
  460    save_n_test_threads(3, &project, cx).await;
  461
  462    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  463
  464    // Set a custom width, collapse the group, and expand "View More".
  465    sidebar.update_in(cx, |sidebar, window, cx| {
  466        sidebar.set_width(Some(px(420.0)), cx);
  467        sidebar.toggle_collapse(&project_group_key, window, cx);
  468    });
  469    cx.run_until_parked();
  470
  471    // Capture the serialized state from the first sidebar.
  472    let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
  473    let serialized = serialized.expect("serialized_state should return Some");
  474
  475    // Create a fresh sidebar and restore into it.
  476    let sidebar2 =
  477        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  478    cx.run_until_parked();
  479
  480    sidebar2.update_in(cx, |sidebar, window, cx| {
  481        sidebar.restore_serialized_state(&serialized, window, cx);
  482    });
  483    cx.run_until_parked();
  484
  485    // Assert all serialized fields match.
  486    let width1 = sidebar.read_with(cx, |s, _| s.width);
  487    let width2 = sidebar2.read_with(cx, |s, _| s.width);
  488
  489    assert_eq!(width1, width2);
  490    assert_eq!(width1, px(420.0));
  491}
  492
  493#[gpui::test]
  494async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
  495    // A regression test to ensure that restoring a serialized archive view does not panic.
  496    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  497    let (multi_workspace, cx) =
  498        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  499    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  500    cx.update(|_window, cx| {
  501        AgentRegistryStore::init_test_global(cx, vec![]);
  502    });
  503
  504    let serialized = serde_json::to_string(&SerializedSidebar {
  505        width: Some(400.0),
  506        active_view: SerializedSidebarView::Archive,
  507    })
  508    .expect("serialization should succeed");
  509
  510    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
  511        if let Some(sidebar) = multi_workspace.sidebar() {
  512            sidebar.restore_serialized_state(&serialized, window, cx);
  513        }
  514    });
  515    cx.run_until_parked();
  516
  517    // After the deferred `show_archive` runs, the view should be Archive.
  518    sidebar.read_with(cx, |sidebar, _cx| {
  519        assert!(
  520            matches!(sidebar.view, SidebarView::Archive(_)),
  521            "expected sidebar view to be Archive after restore, got ThreadList"
  522        );
  523    });
  524}
  525
  526#[test]
  527fn test_clean_mention_links() {
  528    // Simple mention link
  529    assert_eq!(
  530        Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
  531        "check @Button.tsx"
  532    );
  533
  534    // Multiple mention links
  535    assert_eq!(
  536        Sidebar::clean_mention_links(
  537            "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
  538        ),
  539        "look at @foo.rs and @bar.rs"
  540    );
  541
  542    // No mention links — passthrough
  543    assert_eq!(
  544        Sidebar::clean_mention_links("plain text with no mentions"),
  545        "plain text with no mentions"
  546    );
  547
  548    // Incomplete link syntax — preserved as-is
  549    assert_eq!(
  550        Sidebar::clean_mention_links("broken [@mention without closing"),
  551        "broken [@mention without closing"
  552    );
  553
  554    // Regular markdown link (no @) — not touched
  555    assert_eq!(
  556        Sidebar::clean_mention_links("see [docs](https://example.com)"),
  557        "see [docs](https://example.com)"
  558    );
  559
  560    // Empty input
  561    assert_eq!(Sidebar::clean_mention_links(""), "");
  562}
  563
  564#[gpui::test]
  565async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
  566    let project = init_test_project("/my-project", cx).await;
  567    let (multi_workspace, cx) =
  568        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  569    let sidebar = setup_sidebar(&multi_workspace, cx);
  570
  571    let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
  572    let weak_sidebar = sidebar.downgrade();
  573    let weak_multi_workspace = multi_workspace.downgrade();
  574
  575    drop(sidebar);
  576    drop(multi_workspace);
  577    cx.update(|window, _cx| window.remove_window());
  578    cx.run_until_parked();
  579
  580    weak_multi_workspace.assert_released();
  581    weak_sidebar.assert_released();
  582    weak_workspace.assert_released();
  583}
  584
  585#[gpui::test]
  586async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
  587    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  588    let (multi_workspace, cx) =
  589        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  590    let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  591
  592    assert_eq!(
  593        visible_entries_as_strings(&_sidebar, cx),
  594        vec!["v [my-project]"]
  595    );
  596}
  597
  598#[gpui::test]
  599async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
  600    let project = init_test_project("/my-project", cx).await;
  601    let (multi_workspace, cx) =
  602        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  603    let sidebar = setup_sidebar(&multi_workspace, cx);
  604
  605    save_thread_metadata(
  606        acp::SessionId::new(Arc::from("thread-1")),
  607        Some("Fix crash in project panel".into()),
  608        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
  609        None,
  610        &project,
  611        cx,
  612    );
  613
  614    save_thread_metadata(
  615        acp::SessionId::new(Arc::from("thread-2")),
  616        Some("Add inline diff view".into()),
  617        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
  618        None,
  619        &project,
  620        cx,
  621    );
  622    cx.run_until_parked();
  623
  624    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  625    cx.run_until_parked();
  626
  627    assert_eq!(
  628        visible_entries_as_strings(&sidebar, cx),
  629        vec![
  630            //
  631            "v [my-project]",
  632            "  Fix crash in project panel",
  633            "  Add inline diff view",
  634        ]
  635    );
  636}
  637
  638#[gpui::test]
  639async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
  640    let project = init_test_project("/project-a", 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    // Single workspace with a thread
  646    save_thread_metadata(
  647        acp::SessionId::new(Arc::from("thread-a1")),
  648        Some("Thread A1".into()),
  649        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  650        None,
  651        &project,
  652        cx,
  653    );
  654    cx.run_until_parked();
  655
  656    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  657    cx.run_until_parked();
  658
  659    assert_eq!(
  660        visible_entries_as_strings(&sidebar, cx),
  661        vec![
  662            //
  663            "v [project-a]",
  664            "  Thread A1",
  665        ]
  666    );
  667
  668    // Add a second workspace
  669    multi_workspace.update_in(cx, |mw, window, cx| {
  670        mw.create_test_workspace(window, cx).detach();
  671    });
  672    cx.run_until_parked();
  673
  674    assert_eq!(
  675        visible_entries_as_strings(&sidebar, cx),
  676        vec![
  677            //
  678            "v [project-a]",
  679            "  Thread A1",
  680        ]
  681    );
  682}
  683
  684#[gpui::test]
  685async fn test_view_more_pagination(cx: &mut TestAppContext) {
  686    let project = init_test_project("/my-project", cx).await;
  687    let (multi_workspace, cx) =
  688        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  689    let sidebar = setup_sidebar(&multi_workspace, cx);
  690
  691    save_n_test_threads(12, &project, cx).await;
  692
  693    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  694    cx.run_until_parked();
  695
  696    assert_eq!(
  697        visible_entries_as_strings(&sidebar, cx),
  698        vec![
  699            //
  700            "v [my-project]",
  701            "  Thread 12",
  702            "  Thread 11",
  703            "  Thread 10",
  704            "  Thread 9",
  705            "  Thread 8",
  706            "  + View More",
  707        ]
  708    );
  709}
  710
  711#[gpui::test]
  712async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
  713    let project = init_test_project("/my-project", cx).await;
  714    let (multi_workspace, cx) =
  715        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  716    let sidebar = setup_sidebar(&multi_workspace, cx);
  717
  718    // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
  719    save_n_test_threads(17, &project, cx).await;
  720
  721    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  722
  723    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  724    cx.run_until_parked();
  725
  726    // Initially shows 5 threads + View More
  727    let entries = visible_entries_as_strings(&sidebar, cx);
  728    assert_eq!(entries.len(), 7); // header + 5 threads + View More
  729    assert!(entries.iter().any(|e| e.contains("View More")));
  730
  731    // Focus and navigate to View More, then confirm to expand by one batch
  732    focus_sidebar(&sidebar, cx);
  733    for _ in 0..7 {
  734        cx.dispatch_action(SelectNext);
  735    }
  736    cx.dispatch_action(Confirm);
  737    cx.run_until_parked();
  738
  739    // Now shows 10 threads + View More
  740    let entries = visible_entries_as_strings(&sidebar, cx);
  741    assert_eq!(entries.len(), 12); // header + 10 threads + View More
  742    assert!(entries.iter().any(|e| e.contains("View More")));
  743
  744    // Expand again by one batch
  745    sidebar.update_in(cx, |s, _window, cx| {
  746        s.expand_thread_group(&project_group_key, cx);
  747    });
  748    cx.run_until_parked();
  749
  750    // Now shows 15 threads + View More
  751    let entries = visible_entries_as_strings(&sidebar, cx);
  752    assert_eq!(entries.len(), 17); // header + 15 threads + View More
  753    assert!(entries.iter().any(|e| e.contains("View More")));
  754
  755    // Expand one more time - should show all 17 threads with Collapse button
  756    sidebar.update_in(cx, |s, _window, cx| {
  757        s.expand_thread_group(&project_group_key, cx);
  758    });
  759    cx.run_until_parked();
  760
  761    // All 17 threads shown with Collapse button
  762    let entries = visible_entries_as_strings(&sidebar, cx);
  763    assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
  764    assert!(!entries.iter().any(|e| e.contains("View More")));
  765    assert!(entries.iter().any(|e| e.contains("Collapse")));
  766
  767    // Click collapse - should go back to showing 5 threads
  768    sidebar.update_in(cx, |s, _window, cx| {
  769        s.reset_thread_group_expansion(&project_group_key, cx);
  770    });
  771    cx.run_until_parked();
  772
  773    // Back to initial state: 5 threads + View More
  774    let entries = visible_entries_as_strings(&sidebar, cx);
  775    assert_eq!(entries.len(), 7); // header + 5 threads + View More
  776    assert!(entries.iter().any(|e| e.contains("View More")));
  777}
  778
  779#[gpui::test]
  780async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
  781    let project = init_test_project("/my-project", cx).await;
  782    let (multi_workspace, cx) =
  783        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  784    let sidebar = setup_sidebar(&multi_workspace, cx);
  785
  786    save_n_test_threads(1, &project, cx).await;
  787
  788    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  789
  790    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  791    cx.run_until_parked();
  792
  793    assert_eq!(
  794        visible_entries_as_strings(&sidebar, cx),
  795        vec![
  796            //
  797            "v [my-project]",
  798            "  Thread 1",
  799        ]
  800    );
  801
  802    // Collapse
  803    sidebar.update_in(cx, |s, window, cx| {
  804        s.toggle_collapse(&project_group_key, window, cx);
  805    });
  806    cx.run_until_parked();
  807
  808    assert_eq!(
  809        visible_entries_as_strings(&sidebar, cx),
  810        vec![
  811            //
  812            "> [my-project]",
  813        ]
  814    );
  815
  816    // Expand
  817    sidebar.update_in(cx, |s, window, cx| {
  818        s.toggle_collapse(&project_group_key, window, cx);
  819    });
  820    cx.run_until_parked();
  821
  822    assert_eq!(
  823        visible_entries_as_strings(&sidebar, cx),
  824        vec![
  825            //
  826            "v [my-project]",
  827            "  Thread 1",
  828        ]
  829    );
  830}
  831
  832#[gpui::test]
  833async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
  834    // When a worktree is added to a project, the project group key changes.
  835    // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
  836    // UI state must survive the key change.
  837    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
  838    let (multi_workspace, cx) =
  839        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  840    let sidebar = setup_sidebar(&multi_workspace, cx);
  841
  842    save_n_test_threads(2, &project, cx).await;
  843    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  844    cx.run_until_parked();
  845
  846    assert_eq!(
  847        visible_entries_as_strings(&sidebar, cx),
  848        vec!["v [project-a]", "  Thread 2", "  Thread 1",]
  849    );
  850
  851    // Collapse the group.
  852    let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  853    sidebar.update_in(cx, |sidebar, window, cx| {
  854        sidebar.toggle_collapse(&old_key, window, cx);
  855    });
  856    cx.run_until_parked();
  857
  858    assert_eq!(
  859        visible_entries_as_strings(&sidebar, cx),
  860        vec!["> [project-a]"]
  861    );
  862
  863    // Add a second worktree — the key changes from [/project-a] to
  864    // [/project-a, /project-b].
  865    project
  866        .update(cx, |project, cx| {
  867            project.find_or_create_worktree("/project-b", true, cx)
  868        })
  869        .await
  870        .expect("should add worktree");
  871    cx.run_until_parked();
  872
  873    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  874    cx.run_until_parked();
  875
  876    // The group should still be collapsed under the new key.
  877    assert_eq!(
  878        visible_entries_as_strings(&sidebar, cx),
  879        vec!["> [project-a, project-b]"]
  880    );
  881}
  882
  883#[gpui::test]
  884async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
  885    use workspace::ProjectGroup;
  886
  887    let project = init_test_project("/my-project", cx).await;
  888    let (multi_workspace, cx) =
  889        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  890    let sidebar = setup_sidebar(&multi_workspace, cx);
  891
  892    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
  893    let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
  894    let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
  895
  896    // Set the collapsed group state through multi_workspace
  897    multi_workspace.update(cx, |mw, _cx| {
  898        mw.test_add_project_group(ProjectGroup {
  899            key: ProjectGroupKey::new(None, collapsed_path.clone()),
  900            workspaces: Vec::new(),
  901            expanded: false,
  902            visible_thread_count: None,
  903        });
  904    });
  905
  906    sidebar.update_in(cx, |s, _window, _cx| {
  907        let notified_thread_id = ThreadId::new();
  908        s.contents.notified_threads.insert(notified_thread_id);
  909        s.contents.entries = vec![
  910            // Expanded project header
  911            ListEntry::ProjectHeader {
  912                key: ProjectGroupKey::new(None, expanded_path.clone()),
  913                label: "expanded-project".into(),
  914                highlight_positions: Vec::new(),
  915                has_running_threads: false,
  916                waiting_thread_count: 0,
  917                is_active: true,
  918                has_threads: true,
  919            },
  920            ListEntry::Thread(ThreadEntry {
  921                metadata: ThreadMetadata {
  922                    thread_id: ThreadId::new(),
  923                    session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
  924                    agent_id: AgentId::new("zed-agent"),
  925                    worktree_paths: WorktreePaths::default(),
  926                    title: Some("Completed thread".into()),
  927                    updated_at: Utc::now(),
  928                    created_at: Some(Utc::now()),
  929                    archived: false,
  930                    remote_connection: None,
  931                },
  932                icon: IconName::ZedAgent,
  933                icon_from_external_svg: None,
  934                status: AgentThreadStatus::Completed,
  935                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  936                is_live: false,
  937                is_background: false,
  938                is_title_generating: false,
  939                is_draft: false,
  940                highlight_positions: Vec::new(),
  941                worktrees: Vec::new(),
  942                diff_stats: DiffStats::default(),
  943            }),
  944            // Active thread with Running status
  945            ListEntry::Thread(ThreadEntry {
  946                metadata: ThreadMetadata {
  947                    thread_id: ThreadId::new(),
  948                    session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
  949                    agent_id: AgentId::new("zed-agent"),
  950                    worktree_paths: WorktreePaths::default(),
  951                    title: Some("Running thread".into()),
  952                    updated_at: Utc::now(),
  953                    created_at: Some(Utc::now()),
  954                    archived: false,
  955                    remote_connection: None,
  956                },
  957                icon: IconName::ZedAgent,
  958                icon_from_external_svg: None,
  959                status: AgentThreadStatus::Running,
  960                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  961                is_live: true,
  962                is_background: false,
  963                is_title_generating: false,
  964                is_draft: false,
  965                highlight_positions: Vec::new(),
  966                worktrees: Vec::new(),
  967                diff_stats: DiffStats::default(),
  968            }),
  969            // Active thread with Error status
  970            ListEntry::Thread(ThreadEntry {
  971                metadata: ThreadMetadata {
  972                    thread_id: ThreadId::new(),
  973                    session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
  974                    agent_id: AgentId::new("zed-agent"),
  975                    worktree_paths: WorktreePaths::default(),
  976                    title: Some("Error thread".into()),
  977                    updated_at: Utc::now(),
  978                    created_at: Some(Utc::now()),
  979                    archived: false,
  980                    remote_connection: None,
  981                },
  982                icon: IconName::ZedAgent,
  983                icon_from_external_svg: None,
  984                status: AgentThreadStatus::Error,
  985                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  986                is_live: true,
  987                is_background: false,
  988                is_title_generating: false,
  989                is_draft: false,
  990                highlight_positions: Vec::new(),
  991                worktrees: Vec::new(),
  992                diff_stats: DiffStats::default(),
  993            }),
  994            // Thread with WaitingForConfirmation status, not active
  995            // remote_connection: None,
  996            ListEntry::Thread(ThreadEntry {
  997                metadata: ThreadMetadata {
  998                    thread_id: ThreadId::new(),
  999                    session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
 1000                    agent_id: AgentId::new("zed-agent"),
 1001                    worktree_paths: WorktreePaths::default(),
 1002                    title: Some("Waiting thread".into()),
 1003                    updated_at: Utc::now(),
 1004                    created_at: Some(Utc::now()),
 1005                    archived: false,
 1006                    remote_connection: None,
 1007                },
 1008                icon: IconName::ZedAgent,
 1009                icon_from_external_svg: None,
 1010                status: AgentThreadStatus::WaitingForConfirmation,
 1011                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 1012                is_live: false,
 1013                is_background: false,
 1014                is_title_generating: false,
 1015                is_draft: false,
 1016                highlight_positions: Vec::new(),
 1017                worktrees: Vec::new(),
 1018                diff_stats: DiffStats::default(),
 1019            }),
 1020            // Background thread that completed (should show notification)
 1021            // remote_connection: None,
 1022            ListEntry::Thread(ThreadEntry {
 1023                metadata: ThreadMetadata {
 1024                    thread_id: notified_thread_id,
 1025                    session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
 1026                    agent_id: AgentId::new("zed-agent"),
 1027                    worktree_paths: WorktreePaths::default(),
 1028                    title: Some("Notified thread".into()),
 1029                    updated_at: Utc::now(),
 1030                    created_at: Some(Utc::now()),
 1031                    archived: false,
 1032                    remote_connection: None,
 1033                },
 1034                icon: IconName::ZedAgent,
 1035                icon_from_external_svg: None,
 1036                status: AgentThreadStatus::Completed,
 1037                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 1038                is_live: true,
 1039                is_background: true,
 1040                is_title_generating: false,
 1041                is_draft: false,
 1042                highlight_positions: Vec::new(),
 1043                worktrees: Vec::new(),
 1044                diff_stats: DiffStats::default(),
 1045            }),
 1046            // View More entry
 1047            ListEntry::ViewMore {
 1048                key: ProjectGroupKey::new(None, expanded_path.clone()),
 1049                is_fully_expanded: false,
 1050            },
 1051            // Collapsed project header
 1052            ListEntry::ProjectHeader {
 1053                key: ProjectGroupKey::new(None, collapsed_path.clone()),
 1054                label: "collapsed-project".into(),
 1055                highlight_positions: Vec::new(),
 1056                has_running_threads: false,
 1057                waiting_thread_count: 0,
 1058                is_active: false,
 1059                has_threads: false,
 1060            },
 1061        ];
 1062
 1063        // Select the Running thread (index 2)
 1064        s.selection = Some(2);
 1065    });
 1066
 1067    assert_eq!(
 1068        visible_entries_as_strings(&sidebar, cx),
 1069        vec![
 1070            //
 1071            "v [expanded-project]",
 1072            "  Completed thread",
 1073            "  Running thread * (running)  <== selected",
 1074            "  Error thread * (error)",
 1075            "  Waiting thread (waiting)",
 1076            "  Notified thread * (!)",
 1077            "  + View More",
 1078            "> [collapsed-project]",
 1079        ]
 1080    );
 1081
 1082    // Move selection to the collapsed header
 1083    sidebar.update_in(cx, |s, _window, _cx| {
 1084        s.selection = Some(7);
 1085    });
 1086
 1087    assert_eq!(
 1088        visible_entries_as_strings(&sidebar, cx).last().cloned(),
 1089        Some("> [collapsed-project]  <== selected".to_string()),
 1090    );
 1091
 1092    // Clear selection
 1093    sidebar.update_in(cx, |s, _window, _cx| {
 1094        s.selection = None;
 1095    });
 1096
 1097    // No entry should have the selected marker
 1098    let entries = visible_entries_as_strings(&sidebar, cx);
 1099    for entry in &entries {
 1100        assert!(
 1101            !entry.contains("<== selected"),
 1102            "unexpected selection marker in: {}",
 1103            entry
 1104        );
 1105    }
 1106}
 1107
 1108#[gpui::test]
 1109async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
 1110    let project = init_test_project("/my-project", cx).await;
 1111    let (multi_workspace, cx) =
 1112        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1113    let sidebar = setup_sidebar(&multi_workspace, cx);
 1114
 1115    save_n_test_threads(3, &project, cx).await;
 1116
 1117    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1118    cx.run_until_parked();
 1119
 1120    // Entries: [header, thread3, thread2, thread1]
 1121    // Focusing the sidebar does not set a selection; select_next/select_previous
 1122    // handle None gracefully by starting from the first or last entry.
 1123    focus_sidebar(&sidebar, cx);
 1124    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1125
 1126    // First SelectNext from None starts at index 0
 1127    cx.dispatch_action(SelectNext);
 1128    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1129
 1130    // Move down through remaining entries
 1131    cx.dispatch_action(SelectNext);
 1132    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1133
 1134    cx.dispatch_action(SelectNext);
 1135    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1136
 1137    cx.dispatch_action(SelectNext);
 1138    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1139
 1140    // At the end, wraps back to first entry
 1141    cx.dispatch_action(SelectNext);
 1142    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1143
 1144    // Navigate back to the end
 1145    cx.dispatch_action(SelectNext);
 1146    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1147    cx.dispatch_action(SelectNext);
 1148    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1149    cx.dispatch_action(SelectNext);
 1150    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1151
 1152    // Move back up
 1153    cx.dispatch_action(SelectPrevious);
 1154    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1155
 1156    cx.dispatch_action(SelectPrevious);
 1157    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1158
 1159    cx.dispatch_action(SelectPrevious);
 1160    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1161
 1162    // At the top, selection clears (focus returns to editor)
 1163    cx.dispatch_action(SelectPrevious);
 1164    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1165}
 1166
 1167#[gpui::test]
 1168async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
 1169    let project = init_test_project("/my-project", cx).await;
 1170    let (multi_workspace, cx) =
 1171        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1172    let sidebar = setup_sidebar(&multi_workspace, cx);
 1173
 1174    save_n_test_threads(3, &project, cx).await;
 1175    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1176    cx.run_until_parked();
 1177
 1178    focus_sidebar(&sidebar, cx);
 1179
 1180    // SelectLast jumps to the end
 1181    cx.dispatch_action(SelectLast);
 1182    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1183
 1184    // SelectFirst jumps to the beginning
 1185    cx.dispatch_action(SelectFirst);
 1186    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1187}
 1188
 1189#[gpui::test]
 1190async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
 1191    let project = init_test_project("/my-project", cx).await;
 1192    let (multi_workspace, cx) =
 1193        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1194    let sidebar = setup_sidebar(&multi_workspace, cx);
 1195
 1196    // Initially no selection
 1197    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1198
 1199    // Open the sidebar so it's rendered, then focus it to trigger focus_in.
 1200    // focus_in no longer sets a default selection.
 1201    focus_sidebar(&sidebar, cx);
 1202    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1203
 1204    // Manually set a selection, blur, then refocus — selection should be preserved
 1205    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1206        sidebar.selection = Some(0);
 1207    });
 1208
 1209    cx.update(|window, _cx| {
 1210        window.blur();
 1211    });
 1212    cx.run_until_parked();
 1213
 1214    sidebar.update_in(cx, |_, window, cx| {
 1215        cx.focus_self(window);
 1216    });
 1217    cx.run_until_parked();
 1218    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1219}
 1220
 1221#[gpui::test]
 1222async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
 1223    let project = init_test_project("/my-project", cx).await;
 1224    let (multi_workspace, cx) =
 1225        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1226    let sidebar = setup_sidebar(&multi_workspace, cx);
 1227
 1228    save_n_test_threads(1, &project, cx).await;
 1229    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1230    cx.run_until_parked();
 1231
 1232    assert_eq!(
 1233        visible_entries_as_strings(&sidebar, cx),
 1234        vec![
 1235            //
 1236            "v [my-project]",
 1237            "  Thread 1",
 1238        ]
 1239    );
 1240
 1241    // Focus the sidebar and select the header
 1242    focus_sidebar(&sidebar, cx);
 1243    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1244        sidebar.selection = Some(0);
 1245    });
 1246
 1247    // Confirm on project header collapses the group
 1248    cx.dispatch_action(Confirm);
 1249    cx.run_until_parked();
 1250
 1251    assert_eq!(
 1252        visible_entries_as_strings(&sidebar, cx),
 1253        vec![
 1254            //
 1255            "> [my-project]  <== selected",
 1256        ]
 1257    );
 1258
 1259    // Confirm again expands the group
 1260    cx.dispatch_action(Confirm);
 1261    cx.run_until_parked();
 1262
 1263    assert_eq!(
 1264        visible_entries_as_strings(&sidebar, cx),
 1265        vec![
 1266            //
 1267            "v [my-project]  <== selected",
 1268            "  Thread 1",
 1269        ]
 1270    );
 1271}
 1272
 1273#[gpui::test]
 1274async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
 1275    let project = init_test_project("/my-project", cx).await;
 1276    let (multi_workspace, cx) =
 1277        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1278    let sidebar = setup_sidebar(&multi_workspace, cx);
 1279
 1280    save_n_test_threads(8, &project, cx).await;
 1281    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1282    cx.run_until_parked();
 1283
 1284    // Should show header + 5 threads + "View More"
 1285    let entries = visible_entries_as_strings(&sidebar, cx);
 1286    assert_eq!(entries.len(), 7);
 1287    assert!(entries.iter().any(|e| e.contains("View More")));
 1288
 1289    // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
 1290    focus_sidebar(&sidebar, cx);
 1291    for _ in 0..7 {
 1292        cx.dispatch_action(SelectNext);
 1293    }
 1294    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
 1295
 1296    // Confirm on "View More" to expand
 1297    cx.dispatch_action(Confirm);
 1298    cx.run_until_parked();
 1299
 1300    // All 8 threads should now be visible with a "Collapse" button
 1301    let entries = visible_entries_as_strings(&sidebar, cx);
 1302    assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
 1303    assert!(!entries.iter().any(|e| e.contains("View More")));
 1304    assert!(entries.iter().any(|e| e.contains("Collapse")));
 1305}
 1306
 1307#[gpui::test]
 1308async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
 1309    let project = init_test_project("/my-project", cx).await;
 1310    let (multi_workspace, cx) =
 1311        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1312    let sidebar = setup_sidebar(&multi_workspace, cx);
 1313
 1314    save_n_test_threads(1, &project, cx).await;
 1315    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1316    cx.run_until_parked();
 1317
 1318    assert_eq!(
 1319        visible_entries_as_strings(&sidebar, cx),
 1320        vec![
 1321            //
 1322            "v [my-project]",
 1323            "  Thread 1",
 1324        ]
 1325    );
 1326
 1327    // Focus sidebar and manually select the header (index 0). Press left to collapse.
 1328    focus_sidebar(&sidebar, cx);
 1329    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1330        sidebar.selection = Some(0);
 1331    });
 1332
 1333    cx.dispatch_action(SelectParent);
 1334    cx.run_until_parked();
 1335
 1336    assert_eq!(
 1337        visible_entries_as_strings(&sidebar, cx),
 1338        vec![
 1339            //
 1340            "> [my-project]  <== selected",
 1341        ]
 1342    );
 1343
 1344    // Press right to expand
 1345    cx.dispatch_action(SelectChild);
 1346    cx.run_until_parked();
 1347
 1348    assert_eq!(
 1349        visible_entries_as_strings(&sidebar, cx),
 1350        vec![
 1351            //
 1352            "v [my-project]  <== selected",
 1353            "  Thread 1",
 1354        ]
 1355    );
 1356
 1357    // Press right again on already-expanded header moves selection down
 1358    cx.dispatch_action(SelectChild);
 1359    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1360}
 1361
 1362#[gpui::test]
 1363async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
 1364    let project = init_test_project("/my-project", cx).await;
 1365    let (multi_workspace, cx) =
 1366        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1367    let sidebar = setup_sidebar(&multi_workspace, cx);
 1368
 1369    save_n_test_threads(1, &project, cx).await;
 1370    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1371    cx.run_until_parked();
 1372
 1373    // Focus sidebar (selection starts at None), then navigate down to the thread (child)
 1374    focus_sidebar(&sidebar, cx);
 1375    cx.dispatch_action(SelectNext);
 1376    cx.dispatch_action(SelectNext);
 1377    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1378
 1379    assert_eq!(
 1380        visible_entries_as_strings(&sidebar, cx),
 1381        vec![
 1382            //
 1383            "v [my-project]",
 1384            "  Thread 1  <== selected",
 1385        ]
 1386    );
 1387
 1388    // Pressing left on a child collapses the parent group and selects it
 1389    cx.dispatch_action(SelectParent);
 1390    cx.run_until_parked();
 1391
 1392    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1393    assert_eq!(
 1394        visible_entries_as_strings(&sidebar, cx),
 1395        vec![
 1396            //
 1397            "> [my-project]  <== selected",
 1398        ]
 1399    );
 1400}
 1401
 1402#[gpui::test]
 1403async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
 1404    let project = init_test_project_with_agent_panel("/empty-project", cx).await;
 1405    let (multi_workspace, cx) =
 1406        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1407    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1408
 1409    // An empty project has only the header (no auto-created draft).
 1410    assert_eq!(
 1411        visible_entries_as_strings(&sidebar, cx),
 1412        vec!["v [empty-project]"]
 1413    );
 1414
 1415    // Focus sidebar — focus_in does not set a selection
 1416    focus_sidebar(&sidebar, cx);
 1417    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1418
 1419    // First SelectNext from None starts at index 0 (header)
 1420    cx.dispatch_action(SelectNext);
 1421    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1422
 1423    // SelectNext with only one entry stays at index 0
 1424    cx.dispatch_action(SelectNext);
 1425    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1426
 1427    // SelectPrevious from first entry clears selection (returns to editor)
 1428    cx.dispatch_action(SelectPrevious);
 1429    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1430
 1431    // SelectPrevious from None selects the last entry
 1432    cx.dispatch_action(SelectPrevious);
 1433    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1434}
 1435
 1436#[gpui::test]
 1437async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
 1438    let project = init_test_project("/my-project", cx).await;
 1439    let (multi_workspace, cx) =
 1440        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1441    let sidebar = setup_sidebar(&multi_workspace, cx);
 1442
 1443    save_n_test_threads(1, &project, cx).await;
 1444    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1445    cx.run_until_parked();
 1446
 1447    // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
 1448    focus_sidebar(&sidebar, cx);
 1449    cx.dispatch_action(SelectNext);
 1450    cx.dispatch_action(SelectNext);
 1451    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1452
 1453    // Collapse the group, which removes the thread from the list
 1454    cx.dispatch_action(SelectParent);
 1455    cx.run_until_parked();
 1456
 1457    // Selection should be clamped to the last valid index (0 = header)
 1458    let selection = sidebar.read_with(cx, |s, _| s.selection);
 1459    let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
 1460    assert!(
 1461        selection.unwrap_or(0) < entry_count,
 1462        "selection {} should be within bounds (entries: {})",
 1463        selection.unwrap_or(0),
 1464        entry_count,
 1465    );
 1466}
 1467
 1468async fn init_test_project_with_agent_panel(
 1469    worktree_path: &str,
 1470    cx: &mut TestAppContext,
 1471) -> Entity<project::Project> {
 1472    agent_ui::test_support::init_test(cx);
 1473    cx.update(|cx| {
 1474        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 1475        ThreadStore::init_global(cx);
 1476        ThreadMetadataStore::init_global(cx);
 1477        language_model::LanguageModelRegistry::test(cx);
 1478        prompt_store::init(cx);
 1479    });
 1480
 1481    let fs = FakeFs::new(cx.executor());
 1482    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
 1483        .await;
 1484    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 1485    project::Project::test(fs, [worktree_path.as_ref()], cx).await
 1486}
 1487
 1488fn add_agent_panel(
 1489    workspace: &Entity<Workspace>,
 1490    cx: &mut gpui::VisualTestContext,
 1491) -> Entity<AgentPanel> {
 1492    workspace.update_in(cx, |workspace, window, cx| {
 1493        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 1494        workspace.add_panel(panel.clone(), window, cx);
 1495        panel
 1496    })
 1497}
 1498
 1499fn setup_sidebar_with_agent_panel(
 1500    multi_workspace: &Entity<MultiWorkspace>,
 1501    cx: &mut gpui::VisualTestContext,
 1502) -> (Entity<Sidebar>, Entity<AgentPanel>) {
 1503    let sidebar = setup_sidebar(multi_workspace, cx);
 1504    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 1505    let panel = add_agent_panel(&workspace, cx);
 1506    (sidebar, panel)
 1507}
 1508
 1509#[gpui::test]
 1510async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
 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    // Open thread A and keep it generating.
 1517    let connection = StubAgentConnection::new();
 1518    open_thread_with_connection(&panel, connection.clone(), cx);
 1519    send_message(&panel, cx);
 1520
 1521    let session_id_a = active_session_id(&panel, cx);
 1522    save_test_thread_metadata(&session_id_a, &project, cx).await;
 1523
 1524    cx.update(|_, cx| {
 1525        connection.send_update(
 1526            session_id_a.clone(),
 1527            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 1528            cx,
 1529        );
 1530    });
 1531    cx.run_until_parked();
 1532
 1533    // Open thread B (idle, default response) — thread A goes to background.
 1534    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1535        acp::ContentChunk::new("Done".into()),
 1536    )]);
 1537    open_thread_with_connection(&panel, connection, cx);
 1538    send_message(&panel, cx);
 1539
 1540    let session_id_b = active_session_id(&panel, cx);
 1541    save_test_thread_metadata(&session_id_b, &project, cx).await;
 1542
 1543    cx.run_until_parked();
 1544
 1545    let mut entries = visible_entries_as_strings(&sidebar, cx);
 1546    entries[1..].sort();
 1547    assert_eq!(
 1548        entries,
 1549        vec![
 1550            //
 1551            "v [my-project]",
 1552            "  Hello *",
 1553            "  Hello * (running)",
 1554        ]
 1555    );
 1556}
 1557
 1558#[gpui::test]
 1559async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
 1560    cx: &mut TestAppContext,
 1561) {
 1562    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1563    let (multi_workspace, cx) =
 1564        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1565    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1566
 1567    let connection = StubAgentConnection::new().with_supports_load_session(true);
 1568    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1569        acp::ContentChunk::new("Done".into()),
 1570    )]);
 1571    open_thread_with_connection(&panel, connection, cx);
 1572    send_message(&panel, cx);
 1573
 1574    let parent_session_id = active_session_id(&panel, cx);
 1575    save_test_thread_metadata(&parent_session_id, &project, cx).await;
 1576
 1577    let subagent_session_id = acp::SessionId::new("subagent-session");
 1578    cx.update(|_, cx| {
 1579        let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
 1580        parent_thread.update(cx, |thread: &mut AcpThread, cx| {
 1581            thread.subagent_spawned(subagent_session_id.clone(), cx);
 1582        });
 1583    });
 1584    cx.run_until_parked();
 1585
 1586    let subagent_thread = panel.read_with(cx, |panel, cx| {
 1587        panel
 1588            .active_conversation_view()
 1589            .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id, cx))
 1590            .map(|thread_view| thread_view.read(cx).thread.clone())
 1591            .expect("Expected subagent thread to be loaded into the conversation")
 1592    });
 1593    request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
 1594
 1595    let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
 1596        sidebar
 1597            .contents
 1598            .entries
 1599            .iter()
 1600            .find_map(|entry| match entry {
 1601                ListEntry::Thread(thread)
 1602                    if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
 1603                {
 1604                    Some(thread.status)
 1605                }
 1606                _ => None,
 1607            })
 1608            .expect("Expected parent thread entry in sidebar")
 1609    });
 1610
 1611    assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
 1612}
 1613
 1614#[gpui::test]
 1615async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
 1616    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 1617    let (multi_workspace, cx) =
 1618        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1619    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1620
 1621    // Open thread on workspace A and keep it generating.
 1622    let connection_a = StubAgentConnection::new();
 1623    open_thread_with_connection(&panel_a, connection_a.clone(), cx);
 1624    send_message(&panel_a, cx);
 1625
 1626    let session_id_a = active_session_id(&panel_a, cx);
 1627    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 1628
 1629    cx.update(|_, cx| {
 1630        connection_a.send_update(
 1631            session_id_a.clone(),
 1632            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
 1633            cx,
 1634        );
 1635    });
 1636    cx.run_until_parked();
 1637
 1638    // Add a second workspace and activate it (making workspace A the background).
 1639    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 1640    let project_b = project::Project::test(fs, [], cx).await;
 1641    multi_workspace.update_in(cx, |mw, window, cx| {
 1642        mw.test_add_workspace(project_b, window, cx);
 1643    });
 1644    cx.run_until_parked();
 1645
 1646    // Thread A is still running; no notification yet.
 1647    assert_eq!(
 1648        visible_entries_as_strings(&sidebar, cx),
 1649        vec![
 1650            //
 1651            "v [project-a]",
 1652            "  Hello * (running)",
 1653        ]
 1654    );
 1655
 1656    // Complete thread A's turn (transition Running → Completed).
 1657    connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
 1658    cx.run_until_parked();
 1659
 1660    // The completed background thread shows a notification indicator.
 1661    assert_eq!(
 1662        visible_entries_as_strings(&sidebar, cx),
 1663        vec![
 1664            //
 1665            "v [project-a]",
 1666            "  Hello * (!)",
 1667        ]
 1668    );
 1669}
 1670
 1671fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
 1672    sidebar.update_in(cx, |sidebar, window, cx| {
 1673        window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
 1674        sidebar.filter_editor.update(cx, |editor, cx| {
 1675            editor.set_text(query, window, cx);
 1676        });
 1677    });
 1678    cx.run_until_parked();
 1679}
 1680
 1681#[gpui::test]
 1682async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
 1683    let project = init_test_project("/my-project", cx).await;
 1684    let (multi_workspace, cx) =
 1685        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1686    let sidebar = setup_sidebar(&multi_workspace, cx);
 1687
 1688    for (id, title, hour) in [
 1689        ("t-1", "Fix crash in project panel", 3),
 1690        ("t-2", "Add inline diff view", 2),
 1691        ("t-3", "Refactor settings module", 1),
 1692    ] {
 1693        save_thread_metadata(
 1694            acp::SessionId::new(Arc::from(id)),
 1695            Some(title.into()),
 1696            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1697            None,
 1698            &project,
 1699            cx,
 1700        );
 1701    }
 1702    cx.run_until_parked();
 1703
 1704    assert_eq!(
 1705        visible_entries_as_strings(&sidebar, cx),
 1706        vec![
 1707            //
 1708            "v [my-project]",
 1709            "  Fix crash in project panel",
 1710            "  Add inline diff view",
 1711            "  Refactor settings module",
 1712        ]
 1713    );
 1714
 1715    // User types "diff" in the search box — only the matching thread remains,
 1716    // with its workspace header preserved for context.
 1717    type_in_search(&sidebar, "diff", cx);
 1718    assert_eq!(
 1719        visible_entries_as_strings(&sidebar, cx),
 1720        vec![
 1721            //
 1722            "v [my-project]",
 1723            "  Add inline diff view  <== selected",
 1724        ]
 1725    );
 1726
 1727    // User changes query to something with no matches — list is empty.
 1728    type_in_search(&sidebar, "nonexistent", cx);
 1729    assert_eq!(
 1730        visible_entries_as_strings(&sidebar, cx),
 1731        Vec::<String>::new()
 1732    );
 1733}
 1734
 1735#[gpui::test]
 1736async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
 1737    // Scenario: A user remembers a thread title but not the exact casing.
 1738    // Search should match case-insensitively so they can still find it.
 1739    let project = init_test_project("/my-project", cx).await;
 1740    let (multi_workspace, cx) =
 1741        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1742    let sidebar = setup_sidebar(&multi_workspace, cx);
 1743
 1744    save_thread_metadata(
 1745        acp::SessionId::new(Arc::from("thread-1")),
 1746        Some("Fix Crash In Project Panel".into()),
 1747        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 1748        None,
 1749        &project,
 1750        cx,
 1751    );
 1752    cx.run_until_parked();
 1753
 1754    // Lowercase query matches mixed-case title.
 1755    type_in_search(&sidebar, "fix crash", cx);
 1756    assert_eq!(
 1757        visible_entries_as_strings(&sidebar, cx),
 1758        vec![
 1759            //
 1760            "v [my-project]",
 1761            "  Fix Crash In Project Panel  <== selected",
 1762        ]
 1763    );
 1764
 1765    // Uppercase query also matches the same title.
 1766    type_in_search(&sidebar, "FIX CRASH", cx);
 1767    assert_eq!(
 1768        visible_entries_as_strings(&sidebar, cx),
 1769        vec![
 1770            //
 1771            "v [my-project]",
 1772            "  Fix Crash In Project Panel  <== selected",
 1773        ]
 1774    );
 1775}
 1776
 1777#[gpui::test]
 1778async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
 1779    // Scenario: A user searches, finds what they need, then presses Escape
 1780    // to dismiss the filter and see the full list again.
 1781    let project = init_test_project("/my-project", cx).await;
 1782    let (multi_workspace, cx) =
 1783        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1784    let sidebar = setup_sidebar(&multi_workspace, cx);
 1785
 1786    for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
 1787        save_thread_metadata(
 1788            acp::SessionId::new(Arc::from(id)),
 1789            Some(title.into()),
 1790            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1791            None,
 1792            &project,
 1793            cx,
 1794        )
 1795    }
 1796    cx.run_until_parked();
 1797
 1798    // Confirm the full list is showing.
 1799    assert_eq!(
 1800        visible_entries_as_strings(&sidebar, cx),
 1801        vec![
 1802            //
 1803            "v [my-project]",
 1804            "  Alpha thread",
 1805            "  Beta thread",
 1806        ]
 1807    );
 1808
 1809    // User types a search query to filter down.
 1810    focus_sidebar(&sidebar, cx);
 1811    type_in_search(&sidebar, "alpha", cx);
 1812    assert_eq!(
 1813        visible_entries_as_strings(&sidebar, cx),
 1814        vec![
 1815            //
 1816            "v [my-project]",
 1817            "  Alpha thread  <== selected",
 1818        ]
 1819    );
 1820
 1821    // User presses Escape — filter clears, full list is restored.
 1822    // The selection index (1) now points at the first thread entry.
 1823    cx.dispatch_action(Cancel);
 1824    cx.run_until_parked();
 1825    assert_eq!(
 1826        visible_entries_as_strings(&sidebar, cx),
 1827        vec![
 1828            //
 1829            "v [my-project]",
 1830            "  Alpha thread  <== selected",
 1831            "  Beta thread",
 1832        ]
 1833    );
 1834}
 1835
 1836#[gpui::test]
 1837async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
 1838    let project_a = init_test_project("/project-a", cx).await;
 1839    let (multi_workspace, cx) =
 1840        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1841    let sidebar = setup_sidebar(&multi_workspace, cx);
 1842
 1843    for (id, title, hour) in [
 1844        ("a1", "Fix bug in sidebar", 2),
 1845        ("a2", "Add tests for editor", 1),
 1846    ] {
 1847        save_thread_metadata(
 1848            acp::SessionId::new(Arc::from(id)),
 1849            Some(title.into()),
 1850            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1851            None,
 1852            &project_a,
 1853            cx,
 1854        )
 1855    }
 1856
 1857    // Add a second workspace.
 1858    multi_workspace.update_in(cx, |mw, window, cx| {
 1859        mw.create_test_workspace(window, cx).detach();
 1860    });
 1861    cx.run_until_parked();
 1862
 1863    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 1864        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 1865    });
 1866
 1867    for (id, title, hour) in [
 1868        ("b1", "Refactor sidebar layout", 3),
 1869        ("b2", "Fix typo in README", 1),
 1870    ] {
 1871        save_thread_metadata(
 1872            acp::SessionId::new(Arc::from(id)),
 1873            Some(title.into()),
 1874            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1875            None,
 1876            &project_b,
 1877            cx,
 1878        )
 1879    }
 1880    cx.run_until_parked();
 1881
 1882    assert_eq!(
 1883        visible_entries_as_strings(&sidebar, cx),
 1884        vec![
 1885            //
 1886            "v [project-a]",
 1887            "  Fix bug in sidebar",
 1888            "  Add tests for editor",
 1889        ]
 1890    );
 1891
 1892    // "sidebar" matches a thread in each workspace — both headers stay visible.
 1893    type_in_search(&sidebar, "sidebar", cx);
 1894    assert_eq!(
 1895        visible_entries_as_strings(&sidebar, cx),
 1896        vec![
 1897            //
 1898            "v [project-a]",
 1899            "  Fix bug in sidebar  <== selected",
 1900        ]
 1901    );
 1902
 1903    // "typo" only matches in the second workspace — the first header disappears.
 1904    type_in_search(&sidebar, "typo", cx);
 1905    assert_eq!(
 1906        visible_entries_as_strings(&sidebar, cx),
 1907        Vec::<String>::new()
 1908    );
 1909
 1910    // "project-a" matches the first workspace name — the header appears
 1911    // with all child threads included.
 1912    type_in_search(&sidebar, "project-a", cx);
 1913    assert_eq!(
 1914        visible_entries_as_strings(&sidebar, cx),
 1915        vec![
 1916            //
 1917            "v [project-a]",
 1918            "  Fix bug in sidebar  <== selected",
 1919            "  Add tests for editor",
 1920        ]
 1921    );
 1922}
 1923
 1924#[gpui::test]
 1925async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
 1926    let project_a = init_test_project("/alpha-project", cx).await;
 1927    let (multi_workspace, cx) =
 1928        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1929    let sidebar = setup_sidebar(&multi_workspace, cx);
 1930
 1931    for (id, title, hour) in [
 1932        ("a1", "Fix bug in sidebar", 2),
 1933        ("a2", "Add tests for editor", 1),
 1934    ] {
 1935        save_thread_metadata(
 1936            acp::SessionId::new(Arc::from(id)),
 1937            Some(title.into()),
 1938            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1939            None,
 1940            &project_a,
 1941            cx,
 1942        )
 1943    }
 1944
 1945    // Add a second workspace.
 1946    multi_workspace.update_in(cx, |mw, window, cx| {
 1947        mw.create_test_workspace(window, cx).detach();
 1948    });
 1949    cx.run_until_parked();
 1950
 1951    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 1952        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 1953    });
 1954
 1955    for (id, title, hour) in [
 1956        ("b1", "Refactor sidebar layout", 3),
 1957        ("b2", "Fix typo in README", 1),
 1958    ] {
 1959        save_thread_metadata(
 1960            acp::SessionId::new(Arc::from(id)),
 1961            Some(title.into()),
 1962            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1963            None,
 1964            &project_b,
 1965            cx,
 1966        )
 1967    }
 1968    cx.run_until_parked();
 1969
 1970    // "alpha" matches the workspace name "alpha-project" but no thread titles.
 1971    // The workspace header should appear with all child threads included.
 1972    type_in_search(&sidebar, "alpha", cx);
 1973    assert_eq!(
 1974        visible_entries_as_strings(&sidebar, cx),
 1975        vec![
 1976            //
 1977            "v [alpha-project]",
 1978            "  Fix bug in sidebar  <== selected",
 1979            "  Add tests for editor",
 1980        ]
 1981    );
 1982
 1983    // "sidebar" matches thread titles in both workspaces but not workspace names.
 1984    // Both headers appear with their matching threads.
 1985    type_in_search(&sidebar, "sidebar", cx);
 1986    assert_eq!(
 1987        visible_entries_as_strings(&sidebar, cx),
 1988        vec![
 1989            //
 1990            "v [alpha-project]",
 1991            "  Fix bug in sidebar  <== selected",
 1992        ]
 1993    );
 1994
 1995    // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
 1996    // doesn't match) — but does not match either workspace name or any thread.
 1997    // Actually let's test something simpler: a query that matches both a workspace
 1998    // name AND some threads in that workspace. Matching threads should still appear.
 1999    type_in_search(&sidebar, "fix", cx);
 2000    assert_eq!(
 2001        visible_entries_as_strings(&sidebar, cx),
 2002        vec![
 2003            //
 2004            "v [alpha-project]",
 2005            "  Fix bug in sidebar  <== selected",
 2006        ]
 2007    );
 2008
 2009    // A query that matches a workspace name AND a thread in that same workspace.
 2010    // Both the header (highlighted) and all child threads should appear.
 2011    type_in_search(&sidebar, "alpha", cx);
 2012    assert_eq!(
 2013        visible_entries_as_strings(&sidebar, cx),
 2014        vec![
 2015            //
 2016            "v [alpha-project]",
 2017            "  Fix bug in sidebar  <== selected",
 2018            "  Add tests for editor",
 2019        ]
 2020    );
 2021
 2022    // Now search for something that matches only a workspace name when there
 2023    // are also threads with matching titles — the non-matching workspace's
 2024    // threads should still appear if their titles match.
 2025    type_in_search(&sidebar, "alp", cx);
 2026    assert_eq!(
 2027        visible_entries_as_strings(&sidebar, cx),
 2028        vec![
 2029            //
 2030            "v [alpha-project]",
 2031            "  Fix bug in sidebar  <== selected",
 2032            "  Add tests for editor",
 2033        ]
 2034    );
 2035}
 2036
 2037#[gpui::test]
 2038async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
 2039    let project = init_test_project("/my-project", cx).await;
 2040    let (multi_workspace, cx) =
 2041        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2042    let sidebar = setup_sidebar(&multi_workspace, cx);
 2043
 2044    // Create 8 threads. The oldest one has a unique name and will be
 2045    // behind View More (only 5 shown by default).
 2046    for i in 0..8u32 {
 2047        let title = if i == 0 {
 2048            "Hidden gem thread".to_string()
 2049        } else {
 2050            format!("Thread {}", i + 1)
 2051        };
 2052        save_thread_metadata(
 2053            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
 2054            Some(title.into()),
 2055            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
 2056            None,
 2057            &project,
 2058            cx,
 2059        )
 2060    }
 2061    cx.run_until_parked();
 2062
 2063    // Confirm the thread is not visible and View More is shown.
 2064    let entries = visible_entries_as_strings(&sidebar, cx);
 2065    assert!(
 2066        entries.iter().any(|e| e.contains("View More")),
 2067        "should have View More button"
 2068    );
 2069    assert!(
 2070        !entries.iter().any(|e| e.contains("Hidden gem")),
 2071        "Hidden gem should be behind View More"
 2072    );
 2073
 2074    // User searches for the hidden thread — it appears, and View More is gone.
 2075    type_in_search(&sidebar, "hidden gem", cx);
 2076    let filtered = visible_entries_as_strings(&sidebar, cx);
 2077    assert_eq!(
 2078        filtered,
 2079        vec![
 2080            //
 2081            "v [my-project]",
 2082            "  Hidden gem thread  <== selected",
 2083        ]
 2084    );
 2085    assert!(
 2086        !filtered.iter().any(|e| e.contains("View More")),
 2087        "View More should not appear when filtering"
 2088    );
 2089}
 2090
 2091#[gpui::test]
 2092async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
 2093    let project = init_test_project("/my-project", cx).await;
 2094    let (multi_workspace, cx) =
 2095        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2096    let sidebar = setup_sidebar(&multi_workspace, cx);
 2097
 2098    save_thread_metadata(
 2099        acp::SessionId::new(Arc::from("thread-1")),
 2100        Some("Important thread".into()),
 2101        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 2102        None,
 2103        &project,
 2104        cx,
 2105    );
 2106    cx.run_until_parked();
 2107
 2108    // User focuses the sidebar and collapses the group using keyboard:
 2109    // manually select the header, then press SelectParent to collapse.
 2110    focus_sidebar(&sidebar, cx);
 2111    sidebar.update_in(cx, |sidebar, _window, _cx| {
 2112        sidebar.selection = Some(0);
 2113    });
 2114    cx.dispatch_action(SelectParent);
 2115    cx.run_until_parked();
 2116
 2117    assert_eq!(
 2118        visible_entries_as_strings(&sidebar, cx),
 2119        vec![
 2120            //
 2121            "> [my-project]  <== selected",
 2122        ]
 2123    );
 2124
 2125    // User types a search — the thread appears even though its group is collapsed.
 2126    type_in_search(&sidebar, "important", cx);
 2127    assert_eq!(
 2128        visible_entries_as_strings(&sidebar, cx),
 2129        vec![
 2130            //
 2131            "> [my-project]",
 2132            "  Important thread  <== selected",
 2133        ]
 2134    );
 2135}
 2136
 2137#[gpui::test]
 2138async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
 2139    let project = init_test_project("/my-project", cx).await;
 2140    let (multi_workspace, cx) =
 2141        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2142    let sidebar = setup_sidebar(&multi_workspace, cx);
 2143
 2144    for (id, title, hour) in [
 2145        ("t-1", "Fix crash in panel", 3),
 2146        ("t-2", "Fix lint warnings", 2),
 2147        ("t-3", "Add new feature", 1),
 2148    ] {
 2149        save_thread_metadata(
 2150            acp::SessionId::new(Arc::from(id)),
 2151            Some(title.into()),
 2152            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2153            None,
 2154            &project,
 2155            cx,
 2156        )
 2157    }
 2158    cx.run_until_parked();
 2159
 2160    focus_sidebar(&sidebar, cx);
 2161
 2162    // User types "fix" — two threads match.
 2163    type_in_search(&sidebar, "fix", cx);
 2164    assert_eq!(
 2165        visible_entries_as_strings(&sidebar, cx),
 2166        vec![
 2167            //
 2168            "v [my-project]",
 2169            "  Fix crash in panel  <== selected",
 2170            "  Fix lint warnings",
 2171        ]
 2172    );
 2173
 2174    // Selection starts on the first matching thread. User presses
 2175    // SelectNext to move to the second match.
 2176    cx.dispatch_action(SelectNext);
 2177    assert_eq!(
 2178        visible_entries_as_strings(&sidebar, cx),
 2179        vec![
 2180            //
 2181            "v [my-project]",
 2182            "  Fix crash in panel",
 2183            "  Fix lint warnings  <== selected",
 2184        ]
 2185    );
 2186
 2187    // User can also jump back with SelectPrevious.
 2188    cx.dispatch_action(SelectPrevious);
 2189    assert_eq!(
 2190        visible_entries_as_strings(&sidebar, cx),
 2191        vec![
 2192            //
 2193            "v [my-project]",
 2194            "  Fix crash in panel  <== selected",
 2195            "  Fix lint warnings",
 2196        ]
 2197    );
 2198}
 2199
 2200#[gpui::test]
 2201async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
 2202    let project = init_test_project("/my-project", cx).await;
 2203    let (multi_workspace, cx) =
 2204        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2205    let sidebar = setup_sidebar(&multi_workspace, cx);
 2206
 2207    multi_workspace.update_in(cx, |mw, window, cx| {
 2208        mw.create_test_workspace(window, cx).detach();
 2209    });
 2210    cx.run_until_parked();
 2211
 2212    let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
 2213        (
 2214            mw.workspaces().next().unwrap().clone(),
 2215            mw.workspaces().nth(1).unwrap().clone(),
 2216        )
 2217    });
 2218
 2219    save_thread_metadata(
 2220        acp::SessionId::new(Arc::from("hist-1")),
 2221        Some("Historical Thread".into()),
 2222        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2223        None,
 2224        &project,
 2225        cx,
 2226    );
 2227    cx.run_until_parked();
 2228    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2229    cx.run_until_parked();
 2230
 2231    assert_eq!(
 2232        visible_entries_as_strings(&sidebar, cx),
 2233        vec![
 2234            //
 2235            "v [my-project]",
 2236            "  Historical Thread",
 2237        ]
 2238    );
 2239
 2240    // Switch to workspace 1 so we can verify the confirm switches back.
 2241    multi_workspace.update_in(cx, |mw, window, cx| {
 2242        let workspace = mw.workspaces().nth(1).unwrap().clone();
 2243        mw.activate(workspace, window, cx);
 2244    });
 2245    cx.run_until_parked();
 2246    assert_eq!(
 2247        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2248        workspace_1
 2249    );
 2250
 2251    // Confirm on the historical (non-live) thread at index 1.
 2252    // Before a previous fix, the workspace field was Option<usize> and
 2253    // historical threads had None, so activate_thread early-returned
 2254    // without switching the workspace.
 2255    sidebar.update_in(cx, |sidebar, window, cx| {
 2256        sidebar.selection = Some(1);
 2257        sidebar.confirm(&Confirm, window, cx);
 2258    });
 2259    cx.run_until_parked();
 2260
 2261    assert_eq!(
 2262        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2263        workspace_0
 2264    );
 2265}
 2266
 2267#[gpui::test]
 2268async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
 2269    cx: &mut TestAppContext,
 2270) {
 2271    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2272    let (multi_workspace, cx) =
 2273        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2274    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2275
 2276    let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
 2277    let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
 2278    save_thread_metadata(
 2279        newer_session_id,
 2280        Some("Newer Historical Thread".into()),
 2281        newer_timestamp,
 2282        Some(newer_timestamp),
 2283        &project,
 2284        cx,
 2285    );
 2286
 2287    let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
 2288    let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
 2289    save_thread_metadata(
 2290        older_session_id.clone(),
 2291        Some("Older Historical Thread".into()),
 2292        older_timestamp,
 2293        Some(older_timestamp),
 2294        &project,
 2295        cx,
 2296    );
 2297
 2298    cx.run_until_parked();
 2299    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2300    cx.run_until_parked();
 2301
 2302    let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2303        .into_iter()
 2304        .filter(|entry| entry.contains("Historical Thread"))
 2305        .collect();
 2306    assert_eq!(
 2307        historical_entries_before,
 2308        vec![
 2309            "  Newer Historical Thread".to_string(),
 2310            "  Older Historical Thread".to_string(),
 2311        ],
 2312        "expected the sidebar to sort historical threads by their saved timestamp before activation"
 2313    );
 2314
 2315    let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
 2316        sidebar
 2317            .contents
 2318            .entries
 2319            .iter()
 2320            .position(|entry| {
 2321                matches!(entry, ListEntry::Thread(thread)
 2322                    if thread.metadata.session_id.as_ref() == Some(&older_session_id))
 2323            })
 2324            .expect("expected Older Historical Thread to appear in the sidebar")
 2325    });
 2326
 2327    sidebar.update_in(cx, |sidebar, window, cx| {
 2328        sidebar.selection = Some(older_entry_index);
 2329        sidebar.confirm(&Confirm, window, cx);
 2330    });
 2331    cx.run_until_parked();
 2332
 2333    let older_metadata = cx.update(|_, cx| {
 2334        ThreadMetadataStore::global(cx)
 2335            .read(cx)
 2336            .entry_by_session(&older_session_id)
 2337            .cloned()
 2338            .expect("expected metadata for Older Historical Thread after activation")
 2339    });
 2340    assert_eq!(
 2341        older_metadata.created_at,
 2342        Some(older_timestamp),
 2343        "activating a historical thread should not rewrite its saved created_at timestamp"
 2344    );
 2345
 2346    let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2347        .into_iter()
 2348        .filter(|entry| entry.contains("Historical Thread"))
 2349        .collect();
 2350    assert_eq!(
 2351        historical_entries_after,
 2352        vec![
 2353            "  Newer Historical Thread".to_string(),
 2354            "  Older Historical Thread  <== selected".to_string(),
 2355        ],
 2356        "activating an older historical thread should not reorder it ahead of a newer historical thread"
 2357    );
 2358}
 2359
 2360#[gpui::test]
 2361async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
 2362    cx: &mut TestAppContext,
 2363) {
 2364    use workspace::ProjectGroup;
 2365
 2366    agent_ui::test_support::init_test(cx);
 2367    cx.update(|cx| {
 2368        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 2369        ThreadStore::init_global(cx);
 2370        ThreadMetadataStore::init_global(cx);
 2371        language_model::LanguageModelRegistry::test(cx);
 2372        prompt_store::init(cx);
 2373    });
 2374
 2375    let fs = FakeFs::new(cx.executor());
 2376    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 2377        .await;
 2378    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2379        .await;
 2380    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 2381
 2382    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 2383    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 2384
 2385    let (multi_workspace, cx) =
 2386        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2387    let sidebar = setup_sidebar(&multi_workspace, cx);
 2388
 2389    let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
 2390    multi_workspace.update(cx, |mw, _cx| {
 2391        mw.test_add_project_group(ProjectGroup {
 2392            key: project_b_key.clone(),
 2393            workspaces: Vec::new(),
 2394            expanded: true,
 2395            visible_thread_count: None,
 2396        });
 2397    });
 2398
 2399    let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
 2400    save_thread_metadata(
 2401        session_id.clone(),
 2402        Some("Historical Thread in New Group".into()),
 2403        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2404        None,
 2405        &project_b,
 2406        cx,
 2407    );
 2408    cx.run_until_parked();
 2409
 2410    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2411    cx.run_until_parked();
 2412
 2413    let entries_before = visible_entries_as_strings(&sidebar, cx);
 2414    assert_eq!(
 2415        entries_before,
 2416        vec![
 2417            "v [project-a]",
 2418            "v [project-b]",
 2419            "  Historical Thread in New Group",
 2420        ],
 2421        "expected the closed project group to show the historical thread before first open"
 2422    );
 2423
 2424    assert_eq!(
 2425        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2426        1,
 2427        "should start without an open workspace for the new project group"
 2428    );
 2429
 2430    sidebar.update_in(cx, |sidebar, window, cx| {
 2431        sidebar.selection = Some(2);
 2432        sidebar.confirm(&Confirm, window, cx);
 2433    });
 2434
 2435    cx.run_until_parked();
 2436
 2437    assert_eq!(
 2438        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2439        2,
 2440        "confirming the historical thread should open a workspace for the new project group"
 2441    );
 2442
 2443    let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
 2444        mw.workspaces()
 2445            .find(|workspace| {
 2446                PathList::new(&workspace.read(cx).root_paths(cx))
 2447                    == project_b_key.path_list().clone()
 2448            })
 2449            .cloned()
 2450            .expect("expected workspace for project-b after opening the historical thread")
 2451    });
 2452
 2453    assert_eq!(
 2454        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2455        workspace_b,
 2456        "opening the historical thread should activate the new project's workspace"
 2457    );
 2458
 2459    let panel = workspace_b.read_with(cx, |workspace, cx| {
 2460        workspace
 2461            .panel::<AgentPanel>(cx)
 2462            .expect("expected first-open activation to bootstrap the agent panel")
 2463    });
 2464
 2465    let expected_thread_id = cx.update(|_, cx| {
 2466        ThreadMetadataStore::global(cx)
 2467            .read(cx)
 2468            .entries()
 2469            .find(|e| e.session_id.as_ref() == Some(&session_id))
 2470            .map(|e| e.thread_id)
 2471            .expect("metadata should still map session id to thread id")
 2472    });
 2473
 2474    assert_eq!(
 2475        panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 2476        Some(expected_thread_id),
 2477        "expected the agent panel to activate the real historical thread rather than a draft"
 2478    );
 2479
 2480    let entries_after = visible_entries_as_strings(&sidebar, cx);
 2481    let matching_rows: Vec<_> = entries_after
 2482        .iter()
 2483        .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
 2484        .cloned()
 2485        .collect();
 2486    assert_eq!(
 2487        matching_rows.len(),
 2488        1,
 2489        "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
 2490    );
 2491    assert!(
 2492        matching_rows[0].contains("Historical Thread in New Group"),
 2493        "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
 2494    );
 2495    assert!(
 2496        !matching_rows[0].contains("Draft"),
 2497        "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
 2498    );
 2499}
 2500
 2501#[gpui::test]
 2502async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
 2503    let project = init_test_project("/my-project", cx).await;
 2504    let (multi_workspace, cx) =
 2505        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2506    let sidebar = setup_sidebar(&multi_workspace, cx);
 2507
 2508    save_thread_metadata(
 2509        acp::SessionId::new(Arc::from("t-1")),
 2510        Some("Thread A".into()),
 2511        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 2512        None,
 2513        &project,
 2514        cx,
 2515    );
 2516
 2517    save_thread_metadata(
 2518        acp::SessionId::new(Arc::from("t-2")),
 2519        Some("Thread B".into()),
 2520        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 2521        None,
 2522        &project,
 2523        cx,
 2524    );
 2525
 2526    cx.run_until_parked();
 2527    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2528    cx.run_until_parked();
 2529
 2530    assert_eq!(
 2531        visible_entries_as_strings(&sidebar, cx),
 2532        vec![
 2533            //
 2534            "v [my-project]",
 2535            "  Thread A",
 2536            "  Thread B",
 2537        ]
 2538    );
 2539
 2540    // Keyboard confirm preserves selection.
 2541    sidebar.update_in(cx, |sidebar, window, cx| {
 2542        sidebar.selection = Some(1);
 2543        sidebar.confirm(&Confirm, window, cx);
 2544    });
 2545    assert_eq!(
 2546        sidebar.read_with(cx, |sidebar, _| sidebar.selection),
 2547        Some(1)
 2548    );
 2549
 2550    // Click handlers clear selection to None so no highlight lingers
 2551    // after a click regardless of focus state. The hover style provides
 2552    // visual feedback during mouse interaction instead.
 2553    sidebar.update_in(cx, |sidebar, window, cx| {
 2554        sidebar.selection = None;
 2555        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 2556        let project_group_key = ProjectGroupKey::new(None, path_list);
 2557        sidebar.toggle_collapse(&project_group_key, window, cx);
 2558    });
 2559    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2560
 2561    // When the user tabs back into the sidebar, focus_in no longer
 2562    // restores selection — it stays None.
 2563    sidebar.update_in(cx, |sidebar, window, cx| {
 2564        sidebar.focus_in(window, cx);
 2565    });
 2566    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2567}
 2568
 2569#[gpui::test]
 2570async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
 2571    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2572    let (multi_workspace, cx) =
 2573        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2574    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2575
 2576    let connection = StubAgentConnection::new();
 2577    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2578        acp::ContentChunk::new("Hi there!".into()),
 2579    )]);
 2580    open_thread_with_connection(&panel, connection, cx);
 2581    send_message(&panel, cx);
 2582
 2583    let session_id = active_session_id(&panel, cx);
 2584    save_test_thread_metadata(&session_id, &project, cx).await;
 2585    cx.run_until_parked();
 2586
 2587    assert_eq!(
 2588        visible_entries_as_strings(&sidebar, cx),
 2589        vec![
 2590            //
 2591            "v [my-project]",
 2592            "  Hello *",
 2593        ]
 2594    );
 2595
 2596    // Simulate the agent generating a title. The notification chain is:
 2597    // AcpThread::set_title emits TitleUpdated →
 2598    // ConnectionView::handle_thread_event calls cx.notify() →
 2599    // AgentPanel observer fires and emits AgentPanelEvent →
 2600    // Sidebar subscription calls update_entries / rebuild_contents.
 2601    //
 2602    // Before the fix, handle_thread_event did NOT call cx.notify() for
 2603    // TitleUpdated, so the AgentPanel observer never fired and the
 2604    // sidebar kept showing the old title.
 2605    let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
 2606    thread.update(cx, |thread, cx| {
 2607        thread
 2608            .set_title("Friendly Greeting with AI".into(), cx)
 2609            .detach();
 2610    });
 2611    cx.run_until_parked();
 2612
 2613    assert_eq!(
 2614        visible_entries_as_strings(&sidebar, cx),
 2615        vec![
 2616            //
 2617            "v [my-project]",
 2618            "  Friendly Greeting with AI *",
 2619        ]
 2620    );
 2621}
 2622
 2623#[gpui::test]
 2624async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
 2625    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 2626    let (multi_workspace, cx) =
 2627        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2628    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2629
 2630    // Save a thread so it appears in the list.
 2631    let connection_a = StubAgentConnection::new();
 2632    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2633        acp::ContentChunk::new("Done".into()),
 2634    )]);
 2635    open_thread_with_connection(&panel_a, connection_a, cx);
 2636    send_message(&panel_a, cx);
 2637    let session_id_a = active_session_id(&panel_a, cx);
 2638    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 2639
 2640    // Add a second workspace with its own agent panel.
 2641    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 2642    fs.as_fake()
 2643        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2644        .await;
 2645    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
 2646    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 2647        mw.test_add_workspace(project_b.clone(), window, cx)
 2648    });
 2649    let panel_b = add_agent_panel(&workspace_b, cx);
 2650    cx.run_until_parked();
 2651
 2652    let workspace_a =
 2653        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 2654
 2655    // ── 1. Initial state: focused thread derived from active panel ─────
 2656    sidebar.read_with(cx, |sidebar, _cx| {
 2657        assert_active_thread(
 2658            sidebar,
 2659            &session_id_a,
 2660            "The active panel's thread should be focused on startup",
 2661        );
 2662    });
 2663
 2664    let thread_metadata_a = cx.update(|_window, cx| {
 2665        ThreadMetadataStore::global(cx)
 2666            .read(cx)
 2667            .entry_by_session(&session_id_a)
 2668            .cloned()
 2669            .expect("session_id_a should exist in metadata store")
 2670    });
 2671    sidebar.update_in(cx, |sidebar, window, cx| {
 2672        sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
 2673    });
 2674    cx.run_until_parked();
 2675
 2676    sidebar.read_with(cx, |sidebar, _cx| {
 2677        assert_active_thread(
 2678            sidebar,
 2679            &session_id_a,
 2680            "After clicking a thread, it should be the focused thread",
 2681        );
 2682        assert!(
 2683            has_thread_entry(sidebar, &session_id_a),
 2684            "The clicked thread should be present in the entries"
 2685        );
 2686    });
 2687
 2688    workspace_a.read_with(cx, |workspace, cx| {
 2689        assert!(
 2690            workspace.panel::<AgentPanel>(cx).is_some(),
 2691            "Agent panel should exist"
 2692        );
 2693        let dock = workspace.left_dock().read(cx);
 2694        assert!(
 2695            dock.is_open(),
 2696            "Clicking a thread should open the agent panel dock"
 2697        );
 2698    });
 2699
 2700    let connection_b = StubAgentConnection::new();
 2701    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2702        acp::ContentChunk::new("Thread B".into()),
 2703    )]);
 2704    open_thread_with_connection(&panel_b, connection_b, cx);
 2705    send_message(&panel_b, cx);
 2706    let session_id_b = active_session_id(&panel_b, cx);
 2707    save_test_thread_metadata(&session_id_b, &project_b, cx).await;
 2708    cx.run_until_parked();
 2709
 2710    // Workspace A is currently active. Click a thread in workspace B,
 2711    // which also triggers a workspace switch.
 2712    let thread_metadata_b = cx.update(|_window, cx| {
 2713        ThreadMetadataStore::global(cx)
 2714            .read(cx)
 2715            .entry_by_session(&session_id_b)
 2716            .cloned()
 2717            .expect("session_id_b should exist in metadata store")
 2718    });
 2719    sidebar.update_in(cx, |sidebar, window, cx| {
 2720        sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
 2721    });
 2722    cx.run_until_parked();
 2723
 2724    sidebar.read_with(cx, |sidebar, _cx| {
 2725        assert_active_thread(
 2726            sidebar,
 2727            &session_id_b,
 2728            "Clicking a thread in another workspace should focus that thread",
 2729        );
 2730        assert!(
 2731            has_thread_entry(sidebar, &session_id_b),
 2732            "The cross-workspace thread should be present in the entries"
 2733        );
 2734    });
 2735
 2736    multi_workspace.update_in(cx, |mw, window, cx| {
 2737        let workspace = mw.workspaces().next().unwrap().clone();
 2738        mw.activate(workspace, window, cx);
 2739    });
 2740    cx.run_until_parked();
 2741
 2742    sidebar.read_with(cx, |sidebar, _cx| {
 2743        assert_active_thread(
 2744            sidebar,
 2745            &session_id_a,
 2746            "Switching workspace should seed focused_thread from the new active panel",
 2747        );
 2748        assert!(
 2749            has_thread_entry(sidebar, &session_id_a),
 2750            "The seeded thread should be present in the entries"
 2751        );
 2752    });
 2753
 2754    let connection_b2 = StubAgentConnection::new();
 2755    connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2756        acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
 2757    )]);
 2758    open_thread_with_connection(&panel_b, connection_b2, cx);
 2759    send_message(&panel_b, cx);
 2760    let session_id_b2 = active_session_id(&panel_b, cx);
 2761    save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
 2762    cx.run_until_parked();
 2763
 2764    // Panel B is not the active workspace's panel (workspace A is
 2765    // active), so opening a thread there should not change focused_thread.
 2766    // This prevents running threads in background workspaces from causing
 2767    // the selection highlight to jump around.
 2768    sidebar.read_with(cx, |sidebar, _cx| {
 2769        assert_active_thread(
 2770            sidebar,
 2771            &session_id_a,
 2772            "Opening a thread in a non-active panel should not change focused_thread",
 2773        );
 2774    });
 2775
 2776    workspace_b.update_in(cx, |workspace, window, cx| {
 2777        workspace.focus_handle(cx).focus(window, cx);
 2778    });
 2779    cx.run_until_parked();
 2780
 2781    sidebar.read_with(cx, |sidebar, _cx| {
 2782        assert_active_thread(
 2783            sidebar,
 2784            &session_id_a,
 2785            "Defocusing the sidebar should not change focused_thread",
 2786        );
 2787    });
 2788
 2789    // Switching workspaces via the multi_workspace (simulates clicking
 2790    // a workspace header) should clear focused_thread.
 2791    multi_workspace.update_in(cx, |mw, window, cx| {
 2792        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
 2793        if let Some(workspace) = workspace {
 2794            mw.activate(workspace, window, cx);
 2795        }
 2796    });
 2797    cx.run_until_parked();
 2798
 2799    sidebar.read_with(cx, |sidebar, _cx| {
 2800        assert_active_thread(
 2801            sidebar,
 2802            &session_id_b2,
 2803            "Switching workspace should seed focused_thread from the new active panel",
 2804        );
 2805        assert!(
 2806            has_thread_entry(sidebar, &session_id_b2),
 2807            "The seeded thread should be present in the entries"
 2808        );
 2809    });
 2810
 2811    // ── 8. Focusing the agent panel thread keeps focused_thread ────
 2812    // Workspace B still has session_id_b2 loaded in the agent panel.
 2813    // Clicking into the thread (simulated by focusing its view) should
 2814    // keep focused_thread since it was already seeded on workspace switch.
 2815    panel_b.update_in(cx, |panel, window, cx| {
 2816        if let Some(thread_view) = panel.active_conversation_view() {
 2817            thread_view.read(cx).focus_handle(cx).focus(window, cx);
 2818        }
 2819    });
 2820    cx.run_until_parked();
 2821
 2822    sidebar.read_with(cx, |sidebar, _cx| {
 2823        assert_active_thread(
 2824            sidebar,
 2825            &session_id_b2,
 2826            "Focusing the agent panel thread should set focused_thread",
 2827        );
 2828        assert!(
 2829            has_thread_entry(sidebar, &session_id_b2),
 2830            "The focused thread should be present in the entries"
 2831        );
 2832    });
 2833}
 2834
 2835#[gpui::test]
 2836async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
 2837    let project = init_test_project_with_agent_panel("/project-a", cx).await;
 2838    let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
 2839    let (multi_workspace, cx) =
 2840        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2841    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2842
 2843    // Start a thread and send a message so it has history.
 2844    let connection = StubAgentConnection::new();
 2845    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2846        acp::ContentChunk::new("Done".into()),
 2847    )]);
 2848    open_thread_with_connection(&panel, connection, cx);
 2849    send_message(&panel, cx);
 2850    let session_id = active_session_id(&panel, cx);
 2851    save_test_thread_metadata(&session_id, &project, cx).await;
 2852    cx.run_until_parked();
 2853
 2854    // Verify the thread appears in the sidebar.
 2855    assert_eq!(
 2856        visible_entries_as_strings(&sidebar, cx),
 2857        vec![
 2858            //
 2859            "v [project-a]",
 2860            "  Hello *",
 2861        ]
 2862    );
 2863
 2864    // The "New Thread" button should NOT be in "active/draft" state
 2865    // because the panel has a thread with messages.
 2866    sidebar.read_with(cx, |sidebar, _cx| {
 2867        assert!(
 2868            matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
 2869            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
 2870            sidebar.active_entry,
 2871        );
 2872    });
 2873
 2874    // Now add a second folder to the workspace, changing the path_list.
 2875    fs.as_fake()
 2876        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2877        .await;
 2878    project
 2879        .update(cx, |project, cx| {
 2880            project.find_or_create_worktree("/project-b", true, cx)
 2881        })
 2882        .await
 2883        .expect("should add worktree");
 2884    cx.run_until_parked();
 2885
 2886    // The workspace path_list is now [project-a, project-b]. The active
 2887    // thread's metadata was re-saved with the new paths by the agent panel's
 2888    // project subscription. The old [project-a] key is replaced by the new
 2889    // key since no other workspace claims it.
 2890    let entries = visible_entries_as_strings(&sidebar, cx);
 2891    // After adding a worktree, the thread migrates to the new group key.
 2892    // A reconciliation draft may appear during the transition.
 2893    assert!(
 2894        entries.contains(&"  Hello *".to_string()),
 2895        "thread should still be present after adding folder: {entries:?}"
 2896    );
 2897    assert_eq!(entries[0], "v [project-a, project-b]");
 2898
 2899    // The "New Thread" button must still be clickable (not stuck in
 2900    // "active/draft" state). Verify that `active_thread_is_draft` is
 2901    // false — the panel still has the old thread with messages.
 2902    sidebar.read_with(cx, |sidebar, _cx| {
 2903        assert!(
 2904            matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
 2905            "After adding a folder the panel still has a thread with messages, \
 2906                 so active_entry should be Thread, got {:?}",
 2907            sidebar.active_entry,
 2908        );
 2909    });
 2910
 2911    // Actually click "New Thread" by calling create_new_thread and
 2912    // verify a new draft is created.
 2913    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 2914    sidebar.update_in(cx, |sidebar, window, cx| {
 2915        sidebar.create_new_thread(&workspace, window, cx);
 2916    });
 2917    cx.run_until_parked();
 2918
 2919    // After creating a new thread, the panel should now be in draft
 2920    // state (no messages on the new thread).
 2921    sidebar.read_with(cx, |sidebar, _cx| {
 2922        assert_active_draft(
 2923            sidebar,
 2924            &workspace,
 2925            "After creating a new thread active_entry should be Draft",
 2926        );
 2927    });
 2928}
 2929#[gpui::test]
 2930async fn test_draft_title_updates_from_editor_text(cx: &mut TestAppContext) {
 2931    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2932    let (multi_workspace, cx) =
 2933        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2934    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2935
 2936    // Create a new thread (activates the draft as base view and connects).
 2937    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 2938    let panel = workspace.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
 2939    let connection = StubAgentConnection::new();
 2940    open_thread_with_connection(&panel, connection, cx);
 2941    cx.run_until_parked();
 2942
 2943    // Type into the draft's message editor.
 2944    let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 2945    let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
 2946    message_editor.update_in(cx, |editor, window, cx| {
 2947        editor.set_text("Fix the login bug", window, cx);
 2948    });
 2949    cx.run_until_parked();
 2950
 2951    // The sidebar draft title should now reflect the editor text.
 2952    let draft_title = sidebar.read_with(cx, |sidebar, _cx| {
 2953        sidebar
 2954            .contents
 2955            .entries
 2956            .iter()
 2957            .find_map(|entry| match entry {
 2958                ListEntry::Thread(thread) if thread.is_draft => {
 2959                    Some(thread.metadata.display_title())
 2960                }
 2961                _ => None,
 2962            })
 2963            .expect("should still have a draft entry")
 2964    });
 2965    assert_eq!(
 2966        draft_title.as_ref(),
 2967        "Fix the login bug",
 2968        "draft title should update to match editor text"
 2969    );
 2970}
 2971
 2972#[gpui::test]
 2973async fn test_draft_title_updates_across_two_groups(cx: &mut TestAppContext) {
 2974    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 2975    let (multi_workspace, cx) =
 2976        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2977    let (sidebar, _panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2978
 2979    // Add a second project group.
 2980    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 2981    fs.as_fake()
 2982        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2983        .await;
 2984    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
 2985    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 2986        mw.test_add_workspace(project_b.clone(), window, cx)
 2987    });
 2988    let panel_b = add_agent_panel(&workspace_b, cx);
 2989    cx.run_until_parked();
 2990
 2991    // Open a thread in each group's panel to get Connected state.
 2992    let workspace_a =
 2993        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 2994    let panel_a = workspace_a.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
 2995
 2996    let connection_a = StubAgentConnection::new();
 2997    open_thread_with_connection(&panel_a, connection_a, cx);
 2998    cx.run_until_parked();
 2999
 3000    let connection_b = StubAgentConnection::new();
 3001    open_thread_with_connection(&panel_b, connection_b, cx);
 3002    cx.run_until_parked();
 3003
 3004    // Type into group A's draft editor.
 3005    let thread_view_a = panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3006    let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone());
 3007    editor_a.update_in(cx, |editor, window, cx| {
 3008        editor.set_text("Fix the login bug", window, cx);
 3009    });
 3010    cx.run_until_parked();
 3011
 3012    // Type into group B's draft editor.
 3013    let thread_view_b = panel_b.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3014    let editor_b = thread_view_b.read_with(cx, |view, _cx| view.message_editor.clone());
 3015    editor_b.update_in(cx, |editor, window, cx| {
 3016        editor.set_text("Refactor the database", window, cx);
 3017    });
 3018    cx.run_until_parked();
 3019
 3020    // Both draft titles should reflect their respective editor text.
 3021    let draft_titles: Vec<SharedString> = sidebar.read_with(cx, |sidebar, _cx| {
 3022        sidebar
 3023            .contents
 3024            .entries
 3025            .iter()
 3026            .filter_map(|entry| match entry {
 3027                ListEntry::Thread(thread) if thread.is_draft => {
 3028                    Some(thread.metadata.display_title())
 3029                }
 3030                _ => None,
 3031            })
 3032            .collect()
 3033    });
 3034    assert_eq!(draft_titles.len(), 2, "should still have two drafts");
 3035    assert!(
 3036        draft_titles.contains(&SharedString::from("Fix the login bug")),
 3037        "group A draft should show editor text, got: {:?}",
 3038        draft_titles
 3039    );
 3040    assert!(
 3041        draft_titles.contains(&SharedString::from("Refactor the database")),
 3042        "group B draft should show editor text, got: {:?}",
 3043        draft_titles
 3044    );
 3045}
 3046
 3047#[gpui::test]
 3048async fn test_draft_title_survives_folder_addition(cx: &mut TestAppContext) {
 3049    // When a folder is added to the project, the group key changes.
 3050    // The draft's editor observation should still work and the title
 3051    // should update when the user types.
 3052    init_test(cx);
 3053    let fs = FakeFs::new(cx.executor());
 3054    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 3055        .await;
 3056    fs.insert_tree("/project-b", serde_json::json!({ "lib": {} }))
 3057        .await;
 3058    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3059
 3060    let project = project::Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
 3061    let (multi_workspace, cx) =
 3062        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3063    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3064
 3065    // Create a thread with a connection (has a session_id, considered
 3066    // a draft by the panel until messages are sent).
 3067    let connection = StubAgentConnection::new();
 3068    open_thread_with_connection(&panel, connection, cx);
 3069    cx.run_until_parked();
 3070
 3071    // Type into the editor.
 3072    let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
 3073    let editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
 3074    editor.update_in(cx, |editor, window, cx| {
 3075        editor.set_text("Initial text", window, cx);
 3076    });
 3077    let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
 3078    cx.run_until_parked();
 3079
 3080    // The thread without a title should show the editor text via
 3081    // the draft title override.
 3082    sidebar.read_with(cx, |sidebar, _cx| {
 3083        let thread = sidebar
 3084            .contents
 3085            .entries
 3086            .iter()
 3087            .find_map(|entry| match entry {
 3088                ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
 3089                _ => None,
 3090            });
 3091        assert_eq!(
 3092            thread.and_then(|t| t.metadata.title.as_ref().map(|s| s.as_ref())),
 3093            Some("Initial text"),
 3094            "draft title should show editor text before folder add"
 3095        );
 3096    });
 3097
 3098    // Add a second folder to the project — this changes the group key.
 3099    project
 3100        .update(cx, |project, cx| {
 3101            project.find_or_create_worktree("/project-b", true, cx)
 3102        })
 3103        .await
 3104        .expect("should add worktree");
 3105    cx.run_until_parked();
 3106
 3107    // Update editor text.
 3108    editor.update_in(cx, |editor, window, cx| {
 3109        editor.set_text("Updated after folder add", window, cx);
 3110    });
 3111    cx.run_until_parked();
 3112
 3113    // The draft title should still update. After adding a folder the
 3114    // group key changes, so the thread may not appear in the sidebar
 3115    // if its metadata was saved under the old path list. If it IS
 3116    // found, verify the title was overridden.
 3117    sidebar.read_with(cx, |sidebar, _cx| {
 3118        let thread = sidebar
 3119            .contents
 3120            .entries
 3121            .iter()
 3122            .find_map(|entry| match entry {
 3123                ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
 3124                _ => None,
 3125            });
 3126        if let Some(thread) = thread {
 3127            assert_eq!(
 3128                thread.metadata.title.as_ref().map(|s| s.as_ref()),
 3129                Some("Updated after folder add"),
 3130                "draft title should update even after adding a folder"
 3131            );
 3132        }
 3133    });
 3134}
 3135
 3136#[gpui::test]
 3137async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
 3138    // When the user presses Cmd-N (NewThread action) while viewing a
 3139    // non-empty thread, the sidebar should show the "New Thread" entry.
 3140    // This exercises the same code path as the workspace action handler
 3141    // (which bypasses the sidebar's create_new_thread method).
 3142    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3143    let (multi_workspace, cx) =
 3144        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3145    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3146
 3147    // Create a non-empty thread (has messages).
 3148    let connection = StubAgentConnection::new();
 3149    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3150        acp::ContentChunk::new("Done".into()),
 3151    )]);
 3152    open_thread_with_connection(&panel, connection, cx);
 3153    send_message(&panel, cx);
 3154
 3155    let session_id = active_session_id(&panel, cx);
 3156    save_test_thread_metadata(&session_id, &project, cx).await;
 3157    cx.run_until_parked();
 3158
 3159    assert_eq!(
 3160        visible_entries_as_strings(&sidebar, cx),
 3161        vec![
 3162            //
 3163            "v [my-project]",
 3164            "  Hello *",
 3165        ]
 3166    );
 3167
 3168    // Simulate cmd-n
 3169    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 3170    panel.update_in(cx, |panel, window, cx| {
 3171        panel.new_thread(&NewThread, window, cx);
 3172    });
 3173    workspace.update_in(cx, |workspace, window, cx| {
 3174        workspace.focus_panel::<AgentPanel>(window, cx);
 3175    });
 3176    cx.run_until_parked();
 3177
 3178    assert_eq!(
 3179        visible_entries_as_strings(&sidebar, cx),
 3180        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3181        "After Cmd-N the sidebar should show a highlighted Draft entry"
 3182    );
 3183
 3184    sidebar.read_with(cx, |sidebar, _cx| {
 3185        assert_active_draft(
 3186            sidebar,
 3187            &workspace,
 3188            "active_entry should be Draft after Cmd-N",
 3189        );
 3190    });
 3191}
 3192
 3193#[gpui::test]
 3194async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
 3195    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3196    let (multi_workspace, cx) =
 3197        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3198    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3199
 3200    // Create a saved thread so the workspace has history.
 3201    let connection = StubAgentConnection::new();
 3202    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3203        acp::ContentChunk::new("Done".into()),
 3204    )]);
 3205    open_thread_with_connection(&panel, connection, cx);
 3206    send_message(&panel, cx);
 3207    let saved_session_id = active_session_id(&panel, cx);
 3208    save_test_thread_metadata(&saved_session_id, &project, cx).await;
 3209    cx.run_until_parked();
 3210
 3211    assert_eq!(
 3212        visible_entries_as_strings(&sidebar, cx),
 3213        vec![
 3214            //
 3215            "v [my-project]",
 3216            "  Hello *",
 3217        ]
 3218    );
 3219
 3220    // Create a new draft via Cmd-N. Since new_thread() now creates a
 3221    // tracked draft in the AgentPanel, it appears in the sidebar.
 3222    panel.update_in(cx, |panel, window, cx| {
 3223        panel.new_thread(&NewThread, window, cx);
 3224    });
 3225    cx.run_until_parked();
 3226
 3227    assert_eq!(
 3228        visible_entries_as_strings(&sidebar, cx),
 3229        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3230    );
 3231
 3232    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 3233    sidebar.read_with(cx, |sidebar, _cx| {
 3234        assert_active_draft(
 3235            sidebar,
 3236            &workspace,
 3237            "Draft with server session should be Draft, not Thread",
 3238        );
 3239    });
 3240}
 3241
 3242#[gpui::test]
 3243async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext) {
 3244    // When the user sends a message from a draft thread, the draft
 3245    // should be removed from the sidebar and the active_entry should
 3246    // transition to a Thread pointing at the new session.
 3247    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 3248    let (multi_workspace, cx) =
 3249        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3250    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 3251
 3252    // Create a saved thread so the group isn't empty.
 3253    let connection = StubAgentConnection::new();
 3254    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3255        acp::ContentChunk::new("Done".into()),
 3256    )]);
 3257    open_thread_with_connection(&panel, connection, cx);
 3258    send_message(&panel, cx);
 3259    let existing_session_id = active_session_id(&panel, cx);
 3260    save_test_thread_metadata(&existing_session_id, &project, cx).await;
 3261    cx.run_until_parked();
 3262
 3263    // Create a draft via Cmd-N.
 3264    panel.update_in(cx, |panel, window, cx| {
 3265        panel.new_thread(&NewThread, window, cx);
 3266    });
 3267    cx.run_until_parked();
 3268
 3269    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 3270    assert_eq!(
 3271        visible_entries_as_strings(&sidebar, cx),
 3272        vec!["v [my-project]", "  [~ Draft] *", "  Hello *"],
 3273        "draft should be visible before sending",
 3274    );
 3275    sidebar.read_with(cx, |sidebar, _| {
 3276        assert_active_draft(sidebar, &workspace, "should be on draft before sending");
 3277    });
 3278
 3279    // Simulate what happens when a draft sends its first message:
 3280    // the AgentPanel's MessageSentOrQueued handler removes the draft
 3281    // from `draft_threads`, then the sidebar rebuilds. We can't use
 3282    // the NativeAgentServer in tests, so replicate the key steps:
 3283    // remove the draft, open a real thread with a stub connection,
 3284    // and send.
 3285    let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
 3286    panel.update_in(cx, |panel, _window, cx| {
 3287        panel.remove_thread(thread_id, cx);
 3288    });
 3289    let draft_connection = StubAgentConnection::new();
 3290    draft_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3291        acp::ContentChunk::new("World".into()),
 3292    )]);
 3293    open_thread_with_connection(&panel, draft_connection, cx);
 3294    send_message(&panel, cx);
 3295    let new_session_id = active_session_id(&panel, cx);
 3296    save_test_thread_metadata(&new_session_id, &project, cx).await;
 3297    cx.run_until_parked();
 3298
 3299    // The draft should be gone and the new thread should be active.
 3300    let entries = visible_entries_as_strings(&sidebar, cx);
 3301    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 3302    assert_eq!(
 3303        draft_count, 0,
 3304        "draft should be removed after sending a message"
 3305    );
 3306
 3307    sidebar.read_with(cx, |sidebar, _| {
 3308        assert_active_thread(
 3309            sidebar,
 3310            &new_session_id,
 3311            "active_entry should transition to the new thread after sending",
 3312        );
 3313    });
 3314}
 3315
 3316#[gpui::test]
 3317async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
 3318    // When the active workspace is an absorbed git worktree, cmd-n
 3319    // should still show the "New Thread" entry under the main repo's
 3320    // header and highlight it as active.
 3321    agent_ui::test_support::init_test(cx);
 3322    cx.update(|cx| {
 3323        ThreadStore::init_global(cx);
 3324        ThreadMetadataStore::init_global(cx);
 3325        language_model::LanguageModelRegistry::test(cx);
 3326        prompt_store::init(cx);
 3327    });
 3328
 3329    let fs = FakeFs::new(cx.executor());
 3330
 3331    // Main repo with a linked worktree.
 3332    fs.insert_tree(
 3333        "/project",
 3334        serde_json::json!({
 3335            ".git": {},
 3336            "src": {},
 3337        }),
 3338    )
 3339    .await;
 3340
 3341    // Worktree checkout pointing back to the main repo.
 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-a"),
 3347            ref_name: Some("refs/heads/feature-a".into()),
 3348            sha: "aaa".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    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3358    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3359
 3360    main_project
 3361        .update(cx, |p, cx| p.git_scans_complete(cx))
 3362        .await;
 3363    worktree_project
 3364        .update(cx, |p, cx| p.git_scans_complete(cx))
 3365        .await;
 3366
 3367    let (multi_workspace, cx) =
 3368        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3369
 3370    let sidebar = setup_sidebar(&multi_workspace, cx);
 3371
 3372    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3373        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3374    });
 3375
 3376    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3377
 3378    // Switch to the worktree workspace.
 3379    multi_workspace.update_in(cx, |mw, window, cx| {
 3380        let workspace = mw.workspaces().nth(1).unwrap().clone();
 3381        mw.activate(workspace, window, cx);
 3382    });
 3383
 3384    // Create a non-empty thread in the worktree workspace.
 3385    let connection = StubAgentConnection::new();
 3386    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3387        acp::ContentChunk::new("Done".into()),
 3388    )]);
 3389    open_thread_with_connection(&worktree_panel, connection, cx);
 3390    send_message(&worktree_panel, cx);
 3391
 3392    let session_id = active_session_id(&worktree_panel, cx);
 3393    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3394    cx.run_until_parked();
 3395
 3396    assert_eq!(
 3397        visible_entries_as_strings(&sidebar, cx),
 3398        vec![
 3399            //
 3400            "v [project]",
 3401            "  Hello {wt-feature-a} *",
 3402        ]
 3403    );
 3404
 3405    // Simulate Cmd-N in the worktree workspace.
 3406    worktree_panel.update_in(cx, |panel, window, cx| {
 3407        panel.new_thread(&NewThread, window, cx);
 3408    });
 3409    worktree_workspace.update_in(cx, |workspace, window, cx| {
 3410        workspace.focus_panel::<AgentPanel>(window, cx);
 3411    });
 3412    cx.run_until_parked();
 3413
 3414    assert_eq!(
 3415        visible_entries_as_strings(&sidebar, cx),
 3416        vec![
 3417            //
 3418            "v [project]",
 3419            "  [~ Draft {wt-feature-a}] *",
 3420            "  Hello {wt-feature-a} *"
 3421        ],
 3422        "After Cmd-N in an absorbed worktree, the sidebar should show \
 3423             a highlighted Draft entry under the main repo header"
 3424    );
 3425
 3426    sidebar.read_with(cx, |sidebar, _cx| {
 3427        assert_active_draft(
 3428            sidebar,
 3429            &worktree_workspace,
 3430            "active_entry should be Draft after Cmd-N",
 3431        );
 3432    });
 3433}
 3434
 3435async fn init_test_project_with_git(
 3436    worktree_path: &str,
 3437    cx: &mut TestAppContext,
 3438) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
 3439    init_test(cx);
 3440    let fs = FakeFs::new(cx.executor());
 3441    fs.insert_tree(
 3442        worktree_path,
 3443        serde_json::json!({
 3444            ".git": {},
 3445            "src": {},
 3446        }),
 3447    )
 3448    .await;
 3449    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3450    let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
 3451    (project, fs)
 3452}
 3453
 3454#[gpui::test]
 3455async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
 3456    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3457
 3458    fs.as_fake()
 3459        .add_linked_worktree_for_repo(
 3460            Path::new("/project/.git"),
 3461            false,
 3462            git::repository::Worktree {
 3463                path: std::path::PathBuf::from("/wt/rosewood"),
 3464                ref_name: Some("refs/heads/rosewood".into()),
 3465                sha: "abc".into(),
 3466                is_main: false,
 3467                is_bare: false,
 3468            },
 3469        )
 3470        .await;
 3471
 3472    project
 3473        .update(cx, |project, cx| project.git_scans_complete(cx))
 3474        .await;
 3475
 3476    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3477    worktree_project
 3478        .update(cx, |p, cx| p.git_scans_complete(cx))
 3479        .await;
 3480
 3481    let (multi_workspace, cx) =
 3482        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3483    let sidebar = setup_sidebar(&multi_workspace, cx);
 3484
 3485    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
 3486    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
 3487
 3488    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3489    cx.run_until_parked();
 3490
 3491    // Search for "rosewood" — should match the worktree name, not the title.
 3492    type_in_search(&sidebar, "rosewood", cx);
 3493
 3494    assert_eq!(
 3495        visible_entries_as_strings(&sidebar, cx),
 3496        vec![
 3497            //
 3498            "v [project]",
 3499            "  Fix Bug {rosewood}  <== selected",
 3500        ],
 3501    );
 3502}
 3503
 3504#[gpui::test]
 3505async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
 3506    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3507
 3508    project
 3509        .update(cx, |project, cx| project.git_scans_complete(cx))
 3510        .await;
 3511
 3512    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3513    worktree_project
 3514        .update(cx, |p, cx| p.git_scans_complete(cx))
 3515        .await;
 3516
 3517    let (multi_workspace, cx) =
 3518        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3519    let sidebar = setup_sidebar(&multi_workspace, cx);
 3520
 3521    // Save a thread against a worktree path with the correct main
 3522    // worktree association (as if the git state had been resolved).
 3523    save_thread_metadata_with_main_paths(
 3524        "wt-thread",
 3525        "Worktree Thread",
 3526        PathList::new(&[PathBuf::from("/wt/rosewood")]),
 3527        PathList::new(&[PathBuf::from("/project")]),
 3528        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3529        cx,
 3530    );
 3531
 3532    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3533    cx.run_until_parked();
 3534
 3535    // Thread is visible because its main_worktree_paths match the group.
 3536    // The chip name is derived from the path even before git discovery.
 3537    assert_eq!(
 3538        visible_entries_as_strings(&sidebar, cx),
 3539        vec!["v [project]", "  Worktree Thread {rosewood}"]
 3540    );
 3541
 3542    // Now add the worktree to the git state and trigger a rescan.
 3543    fs.as_fake()
 3544        .add_linked_worktree_for_repo(
 3545            Path::new("/project/.git"),
 3546            true,
 3547            git::repository::Worktree {
 3548                path: std::path::PathBuf::from("/wt/rosewood"),
 3549                ref_name: Some("refs/heads/rosewood".into()),
 3550                sha: "abc".into(),
 3551                is_main: false,
 3552                is_bare: false,
 3553            },
 3554        )
 3555        .await;
 3556
 3557    cx.run_until_parked();
 3558
 3559    assert_eq!(
 3560        visible_entries_as_strings(&sidebar, cx),
 3561        vec![
 3562            //
 3563            "v [project]",
 3564            "  Worktree Thread {rosewood}",
 3565        ]
 3566    );
 3567}
 3568
 3569#[gpui::test]
 3570async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
 3571    init_test(cx);
 3572    let fs = FakeFs::new(cx.executor());
 3573
 3574    // Create the main repo directory (not opened as a workspace yet).
 3575    fs.insert_tree(
 3576        "/project",
 3577        serde_json::json!({
 3578            ".git": {
 3579            },
 3580            "src": {},
 3581        }),
 3582    )
 3583    .await;
 3584
 3585    // Two worktree checkouts whose .git files point back to the main repo.
 3586    fs.add_linked_worktree_for_repo(
 3587        Path::new("/project/.git"),
 3588        false,
 3589        git::repository::Worktree {
 3590            path: std::path::PathBuf::from("/wt-feature-a"),
 3591            ref_name: Some("refs/heads/feature-a".into()),
 3592            sha: "aaa".into(),
 3593            is_main: false,
 3594            is_bare: false,
 3595        },
 3596    )
 3597    .await;
 3598    fs.add_linked_worktree_for_repo(
 3599        Path::new("/project/.git"),
 3600        false,
 3601        git::repository::Worktree {
 3602            path: std::path::PathBuf::from("/wt-feature-b"),
 3603            ref_name: Some("refs/heads/feature-b".into()),
 3604            sha: "bbb".into(),
 3605            is_main: false,
 3606            is_bare: false,
 3607        },
 3608    )
 3609    .await;
 3610
 3611    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3612
 3613    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3614    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3615
 3616    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3617    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3618
 3619    // Open both worktrees as workspaces — no main repo yet.
 3620    let (multi_workspace, cx) =
 3621        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3622    multi_workspace.update_in(cx, |mw, window, cx| {
 3623        mw.test_add_workspace(project_b.clone(), window, cx);
 3624    });
 3625    let sidebar = setup_sidebar(&multi_workspace, cx);
 3626
 3627    save_thread_metadata(
 3628        acp::SessionId::new(Arc::from("thread-a")),
 3629        Some("Thread A".into()),
 3630        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3631        None,
 3632        &project_a,
 3633        cx,
 3634    );
 3635    save_thread_metadata(
 3636        acp::SessionId::new(Arc::from("thread-b")),
 3637        Some("Thread B".into()),
 3638        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 3639        None,
 3640        &project_b,
 3641        cx,
 3642    );
 3643
 3644    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3645    cx.run_until_parked();
 3646
 3647    // Without the main repo, each worktree has its own header.
 3648    assert_eq!(
 3649        visible_entries_as_strings(&sidebar, cx),
 3650        vec![
 3651            //
 3652            "v [project]",
 3653            "  Thread B {wt-feature-b}",
 3654            "  Thread A {wt-feature-a}",
 3655        ]
 3656    );
 3657
 3658    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3659    main_project
 3660        .update(cx, |p, cx| p.git_scans_complete(cx))
 3661        .await;
 3662
 3663    multi_workspace.update_in(cx, |mw, window, cx| {
 3664        mw.test_add_workspace(main_project.clone(), window, cx);
 3665    });
 3666    cx.run_until_parked();
 3667
 3668    // Both worktree workspaces should now be absorbed under the main
 3669    // repo header, with worktree chips.
 3670    assert_eq!(
 3671        visible_entries_as_strings(&sidebar, cx),
 3672        vec![
 3673            //
 3674            "v [project]",
 3675            "  Thread B {wt-feature-b}",
 3676            "  Thread A {wt-feature-a}",
 3677        ]
 3678    );
 3679}
 3680
 3681#[gpui::test]
 3682async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
 3683    // When a group has two workspaces — one with threads and one
 3684    // without — the threadless workspace should appear as a
 3685    // "New Thread" button with its worktree chip.
 3686    init_test(cx);
 3687    let fs = FakeFs::new(cx.executor());
 3688
 3689    // Main repo with two linked worktrees.
 3690    fs.insert_tree(
 3691        "/project",
 3692        serde_json::json!({
 3693            ".git": {},
 3694            "src": {},
 3695        }),
 3696    )
 3697    .await;
 3698    fs.add_linked_worktree_for_repo(
 3699        Path::new("/project/.git"),
 3700        false,
 3701        git::repository::Worktree {
 3702            path: std::path::PathBuf::from("/wt-feature-a"),
 3703            ref_name: Some("refs/heads/feature-a".into()),
 3704            sha: "aaa".into(),
 3705            is_main: false,
 3706            is_bare: false,
 3707        },
 3708    )
 3709    .await;
 3710    fs.add_linked_worktree_for_repo(
 3711        Path::new("/project/.git"),
 3712        false,
 3713        git::repository::Worktree {
 3714            path: std::path::PathBuf::from("/wt-feature-b"),
 3715            ref_name: Some("refs/heads/feature-b".into()),
 3716            sha: "bbb".into(),
 3717            is_main: false,
 3718            is_bare: false,
 3719        },
 3720    )
 3721    .await;
 3722
 3723    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3724
 3725    // Workspace A: worktree feature-a (has threads).
 3726    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3727    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3728
 3729    // Workspace B: worktree feature-b (no threads).
 3730    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3731    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3732
 3733    let (multi_workspace, cx) =
 3734        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3735    multi_workspace.update_in(cx, |mw, window, cx| {
 3736        mw.test_add_workspace(project_b.clone(), window, cx);
 3737    });
 3738    let sidebar = setup_sidebar(&multi_workspace, cx);
 3739
 3740    // Only save a thread for workspace A.
 3741    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
 3742
 3743    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3744    cx.run_until_parked();
 3745
 3746    // Workspace A's thread appears normally. Workspace B (threadless)
 3747    // appears as a "New Thread" button with its worktree chip.
 3748    assert_eq!(
 3749        visible_entries_as_strings(&sidebar, cx),
 3750        vec!["v [project]", "  Thread A {wt-feature-a}",]
 3751    );
 3752}
 3753
 3754#[gpui::test]
 3755async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
 3756    // A thread created in a workspace with roots from different git
 3757    // worktrees should show a chip for each distinct worktree name.
 3758    init_test(cx);
 3759    let fs = FakeFs::new(cx.executor());
 3760
 3761    // Two main repos.
 3762    fs.insert_tree(
 3763        "/project_a",
 3764        serde_json::json!({
 3765            ".git": {},
 3766            "src": {},
 3767        }),
 3768    )
 3769    .await;
 3770    fs.insert_tree(
 3771        "/project_b",
 3772        serde_json::json!({
 3773            ".git": {},
 3774            "src": {},
 3775        }),
 3776    )
 3777    .await;
 3778
 3779    // Worktree checkouts.
 3780    for repo in &["project_a", "project_b"] {
 3781        let git_path = format!("/{repo}/.git");
 3782        for branch in &["olivetti", "selectric"] {
 3783            fs.add_linked_worktree_for_repo(
 3784                Path::new(&git_path),
 3785                false,
 3786                git::repository::Worktree {
 3787                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
 3788                    ref_name: Some(format!("refs/heads/{branch}").into()),
 3789                    sha: "aaa".into(),
 3790                    is_main: false,
 3791                    is_bare: false,
 3792                },
 3793            )
 3794            .await;
 3795        }
 3796    }
 3797
 3798    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3799
 3800    // Open a workspace with the worktree checkout paths as roots
 3801    // (this is the workspace the thread was created in).
 3802    let project = project::Project::test(
 3803        fs.clone(),
 3804        [
 3805            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3806            "/worktrees/project_b/selectric/project_b".as_ref(),
 3807        ],
 3808        cx,
 3809    )
 3810    .await;
 3811    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3812
 3813    let (multi_workspace, cx) =
 3814        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3815    let sidebar = setup_sidebar(&multi_workspace, cx);
 3816
 3817    // Save a thread under the same paths as the workspace roots.
 3818    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
 3819
 3820    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3821    cx.run_until_parked();
 3822
 3823    // Should show two distinct worktree chips.
 3824    assert_eq!(
 3825        visible_entries_as_strings(&sidebar, cx),
 3826        vec![
 3827            //
 3828            "v [project_a, project_b]",
 3829            "  Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
 3830        ]
 3831    );
 3832}
 3833
 3834#[gpui::test]
 3835async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
 3836    // When a thread's roots span multiple repos but share the same
 3837    // worktree name (e.g. both in "olivetti"), only one chip should
 3838    // appear.
 3839    init_test(cx);
 3840    let fs = FakeFs::new(cx.executor());
 3841
 3842    fs.insert_tree(
 3843        "/project_a",
 3844        serde_json::json!({
 3845            ".git": {},
 3846            "src": {},
 3847        }),
 3848    )
 3849    .await;
 3850    fs.insert_tree(
 3851        "/project_b",
 3852        serde_json::json!({
 3853            ".git": {},
 3854            "src": {},
 3855        }),
 3856    )
 3857    .await;
 3858
 3859    for repo in &["project_a", "project_b"] {
 3860        let git_path = format!("/{repo}/.git");
 3861        fs.add_linked_worktree_for_repo(
 3862            Path::new(&git_path),
 3863            false,
 3864            git::repository::Worktree {
 3865                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
 3866                ref_name: Some("refs/heads/olivetti".into()),
 3867                sha: "aaa".into(),
 3868                is_main: false,
 3869                is_bare: false,
 3870            },
 3871        )
 3872        .await;
 3873    }
 3874
 3875    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3876
 3877    let project = project::Project::test(
 3878        fs.clone(),
 3879        [
 3880            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3881            "/worktrees/project_b/olivetti/project_b".as_ref(),
 3882        ],
 3883        cx,
 3884    )
 3885    .await;
 3886    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3887
 3888    let (multi_workspace, cx) =
 3889        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3890    let sidebar = setup_sidebar(&multi_workspace, cx);
 3891
 3892    // Thread with roots in both repos' "olivetti" worktrees.
 3893    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
 3894
 3895    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3896    cx.run_until_parked();
 3897
 3898    // Both worktree paths have the name "olivetti", so only one chip.
 3899    assert_eq!(
 3900        visible_entries_as_strings(&sidebar, cx),
 3901        vec![
 3902            //
 3903            "v [project_a, project_b]",
 3904            "  Same Branch Thread {olivetti}",
 3905        ]
 3906    );
 3907}
 3908
 3909#[gpui::test]
 3910async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
 3911    // When a worktree workspace is absorbed under the main repo, a
 3912    // running thread in the worktree's agent panel should still show
 3913    // live status (spinner + "(running)") in the sidebar.
 3914    agent_ui::test_support::init_test(cx);
 3915    cx.update(|cx| {
 3916        ThreadStore::init_global(cx);
 3917        ThreadMetadataStore::init_global(cx);
 3918        language_model::LanguageModelRegistry::test(cx);
 3919        prompt_store::init(cx);
 3920    });
 3921
 3922    let fs = FakeFs::new(cx.executor());
 3923
 3924    // Main repo with a linked worktree.
 3925    fs.insert_tree(
 3926        "/project",
 3927        serde_json::json!({
 3928            ".git": {},
 3929            "src": {},
 3930        }),
 3931    )
 3932    .await;
 3933
 3934    // Worktree checkout pointing back to the main repo.
 3935    fs.add_linked_worktree_for_repo(
 3936        Path::new("/project/.git"),
 3937        false,
 3938        git::repository::Worktree {
 3939            path: std::path::PathBuf::from("/wt-feature-a"),
 3940            ref_name: Some("refs/heads/feature-a".into()),
 3941            sha: "aaa".into(),
 3942            is_main: false,
 3943            is_bare: false,
 3944        },
 3945    )
 3946    .await;
 3947
 3948    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3949
 3950    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3951    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3952
 3953    main_project
 3954        .update(cx, |p, cx| p.git_scans_complete(cx))
 3955        .await;
 3956    worktree_project
 3957        .update(cx, |p, cx| p.git_scans_complete(cx))
 3958        .await;
 3959
 3960    // Create the MultiWorkspace with both projects.
 3961    let (multi_workspace, cx) =
 3962        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3963
 3964    let sidebar = setup_sidebar(&multi_workspace, cx);
 3965
 3966    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3967        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3968    });
 3969
 3970    // Add an agent panel to the worktree workspace so we can run a
 3971    // thread inside it.
 3972    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3973
 3974    // Switch back to the main workspace before setting up the sidebar.
 3975    multi_workspace.update_in(cx, |mw, window, cx| {
 3976        let workspace = mw.workspaces().next().unwrap().clone();
 3977        mw.activate(workspace, window, cx);
 3978    });
 3979
 3980    // Start a thread in the worktree workspace's panel and keep it
 3981    // generating (don't resolve it).
 3982    let connection = StubAgentConnection::new();
 3983    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 3984    send_message(&worktree_panel, cx);
 3985
 3986    let session_id = active_session_id(&worktree_panel, cx);
 3987
 3988    // Save metadata so the sidebar knows about this thread.
 3989    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3990
 3991    // Keep the thread generating by sending a chunk without ending
 3992    // the turn.
 3993    cx.update(|_, cx| {
 3994        connection.send_update(
 3995            session_id.clone(),
 3996            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 3997            cx,
 3998        );
 3999    });
 4000    cx.run_until_parked();
 4001
 4002    // The worktree thread should be absorbed under the main project
 4003    // and show live running status.
 4004    let entries = visible_entries_as_strings(&sidebar, cx);
 4005    assert_eq!(
 4006        entries,
 4007        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 4008    );
 4009}
 4010
 4011#[gpui::test]
 4012async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
 4013    agent_ui::test_support::init_test(cx);
 4014    cx.update(|cx| {
 4015        ThreadStore::init_global(cx);
 4016        ThreadMetadataStore::init_global(cx);
 4017        language_model::LanguageModelRegistry::test(cx);
 4018        prompt_store::init(cx);
 4019    });
 4020
 4021    let fs = FakeFs::new(cx.executor());
 4022
 4023    fs.insert_tree(
 4024        "/project",
 4025        serde_json::json!({
 4026            ".git": {},
 4027            "src": {},
 4028        }),
 4029    )
 4030    .await;
 4031
 4032    fs.add_linked_worktree_for_repo(
 4033        Path::new("/project/.git"),
 4034        false,
 4035        git::repository::Worktree {
 4036            path: std::path::PathBuf::from("/wt-feature-a"),
 4037            ref_name: Some("refs/heads/feature-a".into()),
 4038            sha: "aaa".into(),
 4039            is_main: false,
 4040            is_bare: false,
 4041        },
 4042    )
 4043    .await;
 4044
 4045    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4046
 4047    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4048    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4049
 4050    main_project
 4051        .update(cx, |p, cx| p.git_scans_complete(cx))
 4052        .await;
 4053    worktree_project
 4054        .update(cx, |p, cx| p.git_scans_complete(cx))
 4055        .await;
 4056
 4057    let (multi_workspace, cx) =
 4058        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4059
 4060    let sidebar = setup_sidebar(&multi_workspace, cx);
 4061
 4062    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4063        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4064    });
 4065
 4066    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 4067
 4068    multi_workspace.update_in(cx, |mw, window, cx| {
 4069        let workspace = mw.workspaces().next().unwrap().clone();
 4070        mw.activate(workspace, window, cx);
 4071    });
 4072
 4073    let connection = StubAgentConnection::new();
 4074    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 4075    send_message(&worktree_panel, cx);
 4076
 4077    let session_id = active_session_id(&worktree_panel, cx);
 4078    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 4079
 4080    cx.update(|_, cx| {
 4081        connection.send_update(
 4082            session_id.clone(),
 4083            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 4084            cx,
 4085        );
 4086    });
 4087    cx.run_until_parked();
 4088
 4089    assert_eq!(
 4090        visible_entries_as_strings(&sidebar, cx),
 4091        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 4092    );
 4093
 4094    connection.end_turn(session_id, acp::StopReason::EndTurn);
 4095    cx.run_until_parked();
 4096
 4097    assert_eq!(
 4098        visible_entries_as_strings(&sidebar, cx),
 4099        vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
 4100    );
 4101}
 4102
 4103#[gpui::test]
 4104async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
 4105    init_test(cx);
 4106    let fs = FakeFs::new(cx.executor());
 4107
 4108    fs.insert_tree(
 4109        "/project",
 4110        serde_json::json!({
 4111            ".git": {},
 4112            "src": {},
 4113        }),
 4114    )
 4115    .await;
 4116
 4117    fs.add_linked_worktree_for_repo(
 4118        Path::new("/project/.git"),
 4119        false,
 4120        git::repository::Worktree {
 4121            path: std::path::PathBuf::from("/wt-feature-a"),
 4122            ref_name: Some("refs/heads/feature-a".into()),
 4123            sha: "aaa".into(),
 4124            is_main: false,
 4125            is_bare: false,
 4126        },
 4127    )
 4128    .await;
 4129
 4130    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4131
 4132    // Only open the main repo — no workspace for the worktree.
 4133    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4134    main_project
 4135        .update(cx, |p, cx| p.git_scans_complete(cx))
 4136        .await;
 4137
 4138    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4139    worktree_project
 4140        .update(cx, |p, cx| p.git_scans_complete(cx))
 4141        .await;
 4142
 4143    let (multi_workspace, cx) =
 4144        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4145    let sidebar = setup_sidebar(&multi_workspace, cx);
 4146
 4147    // Save a thread for the worktree path (no workspace for it).
 4148    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4149
 4150    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4151    cx.run_until_parked();
 4152
 4153    // Thread should appear under the main repo with a worktree chip.
 4154    assert_eq!(
 4155        visible_entries_as_strings(&sidebar, cx),
 4156        vec![
 4157            //
 4158            "v [project]",
 4159            "  WT Thread {wt-feature-a}",
 4160        ],
 4161    );
 4162
 4163    // Only 1 workspace should exist.
 4164    assert_eq!(
 4165        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4166        1,
 4167    );
 4168
 4169    // Focus the sidebar and select the worktree thread.
 4170    focus_sidebar(&sidebar, cx);
 4171    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4172        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 4173    });
 4174
 4175    // Confirm to open the worktree thread.
 4176    cx.dispatch_action(Confirm);
 4177    cx.run_until_parked();
 4178
 4179    // A new workspace should have been created for the worktree path.
 4180    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
 4181        assert_eq!(
 4182            mw.workspaces().count(),
 4183            2,
 4184            "confirming a worktree thread without a workspace should open one",
 4185        );
 4186        mw.workspaces().nth(1).unwrap().clone()
 4187    });
 4188
 4189    let new_path_list =
 4190        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 4191    assert_eq!(
 4192        new_path_list,
 4193        PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
 4194        "the new workspace should have been opened for the worktree path",
 4195    );
 4196}
 4197
 4198#[gpui::test]
 4199async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
 4200    cx: &mut TestAppContext,
 4201) {
 4202    init_test(cx);
 4203    let fs = FakeFs::new(cx.executor());
 4204
 4205    fs.insert_tree(
 4206        "/project",
 4207        serde_json::json!({
 4208            ".git": {},
 4209            "src": {},
 4210        }),
 4211    )
 4212    .await;
 4213
 4214    fs.add_linked_worktree_for_repo(
 4215        Path::new("/project/.git"),
 4216        false,
 4217        git::repository::Worktree {
 4218            path: std::path::PathBuf::from("/wt-feature-a"),
 4219            ref_name: Some("refs/heads/feature-a".into()),
 4220            sha: "aaa".into(),
 4221            is_main: false,
 4222            is_bare: false,
 4223        },
 4224    )
 4225    .await;
 4226
 4227    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4228
 4229    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4230    main_project
 4231        .update(cx, |p, cx| p.git_scans_complete(cx))
 4232        .await;
 4233
 4234    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4235    worktree_project
 4236        .update(cx, |p, cx| p.git_scans_complete(cx))
 4237        .await;
 4238
 4239    let (multi_workspace, cx) =
 4240        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4241    let sidebar = setup_sidebar(&multi_workspace, cx);
 4242
 4243    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4244
 4245    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4246    cx.run_until_parked();
 4247
 4248    assert_eq!(
 4249        visible_entries_as_strings(&sidebar, cx),
 4250        vec![
 4251            //
 4252            "v [project]",
 4253            "  WT Thread {wt-feature-a}",
 4254        ],
 4255    );
 4256
 4257    focus_sidebar(&sidebar, cx);
 4258    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4259        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 4260    });
 4261
 4262    let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
 4263        let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
 4264            if let ListEntry::ProjectHeader { label, .. } = entry {
 4265                Some(label.as_ref())
 4266            } else {
 4267                None
 4268            }
 4269        });
 4270
 4271        let Some(project_header) = project_headers.next() else {
 4272            panic!("expected exactly one sidebar project header named `project`, found none");
 4273        };
 4274        assert_eq!(
 4275            project_header, "project",
 4276            "expected the only sidebar project header to be `project`"
 4277        );
 4278        if let Some(unexpected_header) = project_headers.next() {
 4279            panic!(
 4280                "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
 4281            );
 4282        }
 4283
 4284        let mut saw_expected_thread = false;
 4285        for entry in &sidebar.contents.entries {
 4286            match entry {
 4287                ListEntry::ProjectHeader { label, .. } => {
 4288                    assert_eq!(
 4289                        label.as_ref(),
 4290                        "project",
 4291                        "expected the only sidebar project header to be `project`"
 4292                    );
 4293                }
 4294                ListEntry::Thread(thread)
 4295                    if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
 4296                        && thread.worktrees.first().map(|wt| wt.name.as_ref())
 4297                            == Some("wt-feature-a") =>
 4298                {
 4299                    saw_expected_thread = true;
 4300                }
 4301                ListEntry::Thread(thread) if thread.is_draft => {}
 4302                ListEntry::Thread(thread) => {
 4303                    let title = thread.metadata.display_title();
 4304                    let worktree_name = thread
 4305                        .worktrees
 4306                        .first()
 4307                        .map(|wt| wt.name.as_ref())
 4308                        .unwrap_or("<none>");
 4309                    panic!(
 4310                        "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
 4311                        title, worktree_name
 4312                    );
 4313                }
 4314                ListEntry::ViewMore { .. } => {
 4315                    panic!("unexpected `View More` entry while opening linked worktree thread");
 4316                }
 4317            }
 4318        }
 4319
 4320        assert!(
 4321            saw_expected_thread,
 4322            "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
 4323        );
 4324    };
 4325
 4326    sidebar
 4327        .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
 4328        .detach();
 4329
 4330    let window = cx.windows()[0];
 4331    cx.update_window(window, |_, window, cx| {
 4332        window.dispatch_action(Confirm.boxed_clone(), cx);
 4333    })
 4334    .unwrap();
 4335
 4336    cx.run_until_parked();
 4337
 4338    sidebar.update(cx, assert_sidebar_state);
 4339}
 4340
 4341#[gpui::test]
 4342async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
 4343    cx: &mut TestAppContext,
 4344) {
 4345    init_test(cx);
 4346    let fs = FakeFs::new(cx.executor());
 4347
 4348    fs.insert_tree(
 4349        "/project",
 4350        serde_json::json!({
 4351            ".git": {},
 4352            "src": {},
 4353        }),
 4354    )
 4355    .await;
 4356
 4357    fs.add_linked_worktree_for_repo(
 4358        Path::new("/project/.git"),
 4359        false,
 4360        git::repository::Worktree {
 4361            path: std::path::PathBuf::from("/wt-feature-a"),
 4362            ref_name: Some("refs/heads/feature-a".into()),
 4363            sha: "aaa".into(),
 4364            is_main: false,
 4365            is_bare: false,
 4366        },
 4367    )
 4368    .await;
 4369
 4370    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4371
 4372    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4373    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4374
 4375    main_project
 4376        .update(cx, |p, cx| p.git_scans_complete(cx))
 4377        .await;
 4378    worktree_project
 4379        .update(cx, |p, cx| p.git_scans_complete(cx))
 4380        .await;
 4381
 4382    let (multi_workspace, cx) =
 4383        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4384
 4385    let sidebar = setup_sidebar(&multi_workspace, cx);
 4386
 4387    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4388        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4389    });
 4390
 4391    // Activate the main workspace before setting up the sidebar.
 4392    let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4393        let workspace = mw.workspaces().next().unwrap().clone();
 4394        mw.activate(workspace.clone(), window, cx);
 4395        workspace
 4396    });
 4397
 4398    save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
 4399    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4400
 4401    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4402    cx.run_until_parked();
 4403
 4404    // The worktree workspace should be absorbed under the main repo.
 4405    let entries = visible_entries_as_strings(&sidebar, cx);
 4406    assert_eq!(entries.len(), 3);
 4407    assert_eq!(entries[0], "v [project]");
 4408    assert!(entries.contains(&"  Main Thread".to_string()));
 4409    assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
 4410
 4411    let wt_thread_index = entries
 4412        .iter()
 4413        .position(|e| e.contains("WT Thread"))
 4414        .expect("should find the worktree thread entry");
 4415
 4416    assert_eq!(
 4417        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4418        main_workspace,
 4419        "main workspace should be active initially"
 4420    );
 4421
 4422    // Focus the sidebar and select the absorbed worktree thread.
 4423    focus_sidebar(&sidebar, cx);
 4424    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4425        sidebar.selection = Some(wt_thread_index);
 4426    });
 4427
 4428    // Confirm to activate the worktree thread.
 4429    cx.dispatch_action(Confirm);
 4430    cx.run_until_parked();
 4431
 4432    // The worktree workspace should now be active, not the main one.
 4433    let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 4434    assert_eq!(
 4435        active_workspace, worktree_workspace,
 4436        "clicking an absorbed worktree thread should activate the worktree workspace"
 4437    );
 4438}
 4439
 4440#[gpui::test]
 4441async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
 4442    cx: &mut TestAppContext,
 4443) {
 4444    // Thread has saved metadata in ThreadStore. A matching workspace is
 4445    // already open. Expected: activates the matching workspace.
 4446    init_test(cx);
 4447    let fs = FakeFs::new(cx.executor());
 4448    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4449        .await;
 4450    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4451        .await;
 4452    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4453
 4454    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4455    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4456
 4457    let (multi_workspace, cx) =
 4458        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 4459
 4460    let sidebar = setup_sidebar(&multi_workspace, cx);
 4461
 4462    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4463        mw.test_add_workspace(project_b.clone(), window, cx)
 4464    });
 4465    let workspace_a =
 4466        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4467
 4468    // Save a thread with path_list pointing to project-b.
 4469    let session_id = acp::SessionId::new(Arc::from("archived-1"));
 4470    save_test_thread_metadata(&session_id, &project_b, cx).await;
 4471
 4472    // Ensure workspace A is active.
 4473    multi_workspace.update_in(cx, |mw, window, cx| {
 4474        let workspace = mw.workspaces().next().unwrap().clone();
 4475        mw.activate(workspace, window, cx);
 4476    });
 4477    cx.run_until_parked();
 4478    assert_eq!(
 4479        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4480        workspace_a
 4481    );
 4482
 4483    // Call activate_archived_thread – should resolve saved paths and
 4484    // switch to the workspace for project-b.
 4485    sidebar.update_in(cx, |sidebar, window, cx| {
 4486        sidebar.activate_archived_thread(
 4487            ThreadMetadata {
 4488                thread_id: ThreadId::new(),
 4489                session_id: Some(session_id.clone()),
 4490                agent_id: agent::ZED_AGENT_ID.clone(),
 4491                title: Some("Archived Thread".into()),
 4492                updated_at: Utc::now(),
 4493                created_at: None,
 4494                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4495                    "/project-b",
 4496                )])),
 4497                archived: false,
 4498                remote_connection: None,
 4499            },
 4500            window,
 4501            cx,
 4502        );
 4503    });
 4504    cx.run_until_parked();
 4505
 4506    assert_eq!(
 4507        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4508        workspace_b,
 4509        "should have switched to the workspace matching the saved paths"
 4510    );
 4511}
 4512
 4513#[gpui::test]
 4514async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
 4515    cx: &mut TestAppContext,
 4516) {
 4517    // Thread has no saved metadata but session_info has cwd. A matching
 4518    // workspace is open. Expected: uses cwd to find and activate it.
 4519    init_test(cx);
 4520    let fs = FakeFs::new(cx.executor());
 4521    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4522        .await;
 4523    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4524        .await;
 4525    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4526
 4527    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4528    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4529
 4530    let (multi_workspace, cx) =
 4531        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4532
 4533    let sidebar = setup_sidebar(&multi_workspace, cx);
 4534
 4535    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4536        mw.test_add_workspace(project_b, window, cx)
 4537    });
 4538    let workspace_a =
 4539        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4540
 4541    // Start with workspace A active.
 4542    multi_workspace.update_in(cx, |mw, window, cx| {
 4543        let workspace = mw.workspaces().next().unwrap().clone();
 4544        mw.activate(workspace, window, cx);
 4545    });
 4546    cx.run_until_parked();
 4547    assert_eq!(
 4548        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4549        workspace_a
 4550    );
 4551
 4552    // No thread saved to the store – cwd is the only path hint.
 4553    sidebar.update_in(cx, |sidebar, window, cx| {
 4554        sidebar.activate_archived_thread(
 4555            ThreadMetadata {
 4556                thread_id: ThreadId::new(),
 4557                session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
 4558                agent_id: agent::ZED_AGENT_ID.clone(),
 4559                title: Some("CWD Thread".into()),
 4560                updated_at: Utc::now(),
 4561                created_at: None,
 4562                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 4563                    std::path::PathBuf::from("/project-b"),
 4564                ])),
 4565                archived: false,
 4566                remote_connection: None,
 4567            },
 4568            window,
 4569            cx,
 4570        );
 4571    });
 4572    cx.run_until_parked();
 4573
 4574    assert_eq!(
 4575        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4576        workspace_b,
 4577        "should have activated the workspace matching the cwd"
 4578    );
 4579}
 4580
 4581#[gpui::test]
 4582async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
 4583    cx: &mut TestAppContext,
 4584) {
 4585    // Thread has no saved metadata and no cwd. Expected: falls back to
 4586    // the currently active workspace.
 4587    init_test(cx);
 4588    let fs = FakeFs::new(cx.executor());
 4589    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4590        .await;
 4591    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4592        .await;
 4593    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4594
 4595    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4596    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4597
 4598    let (multi_workspace, cx) =
 4599        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4600
 4601    let sidebar = setup_sidebar(&multi_workspace, cx);
 4602
 4603    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4604        mw.test_add_workspace(project_b, window, cx)
 4605    });
 4606
 4607    // Activate workspace B (index 1) to make it the active one.
 4608    multi_workspace.update_in(cx, |mw, window, cx| {
 4609        let workspace = mw.workspaces().nth(1).unwrap().clone();
 4610        mw.activate(workspace, window, cx);
 4611    });
 4612    cx.run_until_parked();
 4613    assert_eq!(
 4614        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4615        workspace_b
 4616    );
 4617
 4618    // No saved thread, no cwd – should fall back to the active workspace.
 4619    sidebar.update_in(cx, |sidebar, window, cx| {
 4620        sidebar.activate_archived_thread(
 4621            ThreadMetadata {
 4622                thread_id: ThreadId::new(),
 4623                session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
 4624                agent_id: agent::ZED_AGENT_ID.clone(),
 4625                title: Some("Contextless Thread".into()),
 4626                updated_at: Utc::now(),
 4627                created_at: None,
 4628                worktree_paths: WorktreePaths::default(),
 4629                archived: false,
 4630                remote_connection: None,
 4631            },
 4632            window,
 4633            cx,
 4634        );
 4635    });
 4636    cx.run_until_parked();
 4637
 4638    assert_eq!(
 4639        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4640        workspace_b,
 4641        "should have stayed on the active workspace when no path info is available"
 4642    );
 4643}
 4644
 4645#[gpui::test]
 4646async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
 4647    // Thread has saved metadata pointing to a path with no open workspace.
 4648    // Expected: opens a new workspace for that path.
 4649    init_test(cx);
 4650    let fs = FakeFs::new(cx.executor());
 4651    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4652        .await;
 4653    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4654        .await;
 4655    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4656
 4657    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4658
 4659    let (multi_workspace, cx) =
 4660        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4661
 4662    let sidebar = setup_sidebar(&multi_workspace, cx);
 4663
 4664    // Save a thread with path_list pointing to project-b – which has no
 4665    // open workspace.
 4666    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 4667    let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
 4668
 4669    assert_eq!(
 4670        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4671        1,
 4672        "should start with one workspace"
 4673    );
 4674
 4675    sidebar.update_in(cx, |sidebar, window, cx| {
 4676        sidebar.activate_archived_thread(
 4677            ThreadMetadata {
 4678                thread_id: ThreadId::new(),
 4679                session_id: Some(session_id.clone()),
 4680                agent_id: agent::ZED_AGENT_ID.clone(),
 4681                title: Some("New WS Thread".into()),
 4682                updated_at: Utc::now(),
 4683                created_at: None,
 4684                worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 4685                archived: false,
 4686                remote_connection: None,
 4687            },
 4688            window,
 4689            cx,
 4690        );
 4691    });
 4692    cx.run_until_parked();
 4693
 4694    assert_eq!(
 4695        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4696        2,
 4697        "should have opened a second workspace for the archived thread's saved paths"
 4698    );
 4699}
 4700
 4701#[gpui::test]
 4702async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
 4703    init_test(cx);
 4704    let fs = FakeFs::new(cx.executor());
 4705    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4706        .await;
 4707    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4708        .await;
 4709    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4710
 4711    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4712    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4713
 4714    let multi_workspace_a =
 4715        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4716    let multi_workspace_b =
 4717        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4718
 4719    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4720    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4721
 4722    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4723    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4724
 4725    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4726    let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4727
 4728    let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
 4729
 4730    sidebar.update_in(cx_a, |sidebar, window, cx| {
 4731        sidebar.activate_archived_thread(
 4732            ThreadMetadata {
 4733                thread_id: ThreadId::new(),
 4734                session_id: Some(session_id.clone()),
 4735                agent_id: agent::ZED_AGENT_ID.clone(),
 4736                title: Some("Cross Window Thread".into()),
 4737                updated_at: Utc::now(),
 4738                created_at: None,
 4739                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4740                    "/project-b",
 4741                )])),
 4742                archived: false,
 4743                remote_connection: None,
 4744            },
 4745            window,
 4746            cx,
 4747        );
 4748    });
 4749    cx_a.run_until_parked();
 4750
 4751    assert_eq!(
 4752        multi_workspace_a
 4753            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4754            .unwrap(),
 4755        1,
 4756        "should not add the other window's workspace into the current window"
 4757    );
 4758    assert_eq!(
 4759        multi_workspace_b
 4760            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4761            .unwrap(),
 4762        1,
 4763        "should reuse the existing workspace in the other window"
 4764    );
 4765    assert!(
 4766        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4767        "should activate the window that already owns the matching workspace"
 4768    );
 4769    sidebar.read_with(cx_a, |sidebar, _| {
 4770            assert!(
 4771                !is_active_session(&sidebar, &session_id),
 4772                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4773            );
 4774        });
 4775}
 4776
 4777#[gpui::test]
 4778async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
 4779    cx: &mut TestAppContext,
 4780) {
 4781    init_test(cx);
 4782    let fs = FakeFs::new(cx.executor());
 4783    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4784        .await;
 4785    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4786        .await;
 4787    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4788
 4789    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4790    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4791
 4792    let multi_workspace_a =
 4793        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4794    let multi_workspace_b =
 4795        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
 4796
 4797    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4798    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4799
 4800    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4801    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4802
 4803    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4804    let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4805    let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
 4806    let _panel_b = add_agent_panel(&workspace_b, cx_b);
 4807
 4808    let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
 4809
 4810    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4811        sidebar.activate_archived_thread(
 4812            ThreadMetadata {
 4813                thread_id: ThreadId::new(),
 4814                session_id: Some(session_id.clone()),
 4815                agent_id: agent::ZED_AGENT_ID.clone(),
 4816                title: Some("Cross Window Thread".into()),
 4817                updated_at: Utc::now(),
 4818                created_at: None,
 4819                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4820                    "/project-b",
 4821                )])),
 4822                archived: false,
 4823                remote_connection: None,
 4824            },
 4825            window,
 4826            cx,
 4827        );
 4828    });
 4829    cx_a.run_until_parked();
 4830
 4831    assert_eq!(
 4832        multi_workspace_a
 4833            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4834            .unwrap(),
 4835        1,
 4836        "should not add the other window's workspace into the current window"
 4837    );
 4838    assert_eq!(
 4839        multi_workspace_b
 4840            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4841            .unwrap(),
 4842        1,
 4843        "should reuse the existing workspace in the other window"
 4844    );
 4845    assert!(
 4846        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4847        "should activate the window that already owns the matching workspace"
 4848    );
 4849    sidebar_a.read_with(cx_a, |sidebar, _| {
 4850            assert!(
 4851                !is_active_session(&sidebar, &session_id),
 4852                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4853            );
 4854        });
 4855    sidebar_b.read_with(cx_b, |sidebar, _| {
 4856        assert_active_thread(
 4857            sidebar,
 4858            &session_id,
 4859            "target window's sidebar should eagerly focus the activated archived thread",
 4860        );
 4861    });
 4862}
 4863
 4864#[gpui::test]
 4865async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
 4866    cx: &mut TestAppContext,
 4867) {
 4868    init_test(cx);
 4869    let fs = FakeFs::new(cx.executor());
 4870    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4871        .await;
 4872    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4873
 4874    let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4875    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4876
 4877    let multi_workspace_b =
 4878        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4879    let multi_workspace_a =
 4880        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4881
 4882    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4883    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4884
 4885    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4886    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4887
 4888    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4889    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4890
 4891    let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
 4892
 4893    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4894        sidebar.activate_archived_thread(
 4895            ThreadMetadata {
 4896                thread_id: ThreadId::new(),
 4897                session_id: Some(session_id.clone()),
 4898                agent_id: agent::ZED_AGENT_ID.clone(),
 4899                title: Some("Current Window Thread".into()),
 4900                updated_at: Utc::now(),
 4901                created_at: None,
 4902                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4903                    "/project-a",
 4904                )])),
 4905                archived: false,
 4906                remote_connection: None,
 4907            },
 4908            window,
 4909            cx,
 4910        );
 4911    });
 4912    cx_a.run_until_parked();
 4913
 4914    assert!(
 4915        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
 4916        "should keep activation in the current window when it already has a matching workspace"
 4917    );
 4918    sidebar_a.read_with(cx_a, |sidebar, _| {
 4919        assert_active_thread(
 4920            sidebar,
 4921            &session_id,
 4922            "current window's sidebar should eagerly focus the activated archived thread",
 4923        );
 4924    });
 4925    assert_eq!(
 4926        multi_workspace_a
 4927            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4928            .unwrap(),
 4929        1,
 4930        "current window should continue reusing its existing workspace"
 4931    );
 4932    assert_eq!(
 4933        multi_workspace_b
 4934            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4935            .unwrap(),
 4936        1,
 4937        "other windows should not be activated just because they also match the saved paths"
 4938    );
 4939}
 4940
 4941#[gpui::test]
 4942async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
 4943    // Regression test: archive_thread previously always loaded the next thread
 4944    // through group_workspace (the main workspace's ProjectHeader), even when
 4945    // the next thread belonged to an absorbed linked-worktree workspace. That
 4946    // caused the worktree thread to be loaded in the main panel, which bound it
 4947    // to the main project and corrupted its stored folder_paths.
 4948    //
 4949    // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
 4950    // falling back to group_workspace only for Closed workspaces.
 4951    agent_ui::test_support::init_test(cx);
 4952    cx.update(|cx| {
 4953        ThreadStore::init_global(cx);
 4954        ThreadMetadataStore::init_global(cx);
 4955        language_model::LanguageModelRegistry::test(cx);
 4956        prompt_store::init(cx);
 4957    });
 4958
 4959    let fs = FakeFs::new(cx.executor());
 4960
 4961    fs.insert_tree(
 4962        "/project",
 4963        serde_json::json!({
 4964            ".git": {},
 4965            "src": {},
 4966        }),
 4967    )
 4968    .await;
 4969
 4970    fs.add_linked_worktree_for_repo(
 4971        Path::new("/project/.git"),
 4972        false,
 4973        git::repository::Worktree {
 4974            path: std::path::PathBuf::from("/wt-feature-a"),
 4975            ref_name: Some("refs/heads/feature-a".into()),
 4976            sha: "aaa".into(),
 4977            is_main: false,
 4978            is_bare: false,
 4979        },
 4980    )
 4981    .await;
 4982
 4983    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4984
 4985    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4986    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4987
 4988    main_project
 4989        .update(cx, |p, cx| p.git_scans_complete(cx))
 4990        .await;
 4991    worktree_project
 4992        .update(cx, |p, cx| p.git_scans_complete(cx))
 4993        .await;
 4994
 4995    let (multi_workspace, cx) =
 4996        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4997
 4998    let sidebar = setup_sidebar(&multi_workspace, cx);
 4999
 5000    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5001        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5002    });
 5003
 5004    // Activate main workspace so the sidebar tracks the main panel.
 5005    multi_workspace.update_in(cx, |mw, window, cx| {
 5006        let workspace = mw.workspaces().next().unwrap().clone();
 5007        mw.activate(workspace, window, cx);
 5008    });
 5009
 5010    let main_workspace =
 5011        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 5012    let main_panel = add_agent_panel(&main_workspace, cx);
 5013    let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
 5014
 5015    // Open Thread 2 in the main panel and keep it running.
 5016    let connection = StubAgentConnection::new();
 5017    open_thread_with_connection(&main_panel, connection.clone(), cx);
 5018    send_message(&main_panel, cx);
 5019
 5020    let thread2_session_id = active_session_id(&main_panel, cx);
 5021
 5022    cx.update(|_, cx| {
 5023        connection.send_update(
 5024            thread2_session_id.clone(),
 5025            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 5026            cx,
 5027        );
 5028    });
 5029
 5030    // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
 5031    save_thread_metadata(
 5032        thread2_session_id.clone(),
 5033        Some("Thread 2".into()),
 5034        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5035        None,
 5036        &main_project,
 5037        cx,
 5038    );
 5039
 5040    // Save thread 1's metadata with the worktree path and an older timestamp so
 5041    // it sorts below thread 2. archive_thread will find it as the "next" candidate.
 5042    let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
 5043    save_thread_metadata(
 5044        thread1_session_id,
 5045        Some("Thread 1".into()),
 5046        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5047        None,
 5048        &worktree_project,
 5049        cx,
 5050    );
 5051
 5052    cx.run_until_parked();
 5053
 5054    // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
 5055    let entries_before = visible_entries_as_strings(&sidebar, cx);
 5056    assert!(
 5057        entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
 5058        "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
 5059        entries_before
 5060    );
 5061
 5062    // The sidebar should track T2 as the focused thread (derived from the
 5063    // main panel's active view).
 5064    sidebar.read_with(cx, |s, _| {
 5065        assert_active_thread(
 5066            s,
 5067            &thread2_session_id,
 5068            "focused thread should be Thread 2 before archiving",
 5069        );
 5070    });
 5071
 5072    // Archive thread 2.
 5073    sidebar.update_in(cx, |sidebar, window, cx| {
 5074        sidebar.archive_thread(&thread2_session_id, window, cx);
 5075    });
 5076
 5077    cx.run_until_parked();
 5078
 5079    // The main panel's active thread must still be thread 2.
 5080    let main_active = main_panel.read_with(cx, |panel, cx| {
 5081        panel
 5082            .active_agent_thread(cx)
 5083            .map(|t| t.read(cx).session_id().clone())
 5084    });
 5085    assert_eq!(
 5086        main_active,
 5087        Some(thread2_session_id.clone()),
 5088        "main panel should not have been taken over by loading the linked-worktree thread T1; \
 5089             before the fix, archive_thread used group_workspace instead of next.workspace, \
 5090             causing T1 to be loaded in the wrong panel"
 5091    );
 5092
 5093    // Thread 1 should still appear in the sidebar with its worktree chip
 5094    // (Thread 2 was archived so it is gone from the list).
 5095    let entries_after = visible_entries_as_strings(&sidebar, cx);
 5096    assert!(
 5097        entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
 5098        "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
 5099        entries_after
 5100    );
 5101}
 5102
 5103#[gpui::test]
 5104async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
 5105    // When the last non-archived thread for a linked worktree is archived,
 5106    // the linked worktree workspace should be removed from the multi-workspace.
 5107    // The main worktree workspace should remain (it's always reachable via
 5108    // the project header).
 5109    init_test(cx);
 5110    let fs = FakeFs::new(cx.executor());
 5111
 5112    fs.insert_tree(
 5113        "/project",
 5114        serde_json::json!({
 5115            ".git": {
 5116                "worktrees": {
 5117                    "feature-a": {
 5118                        "commondir": "../../",
 5119                        "HEAD": "ref: refs/heads/feature-a",
 5120                    },
 5121                },
 5122            },
 5123            "src": {},
 5124        }),
 5125    )
 5126    .await;
 5127
 5128    fs.insert_tree(
 5129        "/wt-feature-a",
 5130        serde_json::json!({
 5131            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5132            "src": {},
 5133        }),
 5134    )
 5135    .await;
 5136
 5137    fs.add_linked_worktree_for_repo(
 5138        Path::new("/project/.git"),
 5139        false,
 5140        git::repository::Worktree {
 5141            path: PathBuf::from("/wt-feature-a"),
 5142            ref_name: Some("refs/heads/feature-a".into()),
 5143            sha: "abc".into(),
 5144            is_main: false,
 5145            is_bare: false,
 5146        },
 5147    )
 5148    .await;
 5149
 5150    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5151
 5152    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5153    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5154
 5155    main_project
 5156        .update(cx, |p, cx| p.git_scans_complete(cx))
 5157        .await;
 5158    worktree_project
 5159        .update(cx, |p, cx| p.git_scans_complete(cx))
 5160        .await;
 5161
 5162    let (multi_workspace, cx) =
 5163        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5164    let sidebar = setup_sidebar(&multi_workspace, cx);
 5165
 5166    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5167        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5168    });
 5169
 5170    // Save a thread for the main project.
 5171    save_thread_metadata(
 5172        acp::SessionId::new(Arc::from("main-thread")),
 5173        Some("Main Thread".into()),
 5174        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5175        None,
 5176        &main_project,
 5177        cx,
 5178    );
 5179
 5180    // Save a thread for the linked worktree.
 5181    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 5182    save_thread_metadata(
 5183        wt_thread_id.clone(),
 5184        Some("Worktree Thread".into()),
 5185        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5186        None,
 5187        &worktree_project,
 5188        cx,
 5189    );
 5190    cx.run_until_parked();
 5191
 5192    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5193    cx.run_until_parked();
 5194
 5195    // Should have 2 workspaces.
 5196    assert_eq!(
 5197        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5198        2,
 5199        "should start with 2 workspaces (main + linked worktree)"
 5200    );
 5201
 5202    // Archive the worktree thread (the only thread for /wt-feature-a).
 5203    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 5204        sidebar.archive_thread(&wt_thread_id, window, cx);
 5205    });
 5206
 5207    // archive_thread spawns a multi-layered chain of tasks (workspace
 5208    // removal → git persist → disk removal), each of which may spawn
 5209    // further background work. Each run_until_parked() call drives one
 5210    // layer of pending work.
 5211
 5212    cx.run_until_parked();
 5213
 5214    // The linked worktree workspace should have been removed.
 5215    assert_eq!(
 5216        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5217        1,
 5218        "linked worktree workspace should be removed after archiving its last thread"
 5219    );
 5220
 5221    // The linked worktree checkout directory should also be removed from disk.
 5222    assert!(
 5223        !fs.is_dir(Path::new("/wt-feature-a")).await,
 5224        "linked worktree directory should be removed from disk after archiving its last thread"
 5225    );
 5226
 5227    // The main thread should still be visible.
 5228    let entries = visible_entries_as_strings(&sidebar, cx);
 5229    assert!(
 5230        entries.iter().any(|e| e.contains("Main Thread")),
 5231        "main thread should still be visible: {entries:?}"
 5232    );
 5233    assert!(
 5234        !entries.iter().any(|e| e.contains("Worktree Thread")),
 5235        "archived worktree thread should not be visible: {entries:?}"
 5236    );
 5237
 5238    // The archived thread must retain its folder_paths so it can be
 5239    // restored to the correct workspace later.
 5240    let wt_thread_id = cx.update(|_window, cx| {
 5241        ThreadMetadataStore::global(cx)
 5242            .read(cx)
 5243            .entry_by_session(&wt_thread_id)
 5244            .unwrap()
 5245            .thread_id
 5246    });
 5247    let archived_paths = cx.update(|_window, cx| {
 5248        ThreadMetadataStore::global(cx)
 5249            .read(cx)
 5250            .entry(wt_thread_id)
 5251            .unwrap()
 5252            .folder_paths()
 5253            .clone()
 5254    });
 5255    assert_eq!(
 5256        archived_paths.paths(),
 5257        &[PathBuf::from("/wt-feature-a")],
 5258        "archived thread must retain its folder_paths for restore"
 5259    );
 5260}
 5261
 5262#[gpui::test]
 5263async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
 5264    // restore_worktree_via_git should succeed when the branch has moved
 5265    // to a different SHA since archival. The worktree stays in detached
 5266    // HEAD and the moved branch is left untouched.
 5267    init_test(cx);
 5268    let fs = FakeFs::new(cx.executor());
 5269
 5270    fs.insert_tree(
 5271        "/project",
 5272        serde_json::json!({
 5273            ".git": {
 5274                "worktrees": {
 5275                    "feature-a": {
 5276                        "commondir": "../../",
 5277                        "HEAD": "ref: refs/heads/feature-a",
 5278                    },
 5279                },
 5280            },
 5281            "src": {},
 5282        }),
 5283    )
 5284    .await;
 5285    fs.insert_tree(
 5286        "/wt-feature-a",
 5287        serde_json::json!({
 5288            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5289            "src": {},
 5290        }),
 5291    )
 5292    .await;
 5293    fs.add_linked_worktree_for_repo(
 5294        Path::new("/project/.git"),
 5295        false,
 5296        git::repository::Worktree {
 5297            path: PathBuf::from("/wt-feature-a"),
 5298            ref_name: Some("refs/heads/feature-a".into()),
 5299            sha: "original-sha".into(),
 5300            is_main: false,
 5301            is_bare: false,
 5302        },
 5303    )
 5304    .await;
 5305    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5306
 5307    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5308    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5309    main_project
 5310        .update(cx, |p, cx| p.git_scans_complete(cx))
 5311        .await;
 5312    worktree_project
 5313        .update(cx, |p, cx| p.git_scans_complete(cx))
 5314        .await;
 5315
 5316    let (multi_workspace, _cx) =
 5317        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5318    multi_workspace.update_in(_cx, |mw, window, cx| {
 5319        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5320    });
 5321
 5322    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5323        project.repositories(cx).values().next().unwrap().clone()
 5324    });
 5325    let (staged_hash, unstaged_hash) = cx
 5326        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5327        .await
 5328        .unwrap()
 5329        .unwrap();
 5330
 5331    // Move the branch to a different SHA.
 5332    fs.with_git_state(Path::new("/project/.git"), false, |state| {
 5333        state
 5334            .refs
 5335            .insert("refs/heads/feature-a".into(), "moved-sha".into());
 5336    })
 5337    .unwrap();
 5338
 5339    let result = cx
 5340        .spawn(|mut cx| async move {
 5341            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5342                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5343                    id: 1,
 5344                    worktree_path: PathBuf::from("/wt-feature-a"),
 5345                    main_repo_path: PathBuf::from("/project"),
 5346                    branch_name: Some("feature-a".to_string()),
 5347                    staged_commit_hash: staged_hash,
 5348                    unstaged_commit_hash: unstaged_hash,
 5349                    original_commit_hash: "original-sha".to_string(),
 5350                },
 5351                &mut cx,
 5352            )
 5353            .await
 5354        })
 5355        .await;
 5356
 5357    assert!(
 5358        result.is_ok(),
 5359        "restore should succeed even when branch has moved: {:?}",
 5360        result.err()
 5361    );
 5362
 5363    // The moved branch ref should be completely untouched.
 5364    let branch_sha = fs
 5365        .with_git_state(Path::new("/project/.git"), false, |state| {
 5366            state.refs.get("refs/heads/feature-a").cloned()
 5367        })
 5368        .unwrap();
 5369    assert_eq!(
 5370        branch_sha.as_deref(),
 5371        Some("moved-sha"),
 5372        "the moved branch ref should not be modified by the restore"
 5373    );
 5374}
 5375
 5376#[gpui::test]
 5377async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
 5378    // restore_worktree_via_git should succeed when the branch still
 5379    // points at the same SHA as at archive time.
 5380    init_test(cx);
 5381    let fs = FakeFs::new(cx.executor());
 5382
 5383    fs.insert_tree(
 5384        "/project",
 5385        serde_json::json!({
 5386            ".git": {
 5387                "worktrees": {
 5388                    "feature-b": {
 5389                        "commondir": "../../",
 5390                        "HEAD": "ref: refs/heads/feature-b",
 5391                    },
 5392                },
 5393            },
 5394            "src": {},
 5395        }),
 5396    )
 5397    .await;
 5398    fs.insert_tree(
 5399        "/wt-feature-b",
 5400        serde_json::json!({
 5401            ".git": "gitdir: /project/.git/worktrees/feature-b",
 5402            "src": {},
 5403        }),
 5404    )
 5405    .await;
 5406    fs.add_linked_worktree_for_repo(
 5407        Path::new("/project/.git"),
 5408        false,
 5409        git::repository::Worktree {
 5410            path: PathBuf::from("/wt-feature-b"),
 5411            ref_name: Some("refs/heads/feature-b".into()),
 5412            sha: "original-sha".into(),
 5413            is_main: false,
 5414            is_bare: false,
 5415        },
 5416    )
 5417    .await;
 5418    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5419
 5420    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5421    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 5422    main_project
 5423        .update(cx, |p, cx| p.git_scans_complete(cx))
 5424        .await;
 5425    worktree_project
 5426        .update(cx, |p, cx| p.git_scans_complete(cx))
 5427        .await;
 5428
 5429    let (multi_workspace, _cx) =
 5430        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5431    multi_workspace.update_in(_cx, |mw, window, cx| {
 5432        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5433    });
 5434
 5435    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5436        project.repositories(cx).values().next().unwrap().clone()
 5437    });
 5438    let (staged_hash, unstaged_hash) = cx
 5439        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5440        .await
 5441        .unwrap()
 5442        .unwrap();
 5443
 5444    // refs/heads/feature-b already points at "original-sha" (set by
 5445    // add_linked_worktree_for_repo), matching original_commit_hash.
 5446
 5447    let result = cx
 5448        .spawn(|mut cx| async move {
 5449            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5450                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5451                    id: 1,
 5452                    worktree_path: PathBuf::from("/wt-feature-b"),
 5453                    main_repo_path: PathBuf::from("/project"),
 5454                    branch_name: Some("feature-b".to_string()),
 5455                    staged_commit_hash: staged_hash,
 5456                    unstaged_commit_hash: unstaged_hash,
 5457                    original_commit_hash: "original-sha".to_string(),
 5458                },
 5459                &mut cx,
 5460            )
 5461            .await
 5462        })
 5463        .await;
 5464
 5465    assert!(
 5466        result.is_ok(),
 5467        "restore should succeed when branch has not moved: {:?}",
 5468        result.err()
 5469    );
 5470}
 5471
 5472#[gpui::test]
 5473async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
 5474    // restore_worktree_via_git should succeed when the branch no longer
 5475    // exists (e.g. it was deleted while the thread was archived). The
 5476    // code should attempt to recreate the branch.
 5477    init_test(cx);
 5478    let fs = FakeFs::new(cx.executor());
 5479
 5480    fs.insert_tree(
 5481        "/project",
 5482        serde_json::json!({
 5483            ".git": {
 5484                "worktrees": {
 5485                    "feature-d": {
 5486                        "commondir": "../../",
 5487                        "HEAD": "ref: refs/heads/feature-d",
 5488                    },
 5489                },
 5490            },
 5491            "src": {},
 5492        }),
 5493    )
 5494    .await;
 5495    fs.insert_tree(
 5496        "/wt-feature-d",
 5497        serde_json::json!({
 5498            ".git": "gitdir: /project/.git/worktrees/feature-d",
 5499            "src": {},
 5500        }),
 5501    )
 5502    .await;
 5503    fs.add_linked_worktree_for_repo(
 5504        Path::new("/project/.git"),
 5505        false,
 5506        git::repository::Worktree {
 5507            path: PathBuf::from("/wt-feature-d"),
 5508            ref_name: Some("refs/heads/feature-d".into()),
 5509            sha: "original-sha".into(),
 5510            is_main: false,
 5511            is_bare: false,
 5512        },
 5513    )
 5514    .await;
 5515    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5516
 5517    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5518    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
 5519    main_project
 5520        .update(cx, |p, cx| p.git_scans_complete(cx))
 5521        .await;
 5522    worktree_project
 5523        .update(cx, |p, cx| p.git_scans_complete(cx))
 5524        .await;
 5525
 5526    let (multi_workspace, _cx) =
 5527        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5528    multi_workspace.update_in(_cx, |mw, window, cx| {
 5529        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5530    });
 5531
 5532    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5533        project.repositories(cx).values().next().unwrap().clone()
 5534    });
 5535    let (staged_hash, unstaged_hash) = cx
 5536        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5537        .await
 5538        .unwrap()
 5539        .unwrap();
 5540
 5541    // Remove the branch ref so change_branch will fail.
 5542    fs.with_git_state(Path::new("/project/.git"), false, |state| {
 5543        state.refs.remove("refs/heads/feature-d");
 5544    })
 5545    .unwrap();
 5546
 5547    let result = cx
 5548        .spawn(|mut cx| async move {
 5549            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5550                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5551                    id: 1,
 5552                    worktree_path: PathBuf::from("/wt-feature-d"),
 5553                    main_repo_path: PathBuf::from("/project"),
 5554                    branch_name: Some("feature-d".to_string()),
 5555                    staged_commit_hash: staged_hash,
 5556                    unstaged_commit_hash: unstaged_hash,
 5557                    original_commit_hash: "original-sha".to_string(),
 5558                },
 5559                &mut cx,
 5560            )
 5561            .await
 5562        })
 5563        .await;
 5564
 5565    assert!(
 5566        result.is_ok(),
 5567        "restore should succeed when branch does not exist: {:?}",
 5568        result.err()
 5569    );
 5570}
 5571
 5572#[gpui::test]
 5573async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
 5574    // Activating an archived linked worktree thread whose directory has
 5575    // been deleted should reuse the existing main repo workspace, not
 5576    // create a new one. The provisional ProjectGroupKey must be derived
 5577    // from main_worktree_paths so that find_or_create_local_workspace
 5578    // matches the main repo workspace when the worktree path is absent.
 5579    init_test(cx);
 5580    let fs = FakeFs::new(cx.executor());
 5581
 5582    fs.insert_tree(
 5583        "/project",
 5584        serde_json::json!({
 5585            ".git": {
 5586                "worktrees": {
 5587                    "feature-c": {
 5588                        "commondir": "../../",
 5589                        "HEAD": "ref: refs/heads/feature-c",
 5590                    },
 5591                },
 5592            },
 5593            "src": {},
 5594        }),
 5595    )
 5596    .await;
 5597
 5598    fs.insert_tree(
 5599        "/wt-feature-c",
 5600        serde_json::json!({
 5601            ".git": "gitdir: /project/.git/worktrees/feature-c",
 5602            "src": {},
 5603        }),
 5604    )
 5605    .await;
 5606
 5607    fs.add_linked_worktree_for_repo(
 5608        Path::new("/project/.git"),
 5609        false,
 5610        git::repository::Worktree {
 5611            path: PathBuf::from("/wt-feature-c"),
 5612            ref_name: Some("refs/heads/feature-c".into()),
 5613            sha: "original-sha".into(),
 5614            is_main: false,
 5615            is_bare: false,
 5616        },
 5617    )
 5618    .await;
 5619
 5620    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5621
 5622    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5623    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
 5624
 5625    main_project
 5626        .update(cx, |p, cx| p.git_scans_complete(cx))
 5627        .await;
 5628    worktree_project
 5629        .update(cx, |p, cx| p.git_scans_complete(cx))
 5630        .await;
 5631
 5632    let (multi_workspace, cx) =
 5633        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5634    let sidebar = setup_sidebar(&multi_workspace, cx);
 5635
 5636    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5637        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5638    });
 5639
 5640    // Save thread metadata for the linked worktree.
 5641    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
 5642    save_thread_metadata(
 5643        wt_session_id.clone(),
 5644        Some("Worktree Thread C".into()),
 5645        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5646        None,
 5647        &worktree_project,
 5648        cx,
 5649    );
 5650    cx.run_until_parked();
 5651
 5652    let thread_id = cx.update(|_window, cx| {
 5653        ThreadMetadataStore::global(cx)
 5654            .read(cx)
 5655            .entry_by_session(&wt_session_id)
 5656            .unwrap()
 5657            .thread_id
 5658    });
 5659
 5660    // Archive the thread without creating ArchivedGitWorktree records.
 5661    let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
 5662    cx.update(|_window, cx| {
 5663        store.update(cx, |store, cx| store.archive(thread_id, None, cx));
 5664    });
 5665    cx.run_until_parked();
 5666
 5667    // Remove the worktree workspace and delete the worktree from disk.
 5668    let main_workspace =
 5669        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 5670    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 5671        mw.remove(
 5672            vec![worktree_workspace],
 5673            move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
 5674            window,
 5675            cx,
 5676        )
 5677    });
 5678    remove_task.await.ok();
 5679    cx.run_until_parked();
 5680    cx.run_until_parked();
 5681    fs.remove_dir(
 5682        Path::new("/wt-feature-c"),
 5683        fs::RemoveOptions {
 5684            recursive: true,
 5685            ignore_if_not_exists: true,
 5686        },
 5687    )
 5688    .await
 5689    .unwrap();
 5690
 5691    let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 5692    assert_eq!(
 5693        workspace_count_before, 1,
 5694        "should have only the main workspace"
 5695    );
 5696
 5697    // Activate the archived thread. The worktree path is missing from
 5698    // disk, so find_or_create_local_workspace falls back to the
 5699    // provisional ProjectGroupKey to find a matching workspace.
 5700    let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
 5701    sidebar.update_in(cx, |sidebar, window, cx| {
 5702        sidebar.activate_archived_thread(metadata, window, cx);
 5703    });
 5704    cx.run_until_parked();
 5705
 5706    // The provisional key should use [/project] (the main repo),
 5707    // which matches the existing main workspace. If it incorrectly
 5708    // used [/wt-feature-c] (the linked worktree path), no workspace
 5709    // would match and a spurious new one would be created.
 5710    let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 5711    assert_eq!(
 5712        workspace_count_after, 1,
 5713        "restoring a linked worktree thread should reuse the main repo workspace, \
 5714         not create a new one (workspace count went from {workspace_count_before} to \
 5715         {workspace_count_after})"
 5716    );
 5717}
 5718
 5719#[gpui::test]
 5720async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
 5721    cx: &mut TestAppContext,
 5722) {
 5723    // A remote thread at the same path as a local linked worktree thread
 5724    // should not prevent the local workspace from being removed when the
 5725    // local thread is archived (the last local thread for that worktree).
 5726    init_test(cx);
 5727    let fs = FakeFs::new(cx.executor());
 5728
 5729    fs.insert_tree(
 5730        "/project",
 5731        serde_json::json!({
 5732            ".git": {
 5733                "worktrees": {
 5734                    "feature-a": {
 5735                        "commondir": "../../",
 5736                        "HEAD": "ref: refs/heads/feature-a",
 5737                    },
 5738                },
 5739            },
 5740            "src": {},
 5741        }),
 5742    )
 5743    .await;
 5744
 5745    fs.insert_tree(
 5746        "/wt-feature-a",
 5747        serde_json::json!({
 5748            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5749            "src": {},
 5750        }),
 5751    )
 5752    .await;
 5753
 5754    fs.add_linked_worktree_for_repo(
 5755        Path::new("/project/.git"),
 5756        false,
 5757        git::repository::Worktree {
 5758            path: PathBuf::from("/wt-feature-a"),
 5759            ref_name: Some("refs/heads/feature-a".into()),
 5760            sha: "abc".into(),
 5761            is_main: false,
 5762            is_bare: false,
 5763        },
 5764    )
 5765    .await;
 5766
 5767    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5768
 5769    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5770    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5771
 5772    main_project
 5773        .update(cx, |p, cx| p.git_scans_complete(cx))
 5774        .await;
 5775    worktree_project
 5776        .update(cx, |p, cx| p.git_scans_complete(cx))
 5777        .await;
 5778
 5779    let (multi_workspace, cx) =
 5780        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5781    let sidebar = setup_sidebar(&multi_workspace, cx);
 5782
 5783    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5784        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5785    });
 5786
 5787    // Save a thread for the main project.
 5788    save_thread_metadata(
 5789        acp::SessionId::new(Arc::from("main-thread")),
 5790        Some("Main Thread".into()),
 5791        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5792        None,
 5793        &main_project,
 5794        cx,
 5795    );
 5796
 5797    // Save a local thread for the linked worktree.
 5798    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 5799    save_thread_metadata(
 5800        wt_thread_id.clone(),
 5801        Some("Local Worktree Thread".into()),
 5802        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5803        None,
 5804        &worktree_project,
 5805        cx,
 5806    );
 5807
 5808    // Save a remote thread at the same /wt-feature-a path but on a
 5809    // different host. This should NOT count as a remaining thread for
 5810    // the local linked worktree workspace.
 5811    let remote_host =
 5812        remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
 5813    cx.update(|_window, cx| {
 5814        let metadata = ThreadMetadata {
 5815            thread_id: ThreadId::new(),
 5816            session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
 5817            agent_id: agent::ZED_AGENT_ID.clone(),
 5818            title: Some("Remote Worktree Thread".into()),
 5819            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5820            created_at: None,
 5821            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 5822                "/wt-feature-a",
 5823            )])),
 5824            archived: false,
 5825            remote_connection: Some(remote_host),
 5826        };
 5827        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 5828            store.save(metadata, cx);
 5829        });
 5830    });
 5831    cx.run_until_parked();
 5832
 5833    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5834    cx.run_until_parked();
 5835
 5836    assert_eq!(
 5837        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5838        2,
 5839        "should start with 2 workspaces (main + linked worktree)"
 5840    );
 5841
 5842    // The remote thread should NOT appear in the sidebar (it belongs
 5843    // to a different host and no matching remote project group exists).
 5844    let entries_before = visible_entries_as_strings(&sidebar, cx);
 5845    assert!(
 5846        !entries_before
 5847            .iter()
 5848            .any(|e| e.contains("Remote Worktree Thread")),
 5849        "remote thread should not appear in local sidebar: {entries_before:?}"
 5850    );
 5851
 5852    // Archive the local worktree thread.
 5853    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 5854        sidebar.archive_thread(&wt_thread_id, window, cx);
 5855    });
 5856
 5857    cx.run_until_parked();
 5858
 5859    // The linked worktree workspace should be removed because the
 5860    // only *local* thread for it was archived. The remote thread at
 5861    // the same path should not have prevented removal.
 5862    assert_eq!(
 5863        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5864        1,
 5865        "linked worktree workspace should be removed; the remote thread at the same path \
 5866         should not count as a remaining local thread"
 5867    );
 5868
 5869    let entries = visible_entries_as_strings(&sidebar, cx);
 5870    assert!(
 5871        entries.iter().any(|e| e.contains("Main Thread")),
 5872        "main thread should still be visible: {entries:?}"
 5873    );
 5874    assert!(
 5875        !entries.iter().any(|e| e.contains("Local Worktree Thread")),
 5876        "archived local worktree thread should not be visible: {entries:?}"
 5877    );
 5878    assert!(
 5879        !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
 5880        "remote thread should still not appear in local sidebar: {entries:?}"
 5881    );
 5882}
 5883
 5884#[gpui::test]
 5885async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
 5886    // When a multi-root workspace (e.g. [/other, /project]) shares a
 5887    // repo with a single-root workspace (e.g. [/project]), linked
 5888    // worktree threads from the shared repo should only appear under
 5889    // the dedicated group [project], not under [other, project].
 5890    agent_ui::test_support::init_test(cx);
 5891    cx.update(|cx| {
 5892        ThreadStore::init_global(cx);
 5893        ThreadMetadataStore::init_global(cx);
 5894        language_model::LanguageModelRegistry::test(cx);
 5895        prompt_store::init(cx);
 5896    });
 5897    let fs = FakeFs::new(cx.executor());
 5898
 5899    // Two independent repos, each with their own git history.
 5900    fs.insert_tree(
 5901        "/project",
 5902        serde_json::json!({
 5903            ".git": {},
 5904            "src": {},
 5905        }),
 5906    )
 5907    .await;
 5908    fs.insert_tree(
 5909        "/other",
 5910        serde_json::json!({
 5911            ".git": {},
 5912            "src": {},
 5913        }),
 5914    )
 5915    .await;
 5916
 5917    // Register the linked worktree in the main repo.
 5918    fs.add_linked_worktree_for_repo(
 5919        Path::new("/project/.git"),
 5920        false,
 5921        git::repository::Worktree {
 5922            path: std::path::PathBuf::from("/wt-feature-a"),
 5923            ref_name: Some("refs/heads/feature-a".into()),
 5924            sha: "aaa".into(),
 5925            is_main: false,
 5926            is_bare: false,
 5927        },
 5928    )
 5929    .await;
 5930
 5931    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5932
 5933    // Workspace 1: just /project.
 5934    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5935    project_only
 5936        .update(cx, |p, cx| p.git_scans_complete(cx))
 5937        .await;
 5938
 5939    // Workspace 2: /other and /project together (multi-root).
 5940    let multi_root =
 5941        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
 5942    multi_root
 5943        .update(cx, |p, cx| p.git_scans_complete(cx))
 5944        .await;
 5945
 5946    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5947    worktree_project
 5948        .update(cx, |p, cx| p.git_scans_complete(cx))
 5949        .await;
 5950
 5951    // Save a thread under the linked worktree path BEFORE setting up
 5952    // the sidebar and panels, so that reconciliation sees the [project]
 5953    // group as non-empty and doesn't create a spurious draft there.
 5954    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
 5955    save_thread_metadata(
 5956        wt_session_id,
 5957        Some("Worktree Thread".into()),
 5958        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5959        None,
 5960        &worktree_project,
 5961        cx,
 5962    );
 5963
 5964    let (multi_workspace, cx) =
 5965        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
 5966    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5967    let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5968        mw.test_add_workspace(multi_root.clone(), window, cx)
 5969    });
 5970    add_agent_panel(&multi_root_workspace, cx);
 5971    cx.run_until_parked();
 5972
 5973    // The thread should appear only under [project] (the dedicated
 5974    // group for the /project repo), not under [other, project].
 5975    assert_eq!(
 5976        visible_entries_as_strings(&sidebar, cx),
 5977        vec![
 5978            //
 5979            "v [other, project]",
 5980            "v [project]",
 5981            "  Worktree Thread {wt-feature-a}",
 5982        ]
 5983    );
 5984}
 5985
 5986#[gpui::test]
 5987async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
 5988    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 5989    let (multi_workspace, cx) =
 5990        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 5991    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5992
 5993    let switcher_ids =
 5994        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<acp::SessionId> {
 5995            sidebar.read_with(cx, |sidebar, cx| {
 5996                let switcher = sidebar
 5997                    .thread_switcher
 5998                    .as_ref()
 5999                    .expect("switcher should be open");
 6000                switcher
 6001                    .read(cx)
 6002                    .entries()
 6003                    .iter()
 6004                    .map(|e| e.session_id.clone())
 6005                    .collect()
 6006            })
 6007        };
 6008
 6009    let switcher_selected_id =
 6010        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> acp::SessionId {
 6011            sidebar.read_with(cx, |sidebar, cx| {
 6012                let switcher = sidebar
 6013                    .thread_switcher
 6014                    .as_ref()
 6015                    .expect("switcher should be open");
 6016                let s = switcher.read(cx);
 6017                s.selected_entry()
 6018                    .expect("should have selection")
 6019                    .session_id
 6020                    .clone()
 6021            })
 6022        };
 6023
 6024    // ── Setup: create three threads with distinct created_at times ──────
 6025    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
 6026    // We send messages in each so they also get last_message_sent_or_queued timestamps.
 6027    let connection_c = StubAgentConnection::new();
 6028    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6029        acp::ContentChunk::new("Done C".into()),
 6030    )]);
 6031    open_thread_with_connection(&panel, connection_c, cx);
 6032    send_message(&panel, cx);
 6033    let session_id_c = active_session_id(&panel, cx);
 6034    save_thread_metadata(
 6035        session_id_c.clone(),
 6036        Some("Thread C".into()),
 6037        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 6038        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
 6039        &project,
 6040        cx,
 6041    );
 6042
 6043    let connection_b = StubAgentConnection::new();
 6044    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6045        acp::ContentChunk::new("Done B".into()),
 6046    )]);
 6047    open_thread_with_connection(&panel, connection_b, cx);
 6048    send_message(&panel, cx);
 6049    let session_id_b = active_session_id(&panel, cx);
 6050    save_thread_metadata(
 6051        session_id_b.clone(),
 6052        Some("Thread B".into()),
 6053        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 6054        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
 6055        &project,
 6056        cx,
 6057    );
 6058
 6059    let connection_a = StubAgentConnection::new();
 6060    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6061        acp::ContentChunk::new("Done A".into()),
 6062    )]);
 6063    open_thread_with_connection(&panel, connection_a, cx);
 6064    send_message(&panel, cx);
 6065    let session_id_a = active_session_id(&panel, cx);
 6066    save_thread_metadata(
 6067        session_id_a.clone(),
 6068        Some("Thread A".into()),
 6069        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
 6070        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
 6071        &project,
 6072        cx,
 6073    );
 6074
 6075    // All three threads are now live. Thread A was opened last, so it's
 6076    // the one being viewed. Opening each thread called record_thread_access,
 6077    // so all three have last_accessed_at set.
 6078    // Access order is: A (most recent), B, C (oldest).
 6079
 6080    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
 6081    focus_sidebar(&sidebar, cx);
 6082    sidebar.update_in(cx, |sidebar, window, cx| {
 6083        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6084    });
 6085    cx.run_until_parked();
 6086
 6087    // All three have last_accessed_at, so they sort by access time.
 6088    // A was accessed most recently (it's the currently viewed thread),
 6089    // then B, then C.
 6090    assert_eq!(
 6091        switcher_ids(&sidebar, cx),
 6092        vec![
 6093            session_id_a.clone(),
 6094            session_id_b.clone(),
 6095            session_id_c.clone()
 6096        ],
 6097    );
 6098    // First ctrl-tab selects the second entry (B).
 6099    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b);
 6100
 6101    // Dismiss the switcher without confirming.
 6102    sidebar.update_in(cx, |sidebar, _window, cx| {
 6103        sidebar.dismiss_thread_switcher(cx);
 6104    });
 6105    cx.run_until_parked();
 6106
 6107    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
 6108    sidebar.update_in(cx, |sidebar, window, cx| {
 6109        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6110    });
 6111    cx.run_until_parked();
 6112
 6113    // Cycle twice to land on Thread C (index 2).
 6114    sidebar.read_with(cx, |sidebar, cx| {
 6115        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6116        assert_eq!(switcher.read(cx).selected_index(), 1);
 6117    });
 6118    sidebar.update_in(cx, |sidebar, _window, cx| {
 6119        sidebar
 6120            .thread_switcher
 6121            .as_ref()
 6122            .unwrap()
 6123            .update(cx, |s, cx| s.cycle_selection(cx));
 6124    });
 6125    cx.run_until_parked();
 6126    assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c);
 6127
 6128    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
 6129
 6130    // Confirm on Thread C.
 6131    sidebar.update_in(cx, |sidebar, window, cx| {
 6132        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6133        let focus = switcher.focus_handle(cx);
 6134        focus.dispatch_action(&menu::Confirm, window, cx);
 6135    });
 6136    cx.run_until_parked();
 6137
 6138    // Switcher should be dismissed after confirm.
 6139    sidebar.read_with(cx, |sidebar, _cx| {
 6140        assert!(
 6141            sidebar.thread_switcher.is_none(),
 6142            "switcher should be dismissed"
 6143        );
 6144    });
 6145
 6146    sidebar.update(cx, |sidebar, _cx| {
 6147        let last_accessed = sidebar
 6148            .thread_last_accessed
 6149            .keys()
 6150            .cloned()
 6151            .collect::<Vec<_>>();
 6152        assert_eq!(last_accessed.len(), 1);
 6153        assert!(last_accessed.contains(&session_id_c));
 6154        assert!(
 6155            is_active_session(&sidebar, &session_id_c),
 6156            "active_entry should be Thread({session_id_c:?})"
 6157        );
 6158    });
 6159
 6160    sidebar.update_in(cx, |sidebar, window, cx| {
 6161        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6162    });
 6163    cx.run_until_parked();
 6164
 6165    assert_eq!(
 6166        switcher_ids(&sidebar, cx),
 6167        vec![
 6168            session_id_c.clone(),
 6169            session_id_a.clone(),
 6170            session_id_b.clone()
 6171        ],
 6172    );
 6173
 6174    // Confirm on Thread A.
 6175    sidebar.update_in(cx, |sidebar, window, cx| {
 6176        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6177        let focus = switcher.focus_handle(cx);
 6178        focus.dispatch_action(&menu::Confirm, window, cx);
 6179    });
 6180    cx.run_until_parked();
 6181
 6182    sidebar.update(cx, |sidebar, _cx| {
 6183        let last_accessed = sidebar
 6184            .thread_last_accessed
 6185            .keys()
 6186            .cloned()
 6187            .collect::<Vec<_>>();
 6188        assert_eq!(last_accessed.len(), 2);
 6189        assert!(last_accessed.contains(&session_id_c));
 6190        assert!(last_accessed.contains(&session_id_a));
 6191        assert!(
 6192            is_active_session(&sidebar, &session_id_a),
 6193            "active_entry should be Thread({session_id_a:?})"
 6194        );
 6195    });
 6196
 6197    sidebar.update_in(cx, |sidebar, window, cx| {
 6198        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6199    });
 6200    cx.run_until_parked();
 6201
 6202    assert_eq!(
 6203        switcher_ids(&sidebar, cx),
 6204        vec![
 6205            session_id_a.clone(),
 6206            session_id_c.clone(),
 6207            session_id_b.clone(),
 6208        ],
 6209    );
 6210
 6211    sidebar.update_in(cx, |sidebar, _window, cx| {
 6212        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6213        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
 6214    });
 6215    cx.run_until_parked();
 6216
 6217    // Confirm on Thread B.
 6218    sidebar.update_in(cx, |sidebar, window, cx| {
 6219        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6220        let focus = switcher.focus_handle(cx);
 6221        focus.dispatch_action(&menu::Confirm, window, cx);
 6222    });
 6223    cx.run_until_parked();
 6224
 6225    sidebar.update(cx, |sidebar, _cx| {
 6226        let last_accessed = sidebar
 6227            .thread_last_accessed
 6228            .keys()
 6229            .cloned()
 6230            .collect::<Vec<_>>();
 6231        assert_eq!(last_accessed.len(), 3);
 6232        assert!(last_accessed.contains(&session_id_c));
 6233        assert!(last_accessed.contains(&session_id_a));
 6234        assert!(last_accessed.contains(&session_id_b));
 6235        assert!(
 6236            is_active_session(&sidebar, &session_id_b),
 6237            "active_entry should be Thread({session_id_b:?})"
 6238        );
 6239    });
 6240
 6241    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
 6242    // This thread was never opened in a panel — it only exists in metadata.
 6243    save_thread_metadata(
 6244        acp::SessionId::new(Arc::from("thread-historical")),
 6245        Some("Historical Thread".into()),
 6246        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 6247        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
 6248        &project,
 6249        cx,
 6250    );
 6251
 6252    sidebar.update_in(cx, |sidebar, window, cx| {
 6253        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6254    });
 6255    cx.run_until_parked();
 6256
 6257    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
 6258    // so it falls to tier 3 (sorted by created_at). It should appear after all
 6259    // accessed threads, even though its created_at (June 2024) is much later
 6260    // than the others.
 6261    //
 6262    // But the live threads (A, B, C) each had send_message called which sets
 6263    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
 6264    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
 6265    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
 6266
 6267    let ids = switcher_ids(&sidebar, cx);
 6268    assert_eq!(
 6269        ids,
 6270        vec![
 6271            session_id_b.clone(),
 6272            session_id_a.clone(),
 6273            session_id_c.clone(),
 6274            session_id_hist.clone()
 6275        ],
 6276    );
 6277
 6278    sidebar.update_in(cx, |sidebar, _window, cx| {
 6279        sidebar.dismiss_thread_switcher(cx);
 6280    });
 6281    cx.run_until_parked();
 6282
 6283    // ── 4. Add another historical thread with older created_at ─────────
 6284    save_thread_metadata(
 6285        acp::SessionId::new(Arc::from("thread-old-historical")),
 6286        Some("Old Historical Thread".into()),
 6287        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
 6288        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
 6289        &project,
 6290        cx,
 6291    );
 6292
 6293    sidebar.update_in(cx, |sidebar, window, cx| {
 6294        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6295    });
 6296    cx.run_until_parked();
 6297
 6298    // Both historical threads have no access or message times. They should
 6299    // appear after accessed threads, sorted by created_at (newest first).
 6300    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
 6301    let ids = switcher_ids(&sidebar, cx);
 6302    assert_eq!(
 6303        ids,
 6304        vec![
 6305            session_id_b,
 6306            session_id_a,
 6307            session_id_c,
 6308            session_id_hist,
 6309            session_id_old_hist,
 6310        ],
 6311    );
 6312
 6313    sidebar.update_in(cx, |sidebar, _window, cx| {
 6314        sidebar.dismiss_thread_switcher(cx);
 6315    });
 6316    cx.run_until_parked();
 6317}
 6318
 6319#[gpui::test]
 6320async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
 6321    let project = init_test_project("/my-project", cx).await;
 6322    let (multi_workspace, cx) =
 6323        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6324    let sidebar = setup_sidebar(&multi_workspace, cx);
 6325
 6326    save_thread_metadata(
 6327        acp::SessionId::new(Arc::from("thread-to-archive")),
 6328        Some("Thread To Archive".into()),
 6329        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 6330        None,
 6331        &project,
 6332        cx,
 6333    );
 6334    cx.run_until_parked();
 6335
 6336    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 6337    cx.run_until_parked();
 6338
 6339    let entries = visible_entries_as_strings(&sidebar, cx);
 6340    assert!(
 6341        entries.iter().any(|e| e.contains("Thread To Archive")),
 6342        "expected thread to be visible before archiving, got: {entries:?}"
 6343    );
 6344
 6345    sidebar.update_in(cx, |sidebar, window, cx| {
 6346        sidebar.archive_thread(
 6347            &acp::SessionId::new(Arc::from("thread-to-archive")),
 6348            window,
 6349            cx,
 6350        );
 6351    });
 6352    cx.run_until_parked();
 6353
 6354    let entries = visible_entries_as_strings(&sidebar, cx);
 6355    assert!(
 6356        !entries.iter().any(|e| e.contains("Thread To Archive")),
 6357        "expected thread to be hidden after archiving, got: {entries:?}"
 6358    );
 6359
 6360    cx.update(|_, cx| {
 6361        let store = ThreadMetadataStore::global(cx);
 6362        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 6363        assert_eq!(archived.len(), 1);
 6364        assert_eq!(
 6365            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 6366            "thread-to-archive"
 6367        );
 6368        assert!(archived[0].archived);
 6369    });
 6370}
 6371
 6372#[gpui::test]
 6373async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
 6374    // Tests two archive scenarios:
 6375    // 1. Archiving a thread in a non-active workspace leaves active_entry
 6376    //    as the current draft.
 6377    // 2. Archiving the thread the user is looking at falls back to a draft
 6378    //    on the same workspace.
 6379    agent_ui::test_support::init_test(cx);
 6380    cx.update(|cx| {
 6381        ThreadStore::init_global(cx);
 6382        ThreadMetadataStore::init_global(cx);
 6383        language_model::LanguageModelRegistry::test(cx);
 6384        prompt_store::init(cx);
 6385    });
 6386
 6387    let fs = FakeFs::new(cx.executor());
 6388    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6389        .await;
 6390    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6391        .await;
 6392    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6393
 6394    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6395    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6396
 6397    let (multi_workspace, cx) =
 6398        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6399    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6400
 6401    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6402        mw.test_add_workspace(project_b.clone(), window, cx)
 6403    });
 6404    let panel_b = add_agent_panel(&workspace_b, cx);
 6405    cx.run_until_parked();
 6406
 6407    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 6408    sidebar.update_in(cx, |sidebar, window, cx| {
 6409        sidebar.create_new_thread(&workspace_b, window, cx);
 6410    });
 6411    cx.run_until_parked();
 6412
 6413    // --- Scenario 1: archive a thread in the non-active workspace ---
 6414
 6415    // Create a thread in project-a (non-active — project-b is active).
 6416    let connection = acp_thread::StubAgentConnection::new();
 6417    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6418        acp::ContentChunk::new("Done".into()),
 6419    )]);
 6420    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 6421    agent_ui::test_support::send_message(&panel_a, cx);
 6422    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 6423    cx.run_until_parked();
 6424
 6425    sidebar.update_in(cx, |sidebar, window, cx| {
 6426        sidebar.archive_thread(&thread_a, window, cx);
 6427    });
 6428    cx.run_until_parked();
 6429
 6430    // active_entry should still be a draft on workspace_b (the active one).
 6431    sidebar.read_with(cx, |sidebar, _| {
 6432        assert!(
 6433            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
 6434            "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
 6435            sidebar.active_entry,
 6436        );
 6437    });
 6438
 6439    // --- Scenario 2: archive the thread the user is looking at ---
 6440
 6441    // Create a thread in project-b (the active workspace) and verify it
 6442    // becomes the active entry.
 6443    let connection = acp_thread::StubAgentConnection::new();
 6444    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6445        acp::ContentChunk::new("Done".into()),
 6446    )]);
 6447    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6448    agent_ui::test_support::send_message(&panel_b, cx);
 6449    let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
 6450    cx.run_until_parked();
 6451
 6452    sidebar.read_with(cx, |sidebar, _| {
 6453        assert!(
 6454            is_active_session(&sidebar, &thread_b),
 6455            "expected active_entry to be Thread({thread_b}), got: {:?}",
 6456            sidebar.active_entry,
 6457        );
 6458    });
 6459
 6460    sidebar.update_in(cx, |sidebar, window, cx| {
 6461        sidebar.archive_thread(&thread_b, window, cx);
 6462    });
 6463    cx.run_until_parked();
 6464
 6465    // Archiving the active thread clears active_entry (no draft is created).
 6466    sidebar.read_with(cx, |sidebar, _| {
 6467        assert!(
 6468            sidebar.active_entry.is_none(),
 6469            "expected None after archiving active thread, got: {:?}",
 6470            sidebar.active_entry,
 6471        );
 6472    });
 6473}
 6474
 6475#[gpui::test]
 6476async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
 6477    // Full flow: create a thread, archive it (removing the workspace),
 6478    // then unarchive. Only the restored thread should appear — no
 6479    // leftover drafts or previously-serialized threads.
 6480    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 6481    let (multi_workspace, cx) =
 6482        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6483    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6484    cx.run_until_parked();
 6485
 6486    // Create a thread and send a message so it's a real thread.
 6487    let connection = acp_thread::StubAgentConnection::new();
 6488    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6489        acp::ContentChunk::new("Hello".into()),
 6490    )]);
 6491    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6492    agent_ui::test_support::send_message(&panel, cx);
 6493    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6494    cx.run_until_parked();
 6495
 6496    // Archive it.
 6497    sidebar.update_in(cx, |sidebar, window, cx| {
 6498        sidebar.archive_thread(&session_id, window, cx);
 6499    });
 6500    cx.run_until_parked();
 6501
 6502    // Grab metadata for unarchive.
 6503    let thread_id = cx.update(|_, cx| {
 6504        ThreadMetadataStore::global(cx)
 6505            .read(cx)
 6506            .entries()
 6507            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6508            .map(|e| e.thread_id)
 6509            .expect("thread should exist")
 6510    });
 6511    let metadata = cx.update(|_, cx| {
 6512        ThreadMetadataStore::global(cx)
 6513            .read(cx)
 6514            .entry(thread_id)
 6515            .cloned()
 6516            .expect("metadata should exist")
 6517    });
 6518
 6519    // Unarchive it — the draft should be replaced by the restored thread.
 6520    sidebar.update_in(cx, |sidebar, window, cx| {
 6521        sidebar.activate_archived_thread(metadata, window, cx);
 6522    });
 6523    cx.run_until_parked();
 6524
 6525    // Only the unarchived thread should be visible — no drafts, no other threads.
 6526    let entries = visible_entries_as_strings(&sidebar, cx);
 6527    let thread_count = entries
 6528        .iter()
 6529        .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
 6530        .count();
 6531    assert_eq!(
 6532        thread_count, 1,
 6533        "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
 6534    );
 6535    assert!(
 6536        !entries.iter().any(|e| e.contains("Draft")),
 6537        "expected no drafts after restoring, got entries: {entries:?}"
 6538    );
 6539}
 6540
 6541#[gpui::test]
 6542async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
 6543    cx: &mut TestAppContext,
 6544) {
 6545    // When a thread is unarchived into a project group that has no open
 6546    // workspace, the sidebar opens a new workspace and loads the thread.
 6547    // No spurious draft should appear alongside the unarchived thread.
 6548    agent_ui::test_support::init_test(cx);
 6549    cx.update(|cx| {
 6550        ThreadStore::init_global(cx);
 6551        ThreadMetadataStore::init_global(cx);
 6552        language_model::LanguageModelRegistry::test(cx);
 6553        prompt_store::init(cx);
 6554    });
 6555
 6556    let fs = FakeFs::new(cx.executor());
 6557    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6558        .await;
 6559    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6560        .await;
 6561    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6562
 6563    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6564    let (multi_workspace, cx) =
 6565        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6566    let sidebar = setup_sidebar(&multi_workspace, cx);
 6567    cx.run_until_parked();
 6568
 6569    // Save an archived thread whose folder_paths point to project-b,
 6570    // which has no open workspace.
 6571    let session_id = acp::SessionId::new(Arc::from("archived-thread"));
 6572    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 6573    let thread_id = ThreadId::new();
 6574    cx.update(|_, cx| {
 6575        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6576            store.save(
 6577                ThreadMetadata {
 6578                    thread_id,
 6579                    session_id: Some(session_id.clone()),
 6580                    agent_id: agent::ZED_AGENT_ID.clone(),
 6581                    title: Some("Unarchived Thread".into()),
 6582                    updated_at: Utc::now(),
 6583                    created_at: None,
 6584                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6585                    archived: true,
 6586                    remote_connection: None,
 6587                },
 6588                cx,
 6589            )
 6590        });
 6591    });
 6592    cx.run_until_parked();
 6593
 6594    // Verify no workspace for project-b exists yet.
 6595    assert_eq!(
 6596        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6597        1,
 6598        "should start with only the project-a workspace"
 6599    );
 6600
 6601    // Un-archive the thread — should open project-b workspace and load it.
 6602    let metadata = cx.update(|_, cx| {
 6603        ThreadMetadataStore::global(cx)
 6604            .read(cx)
 6605            .entry(thread_id)
 6606            .cloned()
 6607            .expect("metadata should exist")
 6608    });
 6609
 6610    sidebar.update_in(cx, |sidebar, window, cx| {
 6611        sidebar.activate_archived_thread(metadata, window, cx);
 6612    });
 6613    cx.run_until_parked();
 6614
 6615    // A second workspace should have been created for project-b.
 6616    assert_eq!(
 6617        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6618        2,
 6619        "should have opened a workspace for the unarchived thread"
 6620    );
 6621
 6622    // The sidebar should show the unarchived thread without a spurious draft
 6623    // in the project-b group.
 6624    let entries = visible_entries_as_strings(&sidebar, cx);
 6625    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6626    // project-a gets a draft (it's the active workspace with no threads),
 6627    // but project-b should NOT have one — only the unarchived thread.
 6628    assert!(
 6629        draft_count <= 1,
 6630        "expected at most one draft (for project-a), got entries: {entries:?}"
 6631    );
 6632    assert!(
 6633        entries.iter().any(|e| e.contains("Unarchived Thread")),
 6634        "expected unarchived thread to appear, got entries: {entries:?}"
 6635    );
 6636}
 6637
 6638#[gpui::test]
 6639async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
 6640    cx: &mut TestAppContext,
 6641) {
 6642    agent_ui::test_support::init_test(cx);
 6643    cx.update(|cx| {
 6644        ThreadStore::init_global(cx);
 6645        ThreadMetadataStore::init_global(cx);
 6646        language_model::LanguageModelRegistry::test(cx);
 6647        prompt_store::init(cx);
 6648    });
 6649
 6650    let fs = FakeFs::new(cx.executor());
 6651    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6652        .await;
 6653    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6654        .await;
 6655    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6656
 6657    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6658    let (multi_workspace, cx) =
 6659        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6660    let sidebar = setup_sidebar(&multi_workspace, cx);
 6661    cx.run_until_parked();
 6662
 6663    let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
 6664    let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
 6665    let original_thread_id = ThreadId::new();
 6666    cx.update(|_, cx| {
 6667        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6668            store.save(
 6669                ThreadMetadata {
 6670                    thread_id: original_thread_id,
 6671                    session_id: Some(session_id.clone()),
 6672                    agent_id: agent::ZED_AGENT_ID.clone(),
 6673                    title: Some("Unarchived Thread".into()),
 6674                    updated_at: Utc::now(),
 6675                    created_at: None,
 6676                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6677                    archived: true,
 6678                    remote_connection: None,
 6679                },
 6680                cx,
 6681            )
 6682        });
 6683    });
 6684    cx.run_until_parked();
 6685
 6686    let metadata = cx.update(|_, cx| {
 6687        ThreadMetadataStore::global(cx)
 6688            .read(cx)
 6689            .entry(original_thread_id)
 6690            .cloned()
 6691            .expect("metadata should exist before unarchive")
 6692    });
 6693
 6694    sidebar.update_in(cx, |sidebar, window, cx| {
 6695        sidebar.activate_archived_thread(metadata, window, cx);
 6696    });
 6697
 6698    cx.run_until_parked();
 6699
 6700    assert_eq!(
 6701        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6702        2,
 6703        "expected unarchive to open the target workspace"
 6704    );
 6705
 6706    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6707        mw.workspaces()
 6708            .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
 6709            .cloned()
 6710            .expect("expected restored workspace for unarchived thread")
 6711    });
 6712    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6713        workspace
 6714            .panel::<AgentPanel>(cx)
 6715            .expect("expected unarchive to install an agent panel in the new workspace")
 6716    });
 6717
 6718    let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6719    assert_eq!(
 6720        restored_thread_id,
 6721        Some(original_thread_id),
 6722        "expected the new workspace's agent panel to target the restored archived thread id"
 6723    );
 6724
 6725    let session_entries = cx.update(|_, cx| {
 6726        ThreadMetadataStore::global(cx)
 6727            .read(cx)
 6728            .entries()
 6729            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6730            .cloned()
 6731            .collect::<Vec<_>>()
 6732    });
 6733    assert_eq!(
 6734        session_entries.len(),
 6735        1,
 6736        "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
 6737    );
 6738    assert_eq!(
 6739        session_entries[0].thread_id, original_thread_id,
 6740        "expected restore into a new workspace to reuse the original thread id"
 6741    );
 6742    assert!(
 6743        !session_entries[0].archived,
 6744        "expected restored thread metadata to be unarchived, got: {:?}",
 6745        session_entries[0]
 6746    );
 6747
 6748    let mapped_thread_id = cx.update(|_, cx| {
 6749        ThreadMetadataStore::global(cx)
 6750            .read(cx)
 6751            .entries()
 6752            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6753            .map(|e| e.thread_id)
 6754    });
 6755    assert_eq!(
 6756        mapped_thread_id,
 6757        Some(original_thread_id),
 6758        "expected session mapping to remain stable after opening the new workspace"
 6759    );
 6760
 6761    let entries = visible_entries_as_strings(&sidebar, cx);
 6762    let real_thread_rows = entries
 6763        .iter()
 6764        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6765        .filter(|entry| !entry.contains("Draft"))
 6766        .count();
 6767    assert_eq!(
 6768        real_thread_rows, 1,
 6769        "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
 6770    );
 6771    assert!(
 6772        entries
 6773            .iter()
 6774            .any(|entry| entry.contains("Unarchived Thread")),
 6775        "expected restored thread row to be visible, got entries: {entries:?}"
 6776    );
 6777}
 6778
 6779#[gpui::test]
 6780async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
 6781    // When a workspace already exists with an empty draft and a thread
 6782    // is unarchived into it, the draft should be replaced — not kept
 6783    // alongside the loaded thread.
 6784    agent_ui::test_support::init_test(cx);
 6785    cx.update(|cx| {
 6786        ThreadStore::init_global(cx);
 6787        ThreadMetadataStore::init_global(cx);
 6788        language_model::LanguageModelRegistry::test(cx);
 6789        prompt_store::init(cx);
 6790    });
 6791
 6792    let fs = FakeFs::new(cx.executor());
 6793    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6794        .await;
 6795    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6796
 6797    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6798    let (multi_workspace, cx) =
 6799        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6800    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6801    cx.run_until_parked();
 6802
 6803    // Create a thread and send a message so it's no longer a draft.
 6804    let connection = acp_thread::StubAgentConnection::new();
 6805    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6806        acp::ContentChunk::new("Done".into()),
 6807    )]);
 6808    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6809    agent_ui::test_support::send_message(&panel, cx);
 6810    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6811    cx.run_until_parked();
 6812
 6813    // Archive the thread — the group is left empty (no draft created).
 6814    sidebar.update_in(cx, |sidebar, window, cx| {
 6815        sidebar.archive_thread(&session_id, window, cx);
 6816    });
 6817    cx.run_until_parked();
 6818
 6819    // Un-archive the thread.
 6820    let thread_id = cx.update(|_, cx| {
 6821        ThreadMetadataStore::global(cx)
 6822            .read(cx)
 6823            .entries()
 6824            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6825            .map(|e| e.thread_id)
 6826            .expect("thread should exist in store")
 6827    });
 6828    let metadata = cx.update(|_, cx| {
 6829        ThreadMetadataStore::global(cx)
 6830            .read(cx)
 6831            .entry(thread_id)
 6832            .cloned()
 6833            .expect("metadata should exist")
 6834    });
 6835
 6836    sidebar.update_in(cx, |sidebar, window, cx| {
 6837        sidebar.activate_archived_thread(metadata, window, cx);
 6838    });
 6839    cx.run_until_parked();
 6840
 6841    // The draft should be gone — only the unarchived thread remains.
 6842    let entries = visible_entries_as_strings(&sidebar, cx);
 6843    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6844    assert_eq!(
 6845        draft_count, 0,
 6846        "expected no drafts after unarchiving, got entries: {entries:?}"
 6847    );
 6848}
 6849
 6850#[gpui::test]
 6851async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
 6852    cx: &mut TestAppContext,
 6853) {
 6854    agent_ui::test_support::init_test(cx);
 6855    cx.update(|cx| {
 6856        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6857        ThreadStore::init_global(cx);
 6858        ThreadMetadataStore::init_global(cx);
 6859        language_model::LanguageModelRegistry::test(cx);
 6860        prompt_store::init(cx);
 6861    });
 6862
 6863    let fs = FakeFs::new(cx.executor());
 6864    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6865        .await;
 6866    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6867        .await;
 6868    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6869
 6870    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6871    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6872
 6873    let (multi_workspace, cx) =
 6874        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6875    let sidebar = setup_sidebar(&multi_workspace, cx);
 6876
 6877    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 6878    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6879        mw.test_add_workspace(project_b.clone(), window, cx)
 6880    });
 6881    let _panel_b = add_agent_panel(&workspace_b, cx);
 6882    cx.run_until_parked();
 6883
 6884    multi_workspace.update_in(cx, |mw, window, cx| {
 6885        mw.activate(workspace_a.clone(), window, cx);
 6886    });
 6887    cx.run_until_parked();
 6888
 6889    let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
 6890    let thread_id = ThreadId::new();
 6891    cx.update(|_, cx| {
 6892        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6893            store.save(
 6894                ThreadMetadata {
 6895                    thread_id,
 6896                    session_id: Some(session_id.clone()),
 6897                    agent_id: agent::ZED_AGENT_ID.clone(),
 6898                    title: Some("Restored In Inactive Workspace".into()),
 6899                    updated_at: Utc::now(),
 6900                    created_at: None,
 6901                    worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 6902                        PathBuf::from("/project-b"),
 6903                    ])),
 6904                    archived: true,
 6905                    remote_connection: None,
 6906                },
 6907                cx,
 6908            )
 6909        });
 6910    });
 6911    cx.run_until_parked();
 6912
 6913    let metadata = cx.update(|_, cx| {
 6914        ThreadMetadataStore::global(cx)
 6915            .read(cx)
 6916            .entry(thread_id)
 6917            .cloned()
 6918            .expect("archived metadata should exist before restore")
 6919    });
 6920
 6921    sidebar.update_in(cx, |sidebar, window, cx| {
 6922        sidebar.activate_archived_thread(metadata, window, cx);
 6923    });
 6924
 6925    let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
 6926        workspace.panel::<AgentPanel>(cx).expect(
 6927            "target workspace should still have an agent panel immediately after activation",
 6928        )
 6929    });
 6930    let immediate_active_thread_id =
 6931        panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6932    let immediate_draft_ids =
 6933        panel_b_before_settle.read_with(cx, |panel, cx| panel.draft_thread_ids(cx));
 6934
 6935    cx.run_until_parked();
 6936
 6937    sidebar.read_with(cx, |sidebar, _cx| {
 6938        assert_active_thread(
 6939            sidebar,
 6940            &session_id,
 6941            "unarchiving into an inactive existing workspace should end on the restored thread",
 6942        );
 6943    });
 6944
 6945    let panel_b = workspace_b.read_with(cx, |workspace, cx| {
 6946        workspace
 6947            .panel::<AgentPanel>(cx)
 6948            .expect("target workspace should still have an agent panel")
 6949    });
 6950    assert_eq!(
 6951        panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6952        Some(thread_id),
 6953        "expected target panel to activate the restored thread id"
 6954    );
 6955    assert!(
 6956        immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
 6957        "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}, draft_ids={immediate_draft_ids:?}"
 6958    );
 6959
 6960    let entries = visible_entries_as_strings(&sidebar, cx);
 6961    let target_rows: Vec<_> = entries
 6962        .iter()
 6963        .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
 6964        .cloned()
 6965        .collect();
 6966    assert_eq!(
 6967        target_rows.len(),
 6968        1,
 6969        "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
 6970    );
 6971    assert!(
 6972        target_rows[0].contains("Restored In Inactive Workspace"),
 6973        "expected the remaining row to be the restored thread, got entries: {entries:?}"
 6974    );
 6975    assert!(
 6976        !target_rows[0].contains("Draft"),
 6977        "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
 6978    );
 6979}
 6980
 6981#[gpui::test]
 6982async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
 6983    cx: &mut TestAppContext,
 6984) {
 6985    agent_ui::test_support::init_test(cx);
 6986    cx.update(|cx| {
 6987        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6988        ThreadStore::init_global(cx);
 6989        ThreadMetadataStore::init_global(cx);
 6990        language_model::LanguageModelRegistry::test(cx);
 6991        prompt_store::init(cx);
 6992    });
 6993
 6994    let fs = FakeFs::new(cx.executor());
 6995    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6996        .await;
 6997    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6998        .await;
 6999    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7000
 7001    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 7002    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 7003
 7004    let (multi_workspace, cx) =
 7005        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 7006    let sidebar = setup_sidebar(&multi_workspace, cx);
 7007
 7008    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 7009        mw.test_add_workspace(project_b.clone(), window, cx)
 7010    });
 7011    let panel_b = add_agent_panel(&workspace_b, cx);
 7012    cx.run_until_parked();
 7013
 7014    let connection = acp_thread::StubAgentConnection::new();
 7015    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 7016        acp::ContentChunk::new("Done".into()),
 7017    )]);
 7018    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 7019    agent_ui::test_support::send_message(&panel_b, cx);
 7020    let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
 7021    save_test_thread_metadata(&session_id, &project_b, cx).await;
 7022    cx.run_until_parked();
 7023
 7024    sidebar.update_in(cx, |sidebar, window, cx| {
 7025        sidebar.archive_thread(&session_id, window, cx);
 7026    });
 7027
 7028    cx.run_until_parked();
 7029
 7030    let archived_metadata = cx.update(|_, cx| {
 7031        let store = ThreadMetadataStore::global(cx).read(cx);
 7032        let thread_id = store
 7033            .entries()
 7034            .find(|e| e.session_id.as_ref() == Some(&session_id))
 7035            .map(|e| e.thread_id)
 7036            .expect("archived thread should still exist in metadata store");
 7037        let metadata = store
 7038            .entry(thread_id)
 7039            .cloned()
 7040            .expect("archived metadata should still exist after archive");
 7041        assert!(
 7042            metadata.archived,
 7043            "thread should be archived before project removal"
 7044        );
 7045        metadata
 7046    });
 7047
 7048    let group_key_b =
 7049        project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
 7050    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 7051        mw.remove_project_group(&group_key_b, window, cx)
 7052    });
 7053    remove_task
 7054        .await
 7055        .expect("remove project group task should complete");
 7056    cx.run_until_parked();
 7057
 7058    assert_eq!(
 7059        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7060        1,
 7061        "removing the archived thread's parent project group should remove its workspace"
 7062    );
 7063
 7064    sidebar.update_in(cx, |sidebar, window, cx| {
 7065        sidebar.activate_archived_thread(archived_metadata.clone(), window, cx);
 7066    });
 7067    cx.run_until_parked();
 7068
 7069    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 7070        mw.workspaces()
 7071            .find(|workspace| {
 7072                PathList::new(&workspace.read(cx).root_paths(cx))
 7073                    == PathList::new(&[PathBuf::from("/project-b")])
 7074            })
 7075            .cloned()
 7076            .expect("expected unarchive to recreate the removed project workspace")
 7077    });
 7078    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 7079        workspace
 7080            .panel::<AgentPanel>(cx)
 7081            .expect("expected restored workspace to bootstrap an agent panel")
 7082    });
 7083
 7084    let restored_thread_id = cx.update(|_, cx| {
 7085        ThreadMetadataStore::global(cx)
 7086            .read(cx)
 7087            .entries()
 7088            .find(|e| e.session_id.as_ref() == Some(&session_id))
 7089            .map(|e| e.thread_id)
 7090            .expect("session should still map to restored thread id")
 7091    });
 7092    assert_eq!(
 7093        restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 7094        Some(restored_thread_id),
 7095        "expected unarchive after project removal to activate the restored real thread"
 7096    );
 7097
 7098    sidebar.read_with(cx, |sidebar, _cx| {
 7099        assert_active_thread(
 7100            sidebar,
 7101            &session_id,
 7102            "expected sidebar active entry to track the restored thread after project removal",
 7103        );
 7104    });
 7105
 7106    let entries = visible_entries_as_strings(&sidebar, cx);
 7107    let restored_title = archived_metadata.display_title().to_string();
 7108    let matching_rows: Vec<_> = entries
 7109        .iter()
 7110        .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
 7111        .cloned()
 7112        .collect();
 7113    assert_eq!(
 7114        matching_rows.len(),
 7115        1,
 7116        "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
 7117    );
 7118    assert!(
 7119        !matching_rows[0].contains("Draft"),
 7120        "expected no draft row after unarchive following project removal, got entries: {entries:?}"
 7121    );
 7122}
 7123
 7124#[gpui::test]
 7125async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
 7126    agent_ui::test_support::init_test(cx);
 7127    cx.update(|cx| {
 7128        ThreadStore::init_global(cx);
 7129        ThreadMetadataStore::init_global(cx);
 7130        language_model::LanguageModelRegistry::test(cx);
 7131        prompt_store::init(cx);
 7132    });
 7133
 7134    let fs = FakeFs::new(cx.executor());
 7135    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 7136        .await;
 7137    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7138
 7139    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 7140    let (multi_workspace, cx) =
 7141        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 7142    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 7143    cx.run_until_parked();
 7144
 7145    let connection = acp_thread::StubAgentConnection::new();
 7146    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 7147        acp::ContentChunk::new("Done".into()),
 7148    )]);
 7149    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 7150    agent_ui::test_support::send_message(&panel, cx);
 7151    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 7152    cx.run_until_parked();
 7153
 7154    let original_thread_id = cx.update(|_, cx| {
 7155        ThreadMetadataStore::global(cx)
 7156            .read(cx)
 7157            .entries()
 7158            .find(|e| e.session_id.as_ref() == Some(&session_id))
 7159            .map(|e| e.thread_id)
 7160            .expect("thread should exist in store before archiving")
 7161    });
 7162
 7163    sidebar.update_in(cx, |sidebar, window, cx| {
 7164        sidebar.archive_thread(&session_id, window, cx);
 7165    });
 7166    cx.run_until_parked();
 7167
 7168    let metadata = cx.update(|_, cx| {
 7169        ThreadMetadataStore::global(cx)
 7170            .read(cx)
 7171            .entry(original_thread_id)
 7172            .cloned()
 7173            .expect("metadata should exist after archiving")
 7174    });
 7175
 7176    sidebar.update_in(cx, |sidebar, window, cx| {
 7177        sidebar.activate_archived_thread(metadata, window, cx);
 7178    });
 7179    cx.run_until_parked();
 7180
 7181    let session_entries = cx.update(|_, cx| {
 7182        ThreadMetadataStore::global(cx)
 7183            .read(cx)
 7184            .entries()
 7185            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7186            .cloned()
 7187            .collect::<Vec<_>>()
 7188    });
 7189
 7190    assert_eq!(
 7191        session_entries.len(),
 7192        1,
 7193        "expected exactly one metadata row for the restored session, got: {session_entries:?}"
 7194    );
 7195    assert_eq!(
 7196        session_entries[0].thread_id, original_thread_id,
 7197        "expected unarchive to reuse the original thread id instead of creating a duplicate row"
 7198    );
 7199    assert!(
 7200        !session_entries[0].is_draft(),
 7201        "expected restored metadata to be a real thread, got: {:?}",
 7202        session_entries[0]
 7203    );
 7204
 7205    let entries = visible_entries_as_strings(&sidebar, cx);
 7206    let real_thread_rows = entries
 7207        .iter()
 7208        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7209        .filter(|entry| !entry.contains("Draft"))
 7210        .count();
 7211    assert_eq!(
 7212        real_thread_rows, 1,
 7213        "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
 7214    );
 7215    assert!(
 7216        !entries.iter().any(|entry| entry.contains("Draft")),
 7217        "expected no draft rows after restoring, got entries: {entries:?}"
 7218    );
 7219}
 7220
 7221#[gpui::test]
 7222async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
 7223    cx: &mut TestAppContext,
 7224) {
 7225    // When a thread is archived while the user is in a different workspace,
 7226    // the group is left empty (no draft is created). Switching back to that
 7227    // workspace should show no active entry.
 7228    agent_ui::test_support::init_test(cx);
 7229    cx.update(|cx| {
 7230        ThreadStore::init_global(cx);
 7231        ThreadMetadataStore::init_global(cx);
 7232        language_model::LanguageModelRegistry::test(cx);
 7233        prompt_store::init(cx);
 7234    });
 7235
 7236    let fs = FakeFs::new(cx.executor());
 7237    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 7238        .await;
 7239    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 7240        .await;
 7241    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7242
 7243    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 7244    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 7245
 7246    let (multi_workspace, cx) =
 7247        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 7248    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 7249
 7250    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 7251        mw.test_add_workspace(project_b.clone(), window, cx)
 7252    });
 7253    let _panel_b = add_agent_panel(&workspace_b, cx);
 7254    cx.run_until_parked();
 7255
 7256    // Create a thread in project-a's panel (currently non-active).
 7257    let connection = acp_thread::StubAgentConnection::new();
 7258    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 7259        acp::ContentChunk::new("Done".into()),
 7260    )]);
 7261    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 7262    agent_ui::test_support::send_message(&panel_a, cx);
 7263    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 7264    cx.run_until_parked();
 7265
 7266    // Archive it while project-b is active.
 7267    sidebar.update_in(cx, |sidebar, window, cx| {
 7268        sidebar.archive_thread(&thread_a, window, cx);
 7269    });
 7270    cx.run_until_parked();
 7271
 7272    // Switch back to project-a. Its panel was cleared during archiving,
 7273    // so active_entry should be None (no draft is created).
 7274    let workspace_a =
 7275        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7276    multi_workspace.update_in(cx, |mw, window, cx| {
 7277        mw.activate(workspace_a.clone(), window, cx);
 7278    });
 7279    cx.run_until_parked();
 7280
 7281    sidebar.update_in(cx, |sidebar, _window, cx| {
 7282        sidebar.update_entries(cx);
 7283    });
 7284    cx.run_until_parked();
 7285
 7286    sidebar.read_with(cx, |sidebar, _| {
 7287        assert!(
 7288            sidebar.active_entry.is_none(),
 7289            "expected no active entry after switching to workspace with archived thread, got: {:?}",
 7290            sidebar.active_entry,
 7291        );
 7292    });
 7293}
 7294
 7295#[gpui::test]
 7296async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
 7297    let project = init_test_project("/my-project", cx).await;
 7298    let (multi_workspace, cx) =
 7299        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 7300    let sidebar = setup_sidebar(&multi_workspace, cx);
 7301
 7302    save_thread_metadata(
 7303        acp::SessionId::new(Arc::from("visible-thread")),
 7304        Some("Visible Thread".into()),
 7305        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7306        None,
 7307        &project,
 7308        cx,
 7309    );
 7310
 7311    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
 7312    save_thread_metadata(
 7313        archived_thread_session_id.clone(),
 7314        Some("Archived Thread".into()),
 7315        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7316        None,
 7317        &project,
 7318        cx,
 7319    );
 7320
 7321    cx.update(|_, cx| {
 7322        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7323            let thread_id = store
 7324                .entries()
 7325                .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
 7326                .map(|e| e.thread_id)
 7327                .unwrap();
 7328            store.archive(thread_id, None, cx)
 7329        })
 7330    });
 7331    cx.run_until_parked();
 7332
 7333    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7334    cx.run_until_parked();
 7335
 7336    let entries = visible_entries_as_strings(&sidebar, cx);
 7337    assert!(
 7338        entries.iter().any(|e| e.contains("Visible Thread")),
 7339        "expected visible thread in sidebar, got: {entries:?}"
 7340    );
 7341    assert!(
 7342        !entries.iter().any(|e| e.contains("Archived Thread")),
 7343        "expected archived thread to be hidden from sidebar, got: {entries:?}"
 7344    );
 7345
 7346    cx.update(|_, cx| {
 7347        let store = ThreadMetadataStore::global(cx);
 7348        let all: Vec<_> = store.read(cx).entries().collect();
 7349        assert_eq!(
 7350            all.len(),
 7351            2,
 7352            "expected 2 total entries in the store, got: {}",
 7353            all.len()
 7354        );
 7355
 7356        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 7357        assert_eq!(archived.len(), 1);
 7358        assert_eq!(
 7359            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 7360            "archived-thread"
 7361        );
 7362    });
 7363}
 7364
 7365#[gpui::test]
 7366async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
 7367    cx: &mut TestAppContext,
 7368) {
 7369    // When a linked worktree has a single thread and that thread is archived,
 7370    // the sidebar must NOT create a new thread on the same worktree (which
 7371    // would prevent the worktree from being cleaned up on disk). Instead,
 7372    // archive_thread switches to a sibling thread on the main workspace (or
 7373    // creates a draft there) before archiving the metadata.
 7374    agent_ui::test_support::init_test(cx);
 7375    cx.update(|cx| {
 7376        ThreadStore::init_global(cx);
 7377        ThreadMetadataStore::init_global(cx);
 7378        language_model::LanguageModelRegistry::test(cx);
 7379        prompt_store::init(cx);
 7380    });
 7381
 7382    let fs = FakeFs::new(cx.executor());
 7383
 7384    fs.insert_tree(
 7385        "/project",
 7386        serde_json::json!({
 7387            ".git": {},
 7388            "src": {},
 7389        }),
 7390    )
 7391    .await;
 7392
 7393    fs.add_linked_worktree_for_repo(
 7394        Path::new("/project/.git"),
 7395        false,
 7396        git::repository::Worktree {
 7397            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7398            ref_name: Some("refs/heads/ochre-drift".into()),
 7399            sha: "aaa".into(),
 7400            is_main: false,
 7401            is_bare: false,
 7402        },
 7403    )
 7404    .await;
 7405
 7406    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7407
 7408    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7409    let worktree_project =
 7410        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7411
 7412    main_project
 7413        .update(cx, |p, cx| p.git_scans_complete(cx))
 7414        .await;
 7415    worktree_project
 7416        .update(cx, |p, cx| p.git_scans_complete(cx))
 7417        .await;
 7418
 7419    let (multi_workspace, cx) =
 7420        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7421
 7422    let sidebar = setup_sidebar(&multi_workspace, cx);
 7423
 7424    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7425        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7426    });
 7427
 7428    // Set up both workspaces with agent panels.
 7429    let main_workspace =
 7430        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7431    let _main_panel = add_agent_panel(&main_workspace, cx);
 7432    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7433
 7434    // Activate the linked worktree workspace so the sidebar tracks it.
 7435    multi_workspace.update_in(cx, |mw, window, cx| {
 7436        mw.activate(worktree_workspace.clone(), window, cx);
 7437    });
 7438
 7439    // Open a thread in the linked worktree panel and send a message
 7440    // so it becomes the active thread.
 7441    let connection = StubAgentConnection::new();
 7442    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7443    send_message(&worktree_panel, cx);
 7444
 7445    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7446
 7447    // Give the thread a response chunk so it has content.
 7448    cx.update(|_, cx| {
 7449        connection.send_update(
 7450            worktree_thread_id.clone(),
 7451            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7452            cx,
 7453        );
 7454    });
 7455
 7456    // Save the worktree thread's metadata.
 7457    save_thread_metadata(
 7458        worktree_thread_id.clone(),
 7459        Some("Ochre Drift Thread".into()),
 7460        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7461        None,
 7462        &worktree_project,
 7463        cx,
 7464    );
 7465
 7466    // Also save a thread on the main project so there's a sibling in the
 7467    // group that can be selected after archiving.
 7468    save_thread_metadata(
 7469        acp::SessionId::new(Arc::from("main-project-thread")),
 7470        Some("Main Project Thread".into()),
 7471        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7472        None,
 7473        &main_project,
 7474        cx,
 7475    );
 7476
 7477    cx.run_until_parked();
 7478
 7479    // Verify the linked worktree thread appears with its chip.
 7480    // The live thread title comes from the message text ("Hello"), not
 7481    // the metadata title we saved.
 7482    let entries_before = visible_entries_as_strings(&sidebar, cx);
 7483    assert!(
 7484        entries_before
 7485            .iter()
 7486            .any(|s| s.contains("{wt-ochre-drift}")),
 7487        "expected worktree thread with chip before archiving, got: {entries_before:?}"
 7488    );
 7489    assert!(
 7490        entries_before
 7491            .iter()
 7492            .any(|s| s.contains("Main Project Thread")),
 7493        "expected main project thread before archiving, got: {entries_before:?}"
 7494    );
 7495
 7496    // Confirm the worktree thread is the active entry.
 7497    sidebar.read_with(cx, |s, _| {
 7498        assert_active_thread(
 7499            s,
 7500            &worktree_thread_id,
 7501            "worktree thread should be active before archiving",
 7502        );
 7503    });
 7504
 7505    // Archive the worktree thread — it's the only thread using ochre-drift.
 7506    sidebar.update_in(cx, |sidebar, window, cx| {
 7507        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7508    });
 7509
 7510    cx.run_until_parked();
 7511
 7512    // The archived thread should no longer appear in the sidebar.
 7513    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7514    assert!(
 7515        !entries_after
 7516            .iter()
 7517            .any(|s| s.contains("Ochre Drift Thread")),
 7518        "archived thread should be hidden, got: {entries_after:?}"
 7519    );
 7520
 7521    // No "+ New Thread" entry should appear with the ochre-drift worktree
 7522    // chip — that would keep the worktree alive and prevent cleanup.
 7523    assert!(
 7524        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7525        "no entry should reference the archived worktree, got: {entries_after:?}"
 7526    );
 7527
 7528    // The main project thread should still be visible.
 7529    assert!(
 7530        entries_after
 7531            .iter()
 7532            .any(|s| s.contains("Main Project Thread")),
 7533        "main project thread should still be visible, got: {entries_after:?}"
 7534    );
 7535}
 7536
 7537#[gpui::test]
 7538async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
 7539    cx: &mut TestAppContext,
 7540) {
 7541    // When a linked worktree thread is the ONLY thread in the project group
 7542    // (no threads on the main repo either), archiving it should leave the
 7543    // group empty with no active entry.
 7544    agent_ui::test_support::init_test(cx);
 7545    cx.update(|cx| {
 7546        ThreadStore::init_global(cx);
 7547        ThreadMetadataStore::init_global(cx);
 7548        language_model::LanguageModelRegistry::test(cx);
 7549        prompt_store::init(cx);
 7550    });
 7551
 7552    let fs = FakeFs::new(cx.executor());
 7553
 7554    fs.insert_tree(
 7555        "/project",
 7556        serde_json::json!({
 7557            ".git": {},
 7558            "src": {},
 7559        }),
 7560    )
 7561    .await;
 7562
 7563    fs.add_linked_worktree_for_repo(
 7564        Path::new("/project/.git"),
 7565        false,
 7566        git::repository::Worktree {
 7567            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7568            ref_name: Some("refs/heads/ochre-drift".into()),
 7569            sha: "aaa".into(),
 7570            is_main: false,
 7571            is_bare: false,
 7572        },
 7573    )
 7574    .await;
 7575
 7576    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7577
 7578    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7579    let worktree_project =
 7580        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7581
 7582    main_project
 7583        .update(cx, |p, cx| p.git_scans_complete(cx))
 7584        .await;
 7585    worktree_project
 7586        .update(cx, |p, cx| p.git_scans_complete(cx))
 7587        .await;
 7588
 7589    let (multi_workspace, cx) =
 7590        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7591
 7592    let sidebar = setup_sidebar(&multi_workspace, cx);
 7593
 7594    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7595        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7596    });
 7597
 7598    let main_workspace =
 7599        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7600    let _main_panel = add_agent_panel(&main_workspace, cx);
 7601    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7602
 7603    // Activate the linked worktree workspace.
 7604    multi_workspace.update_in(cx, |mw, window, cx| {
 7605        mw.activate(worktree_workspace.clone(), window, cx);
 7606    });
 7607
 7608    // Open a thread on the linked worktree — this is the ONLY thread.
 7609    let connection = StubAgentConnection::new();
 7610    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7611    send_message(&worktree_panel, cx);
 7612
 7613    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7614
 7615    cx.update(|_, cx| {
 7616        connection.send_update(
 7617            worktree_thread_id.clone(),
 7618            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7619            cx,
 7620        );
 7621    });
 7622
 7623    save_thread_metadata(
 7624        worktree_thread_id.clone(),
 7625        Some("Ochre Drift Thread".into()),
 7626        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7627        None,
 7628        &worktree_project,
 7629        cx,
 7630    );
 7631
 7632    cx.run_until_parked();
 7633
 7634    // Archive it — there are no other threads in the group.
 7635    sidebar.update_in(cx, |sidebar, window, cx| {
 7636        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7637    });
 7638
 7639    cx.run_until_parked();
 7640
 7641    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7642
 7643    // No entry should reference the linked worktree.
 7644    assert!(
 7645        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7646        "no entry should reference the archived worktree, got: {entries_after:?}"
 7647    );
 7648
 7649    // The active entry should be None — no draft is created.
 7650    sidebar.read_with(cx, |s, _| {
 7651        assert!(
 7652            s.active_entry.is_none(),
 7653            "expected no active entry after archiving the last thread, got: {:?}",
 7654            s.active_entry,
 7655        );
 7656    });
 7657}
 7658
 7659#[gpui::test]
 7660async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
 7661    cx: &mut TestAppContext,
 7662) {
 7663    // When an archived thread belongs to a linked worktree whose main repo is
 7664    // already open, unarchiving should reopen the linked workspace into the
 7665    // same project group and show only the restored real thread row.
 7666    agent_ui::test_support::init_test(cx);
 7667    cx.update(|cx| {
 7668        ThreadStore::init_global(cx);
 7669        ThreadMetadataStore::init_global(cx);
 7670        language_model::LanguageModelRegistry::test(cx);
 7671        prompt_store::init(cx);
 7672    });
 7673
 7674    let fs = FakeFs::new(cx.executor());
 7675
 7676    fs.insert_tree(
 7677        "/project",
 7678        serde_json::json!({
 7679            ".git": {},
 7680            "src": {},
 7681        }),
 7682    )
 7683    .await;
 7684
 7685    fs.insert_tree(
 7686        "/wt-ochre-drift",
 7687        serde_json::json!({
 7688            ".git": "gitdir: /project/.git/worktrees/ochre-drift",
 7689            "src": {},
 7690        }),
 7691    )
 7692    .await;
 7693
 7694    fs.add_linked_worktree_for_repo(
 7695        Path::new("/project/.git"),
 7696        false,
 7697        git::repository::Worktree {
 7698            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7699            ref_name: Some("refs/heads/ochre-drift".into()),
 7700            sha: "aaa".into(),
 7701            is_main: false,
 7702            is_bare: false,
 7703        },
 7704    )
 7705    .await;
 7706
 7707    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7708
 7709    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7710    let worktree_project =
 7711        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7712
 7713    main_project
 7714        .update(cx, |p, cx| p.git_scans_complete(cx))
 7715        .await;
 7716    worktree_project
 7717        .update(cx, |p, cx| p.git_scans_complete(cx))
 7718        .await;
 7719
 7720    let (multi_workspace, cx) =
 7721        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7722
 7723    let sidebar = setup_sidebar(&multi_workspace, cx);
 7724    let main_workspace =
 7725        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7726    let _main_panel = add_agent_panel(&main_workspace, cx);
 7727    cx.run_until_parked();
 7728
 7729    let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
 7730    let original_thread_id = ThreadId::new();
 7731    let main_paths = PathList::new(&[PathBuf::from("/project")]);
 7732    let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
 7733
 7734    cx.update(|_, cx| {
 7735        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7736            store.save(
 7737                ThreadMetadata {
 7738                    thread_id: original_thread_id,
 7739                    session_id: Some(session_id.clone()),
 7740                    agent_id: agent::ZED_AGENT_ID.clone(),
 7741                    title: Some("Unarchived Linked Thread".into()),
 7742                    updated_at: Utc::now(),
 7743                    created_at: None,
 7744                    worktree_paths: WorktreePaths::from_path_lists(
 7745                        main_paths.clone(),
 7746                        folder_paths.clone(),
 7747                    )
 7748                    .expect("main and folder paths should be well-formed"),
 7749                    archived: true,
 7750                    remote_connection: None,
 7751                },
 7752                cx,
 7753            )
 7754        });
 7755    });
 7756    cx.run_until_parked();
 7757
 7758    let metadata = cx.update(|_, cx| {
 7759        ThreadMetadataStore::global(cx)
 7760            .read(cx)
 7761            .entry(original_thread_id)
 7762            .cloned()
 7763            .expect("archived linked-worktree metadata should exist before restore")
 7764    });
 7765
 7766    sidebar.update_in(cx, |sidebar, window, cx| {
 7767        sidebar.activate_archived_thread(metadata, window, cx);
 7768    });
 7769    cx.run_until_parked();
 7770
 7771    assert_eq!(
 7772        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7773        2,
 7774        "expected unarchive to open the linked worktree workspace into the project group"
 7775    );
 7776
 7777    let session_entries = cx.update(|_, cx| {
 7778        ThreadMetadataStore::global(cx)
 7779            .read(cx)
 7780            .entries()
 7781            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7782            .cloned()
 7783            .collect::<Vec<_>>()
 7784    });
 7785    assert_eq!(
 7786        session_entries.len(),
 7787        1,
 7788        "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
 7789    );
 7790    assert_eq!(
 7791        session_entries[0].thread_id, original_thread_id,
 7792        "expected unarchive to reuse the original linked worktree thread id"
 7793    );
 7794    assert!(
 7795        !session_entries[0].archived,
 7796        "expected restored linked worktree metadata to be unarchived, got: {:?}",
 7797        session_entries[0]
 7798    );
 7799
 7800    let assert_no_extra_rows = |entries: &[String]| {
 7801        let real_thread_rows = entries
 7802            .iter()
 7803            .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7804            .filter(|entry| !entry.contains("Draft"))
 7805            .count();
 7806        assert_eq!(
 7807            real_thread_rows, 1,
 7808            "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
 7809        );
 7810        assert!(
 7811            !entries.iter().any(|entry| entry.contains("Draft")),
 7812            "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
 7813        );
 7814        assert!(
 7815            !entries
 7816                .iter()
 7817                .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
 7818            "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
 7819        );
 7820        assert!(
 7821            entries
 7822                .iter()
 7823                .any(|entry| entry.contains("Unarchived Linked Thread")),
 7824            "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
 7825        );
 7826    };
 7827
 7828    let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
 7829    assert_no_extra_rows(&entries_after_restore);
 7830
 7831    // The reported bug may only appear after an extra scheduling turn.
 7832    cx.run_until_parked();
 7833
 7834    let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
 7835    assert_no_extra_rows(&entries_after_extra_turns);
 7836}
 7837
 7838#[gpui::test]
 7839async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
 7840    // When a linked worktree thread is archived but the group has other
 7841    // threads (e.g. on the main project), archive_thread should select
 7842    // the nearest sibling.
 7843    agent_ui::test_support::init_test(cx);
 7844    cx.update(|cx| {
 7845        ThreadStore::init_global(cx);
 7846        ThreadMetadataStore::init_global(cx);
 7847        language_model::LanguageModelRegistry::test(cx);
 7848        prompt_store::init(cx);
 7849    });
 7850
 7851    let fs = FakeFs::new(cx.executor());
 7852
 7853    fs.insert_tree(
 7854        "/project",
 7855        serde_json::json!({
 7856            ".git": {},
 7857            "src": {},
 7858        }),
 7859    )
 7860    .await;
 7861
 7862    fs.add_linked_worktree_for_repo(
 7863        Path::new("/project/.git"),
 7864        false,
 7865        git::repository::Worktree {
 7866            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7867            ref_name: Some("refs/heads/ochre-drift".into()),
 7868            sha: "aaa".into(),
 7869            is_main: false,
 7870            is_bare: false,
 7871        },
 7872    )
 7873    .await;
 7874
 7875    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7876
 7877    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7878    let worktree_project =
 7879        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7880
 7881    main_project
 7882        .update(cx, |p, cx| p.git_scans_complete(cx))
 7883        .await;
 7884    worktree_project
 7885        .update(cx, |p, cx| p.git_scans_complete(cx))
 7886        .await;
 7887
 7888    let (multi_workspace, cx) =
 7889        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7890
 7891    let sidebar = setup_sidebar(&multi_workspace, cx);
 7892
 7893    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7894        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7895    });
 7896
 7897    let main_workspace =
 7898        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7899    let _main_panel = add_agent_panel(&main_workspace, cx);
 7900    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7901
 7902    // Activate the linked worktree workspace.
 7903    multi_workspace.update_in(cx, |mw, window, cx| {
 7904        mw.activate(worktree_workspace.clone(), window, cx);
 7905    });
 7906
 7907    // Open a thread on the linked worktree.
 7908    let connection = StubAgentConnection::new();
 7909    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7910    send_message(&worktree_panel, cx);
 7911
 7912    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7913
 7914    cx.update(|_, cx| {
 7915        connection.send_update(
 7916            worktree_thread_id.clone(),
 7917            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7918            cx,
 7919        );
 7920    });
 7921
 7922    save_thread_metadata(
 7923        worktree_thread_id.clone(),
 7924        Some("Ochre Drift Thread".into()),
 7925        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7926        None,
 7927        &worktree_project,
 7928        cx,
 7929    );
 7930
 7931    // Save a sibling thread on the main project.
 7932    let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
 7933    save_thread_metadata(
 7934        main_thread_id,
 7935        Some("Main Project Thread".into()),
 7936        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7937        None,
 7938        &main_project,
 7939        cx,
 7940    );
 7941
 7942    cx.run_until_parked();
 7943
 7944    // Confirm the worktree thread is active.
 7945    sidebar.read_with(cx, |s, _| {
 7946        assert_active_thread(
 7947            s,
 7948            &worktree_thread_id,
 7949            "worktree thread should be active before archiving",
 7950        );
 7951    });
 7952
 7953    // Archive the worktree thread.
 7954    sidebar.update_in(cx, |sidebar, window, cx| {
 7955        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7956    });
 7957
 7958    cx.run_until_parked();
 7959
 7960    // The worktree workspace was removed and a draft was created on the
 7961    // main workspace. No entry should reference the linked worktree.
 7962    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7963    assert!(
 7964        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7965        "no entry should reference the archived worktree, got: {entries_after:?}"
 7966    );
 7967
 7968    // The main project thread should still be visible.
 7969    assert!(
 7970        entries_after
 7971            .iter()
 7972            .any(|s| s.contains("Main Project Thread")),
 7973        "main project thread should still be visible, got: {entries_after:?}"
 7974    );
 7975}
 7976
 7977#[gpui::test]
 7978async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
 7979    // When a linked worktree is opened as its own workspace and the user
 7980    // creates a draft thread from it, then switches away, the workspace must
 7981    // still be reachable from that DraftThread sidebar entry. Pressing
 7982    // RemoveSelectedThread (shift-backspace) on that entry should remove the
 7983    // workspace.
 7984    init_test(cx);
 7985    let fs = FakeFs::new(cx.executor());
 7986
 7987    fs.insert_tree(
 7988        "/project",
 7989        serde_json::json!({
 7990            ".git": {
 7991                "worktrees": {
 7992                    "feature-a": {
 7993                        "commondir": "../../",
 7994                        "HEAD": "ref: refs/heads/feature-a",
 7995                    },
 7996                },
 7997            },
 7998            "src": {},
 7999        }),
 8000    )
 8001    .await;
 8002
 8003    fs.insert_tree(
 8004        "/wt-feature-a",
 8005        serde_json::json!({
 8006            ".git": "gitdir: /project/.git/worktrees/feature-a",
 8007            "src": {},
 8008        }),
 8009    )
 8010    .await;
 8011
 8012    fs.add_linked_worktree_for_repo(
 8013        Path::new("/project/.git"),
 8014        false,
 8015        git::repository::Worktree {
 8016            path: PathBuf::from("/wt-feature-a"),
 8017            ref_name: Some("refs/heads/feature-a".into()),
 8018            sha: "aaa".into(),
 8019            is_main: false,
 8020            is_bare: false,
 8021        },
 8022    )
 8023    .await;
 8024
 8025    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8026
 8027    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 8028    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8029
 8030    main_project
 8031        .update(cx, |p, cx| p.git_scans_complete(cx))
 8032        .await;
 8033    worktree_project
 8034        .update(cx, |p, cx| p.git_scans_complete(cx))
 8035        .await;
 8036
 8037    let (multi_workspace, cx) =
 8038        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 8039    let sidebar = setup_sidebar(&multi_workspace, cx);
 8040
 8041    // Open the linked worktree as a separate workspace (simulates cmd-o).
 8042    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8043        mw.test_add_workspace(worktree_project.clone(), window, cx)
 8044    });
 8045    add_agent_panel(&worktree_workspace, cx);
 8046    cx.run_until_parked();
 8047
 8048    // Explicitly create a draft thread from the linked worktree workspace.
 8049    // Auto-created drafts use the group's first workspace (the main one),
 8050    // so a user-created draft is needed to make the linked worktree reachable.
 8051    sidebar.update_in(cx, |sidebar, window, cx| {
 8052        sidebar.create_new_thread(&worktree_workspace, window, cx);
 8053    });
 8054    cx.run_until_parked();
 8055
 8056    // Switch back to the main workspace.
 8057    multi_workspace.update_in(cx, |mw, window, cx| {
 8058        let main_ws = mw.workspaces().next().unwrap().clone();
 8059        mw.activate(main_ws, window, cx);
 8060    });
 8061    cx.run_until_parked();
 8062
 8063    sidebar.update_in(cx, |sidebar, _window, cx| {
 8064        sidebar.update_entries(cx);
 8065    });
 8066    cx.run_until_parked();
 8067
 8068    // The linked worktree workspace must be reachable from some sidebar entry.
 8069    let worktree_ws_id = worktree_workspace.entity_id();
 8070    let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
 8071        let mw = multi_workspace.read(cx);
 8072        sidebar
 8073            .contents
 8074            .entries
 8075            .iter()
 8076            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 8077            .map(|ws| ws.entity_id())
 8078            .collect()
 8079    });
 8080    assert!(
 8081        reachable.contains(&worktree_ws_id),
 8082        "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
 8083    );
 8084
 8085    // Find the draft Thread entry whose workspace is the linked worktree.
 8086    let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
 8087        sidebar
 8088            .contents
 8089            .entries
 8090            .iter()
 8091            .position(|entry| match entry {
 8092                ListEntry::Thread(thread) if thread.is_draft => matches!(
 8093                    &thread.workspace,
 8094                    ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
 8095                ),
 8096                _ => false,
 8097            })
 8098            .expect("expected a draft thread entry for the linked worktree")
 8099    });
 8100
 8101    assert_eq!(
 8102        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8103        2
 8104    );
 8105
 8106    sidebar.update_in(cx, |sidebar, window, cx| {
 8107        sidebar.selection = Some(new_thread_ix);
 8108        sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
 8109    });
 8110    cx.run_until_parked();
 8111
 8112    assert_eq!(
 8113        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8114        2,
 8115        "dismissing a draft no longer removes the linked worktree workspace"
 8116    );
 8117
 8118    let has_draft_for_worktree = sidebar.read_with(cx, |sidebar, _| {
 8119        sidebar.contents.entries.iter().any(|entry| match entry {
 8120            ListEntry::Thread(thread) if thread.is_draft => matches!(
 8121                &thread.workspace,
 8122                ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
 8123            ),
 8124            _ => false,
 8125        })
 8126    });
 8127    assert!(
 8128        !has_draft_for_worktree,
 8129        "draft thread entry for the linked worktree should be removed after dismiss"
 8130    );
 8131}
 8132
 8133#[gpui::test]
 8134async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
 8135    // When only a linked worktree workspace is open (not the main repo),
 8136    // threads saved against the main repo should still appear in the sidebar.
 8137    init_test(cx);
 8138    let fs = FakeFs::new(cx.executor());
 8139
 8140    // Create the main repo with a linked worktree.
 8141    fs.insert_tree(
 8142        "/project",
 8143        serde_json::json!({
 8144            ".git": {
 8145                "worktrees": {
 8146                    "feature-a": {
 8147                        "commondir": "../../",
 8148                        "HEAD": "ref: refs/heads/feature-a",
 8149                    },
 8150                },
 8151            },
 8152            "src": {},
 8153        }),
 8154    )
 8155    .await;
 8156
 8157    fs.insert_tree(
 8158        "/wt-feature-a",
 8159        serde_json::json!({
 8160            ".git": "gitdir: /project/.git/worktrees/feature-a",
 8161            "src": {},
 8162        }),
 8163    )
 8164    .await;
 8165
 8166    fs.add_linked_worktree_for_repo(
 8167        std::path::Path::new("/project/.git"),
 8168        false,
 8169        git::repository::Worktree {
 8170            path: std::path::PathBuf::from("/wt-feature-a"),
 8171            ref_name: Some("refs/heads/feature-a".into()),
 8172            sha: "abc".into(),
 8173            is_main: false,
 8174            is_bare: false,
 8175        },
 8176    )
 8177    .await;
 8178
 8179    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8180
 8181    // Only open the linked worktree as a workspace — NOT the main repo.
 8182    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8183    worktree_project
 8184        .update(cx, |p, cx| p.git_scans_complete(cx))
 8185        .await;
 8186
 8187    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 8188    main_project
 8189        .update(cx, |p, cx| p.git_scans_complete(cx))
 8190        .await;
 8191
 8192    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 8193        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 8194    });
 8195    let sidebar = setup_sidebar(&multi_workspace, cx);
 8196
 8197    // Save a thread against the MAIN repo path.
 8198    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
 8199
 8200    // Save a thread against the linked worktree path.
 8201    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
 8202
 8203    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 8204    cx.run_until_parked();
 8205
 8206    // Both threads should be visible: the worktree thread by direct lookup,
 8207    // and the main repo thread because the workspace is a linked worktree
 8208    // and we also query the main repo path.
 8209    let entries = visible_entries_as_strings(&sidebar, cx);
 8210    assert!(
 8211        entries.iter().any(|e| e.contains("Main Repo Thread")),
 8212        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
 8213    );
 8214    assert!(
 8215        entries.iter().any(|e| e.contains("Worktree Thread")),
 8216        "expected worktree thread to be visible, got: {entries:?}"
 8217    );
 8218}
 8219
 8220async fn init_multi_project_test(
 8221    paths: &[&str],
 8222    cx: &mut TestAppContext,
 8223) -> (Arc<FakeFs>, Entity<project::Project>) {
 8224    agent_ui::test_support::init_test(cx);
 8225    cx.update(|cx| {
 8226        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 8227        ThreadStore::init_global(cx);
 8228        ThreadMetadataStore::init_global(cx);
 8229        language_model::LanguageModelRegistry::test(cx);
 8230        prompt_store::init(cx);
 8231    });
 8232    let fs = FakeFs::new(cx.executor());
 8233    for path in paths {
 8234        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
 8235            .await;
 8236    }
 8237    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8238    let project =
 8239        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
 8240    (fs, project)
 8241}
 8242
 8243async fn add_test_project(
 8244    path: &str,
 8245    fs: &Arc<FakeFs>,
 8246    multi_workspace: &Entity<MultiWorkspace>,
 8247    cx: &mut gpui::VisualTestContext,
 8248) -> Entity<Workspace> {
 8249    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
 8250    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8251        mw.test_add_workspace(project, window, cx)
 8252    });
 8253    cx.run_until_parked();
 8254    workspace
 8255}
 8256
 8257#[gpui::test]
 8258async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
 8259    let (fs, project_a) =
 8260        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8261    let (multi_workspace, cx) =
 8262        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8263    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
 8264
 8265    // Sidebar starts closed. Initial workspace A is transient.
 8266    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8267    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 8268    assert_eq!(
 8269        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8270        1
 8271    );
 8272    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
 8273
 8274    // Add B — replaces A as the transient workspace.
 8275    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8276    assert_eq!(
 8277        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8278        1
 8279    );
 8280    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8281
 8282    // Add C — replaces B as the transient workspace.
 8283    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8284    assert_eq!(
 8285        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8286        1
 8287    );
 8288    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8289}
 8290
 8291#[gpui::test]
 8292async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
 8293    let (fs, project_a) = init_multi_project_test(
 8294        &["/project-a", "/project-b", "/project-c", "/project-d"],
 8295        cx,
 8296    )
 8297    .await;
 8298    let (multi_workspace, cx) =
 8299        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8300    let _sidebar = setup_sidebar(&multi_workspace, cx);
 8301    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 8302
 8303    // Add B — retained since sidebar is open.
 8304    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8305    assert_eq!(
 8306        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8307        2
 8308    );
 8309
 8310    // Switch to A — B survives. (Switching from one internal workspace, to another)
 8311    multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
 8312    cx.run_until_parked();
 8313    assert_eq!(
 8314        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8315        2
 8316    );
 8317
 8318    // Close sidebar — both A and B remain retained.
 8319    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
 8320    cx.run_until_parked();
 8321    assert_eq!(
 8322        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8323        2
 8324    );
 8325
 8326    // Add C — added as new transient workspace. (switching from retained, to transient)
 8327    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8328    assert_eq!(
 8329        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8330        3
 8331    );
 8332    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8333
 8334    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
 8335    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
 8336    assert_eq!(
 8337        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8338        3
 8339    );
 8340    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
 8341}
 8342
 8343#[gpui::test]
 8344async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
 8345    let (fs, project_a) =
 8346        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8347    let (multi_workspace, cx) =
 8348        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8349    setup_sidebar_closed(&multi_workspace, cx);
 8350
 8351    // Add B — replaces A as the transient workspace (A is discarded).
 8352    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8353    assert_eq!(
 8354        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8355        1
 8356    );
 8357    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8358
 8359    // Open sidebar — promotes the transient B to retained.
 8360    multi_workspace.update_in(cx, |mw, window, cx| {
 8361        mw.toggle_sidebar(window, cx);
 8362    });
 8363    cx.run_until_parked();
 8364    assert_eq!(
 8365        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8366        1
 8367    );
 8368    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
 8369
 8370    // Close sidebar — the retained B remains.
 8371    multi_workspace.update_in(cx, |mw, window, cx| {
 8372        mw.toggle_sidebar(window, cx);
 8373    });
 8374
 8375    // Add C — added as new transient workspace.
 8376    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8377    assert_eq!(
 8378        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8379        2
 8380    );
 8381    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8382}
 8383
 8384#[gpui::test]
 8385async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
 8386    init_test(cx);
 8387    let fs = FakeFs::new(cx.executor());
 8388
 8389    fs.insert_tree(
 8390        "/project",
 8391        serde_json::json!({
 8392            ".git": {
 8393                "worktrees": {
 8394                    "feature-a": {
 8395                        "commondir": "../../",
 8396                        "HEAD": "ref: refs/heads/feature-a",
 8397                    },
 8398                },
 8399            },
 8400            "src": {},
 8401        }),
 8402    )
 8403    .await;
 8404
 8405    fs.insert_tree(
 8406        "/wt-feature-a",
 8407        serde_json::json!({
 8408            ".git": "gitdir: /project/.git/worktrees/feature-a",
 8409            "src": {},
 8410        }),
 8411    )
 8412    .await;
 8413
 8414    fs.add_linked_worktree_for_repo(
 8415        Path::new("/project/.git"),
 8416        false,
 8417        git::repository::Worktree {
 8418            path: PathBuf::from("/wt-feature-a"),
 8419            ref_name: Some("refs/heads/feature-a".into()),
 8420            sha: "abc".into(),
 8421            is_main: false,
 8422            is_bare: false,
 8423        },
 8424    )
 8425    .await;
 8426
 8427    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8428
 8429    // Only a linked worktree workspace is open — no workspace for /project.
 8430    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8431    worktree_project
 8432        .update(cx, |p, cx| p.git_scans_complete(cx))
 8433        .await;
 8434
 8435    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 8436        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 8437    });
 8438    let sidebar = setup_sidebar(&multi_workspace, cx);
 8439
 8440    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
 8441    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
 8442    cx.update(|_, cx| {
 8443        let metadata = ThreadMetadata {
 8444            thread_id: ThreadId::new(),
 8445            session_id: Some(legacy_session.clone()),
 8446            agent_id: agent::ZED_AGENT_ID.clone(),
 8447            title: Some("Legacy Main Thread".into()),
 8448            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8449            created_at: None,
 8450            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 8451                "/project",
 8452            )])),
 8453            archived: false,
 8454            remote_connection: None,
 8455        };
 8456        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
 8457    });
 8458    cx.run_until_parked();
 8459
 8460    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 8461    cx.run_until_parked();
 8462
 8463    // The legacy thread should appear in the sidebar under the project group.
 8464    let entries = visible_entries_as_strings(&sidebar, cx);
 8465    assert!(
 8466        entries.iter().any(|e| e.contains("Legacy Main Thread")),
 8467        "legacy thread should be visible: {entries:?}",
 8468    );
 8469
 8470    // Verify only 1 workspace before clicking.
 8471    assert_eq!(
 8472        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8473        1,
 8474    );
 8475
 8476    // Focus and select the legacy thread, then confirm.
 8477    focus_sidebar(&sidebar, cx);
 8478    let thread_index = sidebar.read_with(cx, |sidebar, _| {
 8479        sidebar
 8480            .contents
 8481            .entries
 8482            .iter()
 8483            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
 8484            .expect("legacy thread should be in entries")
 8485    });
 8486    sidebar.update_in(cx, |sidebar, _window, _cx| {
 8487        sidebar.selection = Some(thread_index);
 8488    });
 8489    cx.dispatch_action(Confirm);
 8490    cx.run_until_parked();
 8491
 8492    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8493    let new_path_list =
 8494        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 8495    assert_eq!(
 8496        new_path_list,
 8497        PathList::new(&[PathBuf::from("/project")]),
 8498        "the new workspace should be for the main repo, not the linked worktree",
 8499    );
 8500}
 8501
 8502#[gpui::test]
 8503async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
 8504    cx: &mut TestAppContext,
 8505) {
 8506    // Regression test for a property-test finding:
 8507    //   AddLinkedWorktree { project_group_index: 0 }
 8508    //   AddProject { use_worktree: true }
 8509    //   AddProject { use_worktree: false }
 8510    // After these three steps, the linked-worktree workspace was not
 8511    // reachable from any sidebar entry.
 8512    agent_ui::test_support::init_test(cx);
 8513    cx.update(|cx| {
 8514        ThreadStore::init_global(cx);
 8515        ThreadMetadataStore::init_global(cx);
 8516        language_model::LanguageModelRegistry::test(cx);
 8517        prompt_store::init(cx);
 8518
 8519        cx.observe_new(
 8520            |workspace: &mut Workspace,
 8521             window: Option<&mut Window>,
 8522             cx: &mut gpui::Context<Workspace>| {
 8523                if let Some(window) = window {
 8524                    let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 8525                    workspace.add_panel(panel, window, cx);
 8526                }
 8527            },
 8528        )
 8529        .detach();
 8530    });
 8531
 8532    let fs = FakeFs::new(cx.executor());
 8533    fs.insert_tree(
 8534        "/my-project",
 8535        serde_json::json!({
 8536            ".git": {},
 8537            "src": {},
 8538        }),
 8539    )
 8540    .await;
 8541    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8542    let project =
 8543        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
 8544    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8545
 8546    let (multi_workspace, cx) =
 8547        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8548    let sidebar = setup_sidebar(&multi_workspace, cx);
 8549
 8550    // Step 1: Create a linked worktree for the main project.
 8551    let worktree_name = "wt-0";
 8552    let worktree_path = "/worktrees/wt-0";
 8553
 8554    fs.insert_tree(
 8555        worktree_path,
 8556        serde_json::json!({
 8557            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8558            "src": {},
 8559        }),
 8560    )
 8561    .await;
 8562    fs.insert_tree(
 8563        "/my-project/.git/worktrees/wt-0",
 8564        serde_json::json!({
 8565            "commondir": "../../",
 8566            "HEAD": "ref: refs/heads/wt-0",
 8567        }),
 8568    )
 8569    .await;
 8570    fs.add_linked_worktree_for_repo(
 8571        Path::new("/my-project/.git"),
 8572        false,
 8573        git::repository::Worktree {
 8574            path: PathBuf::from(worktree_path),
 8575            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 8576            sha: "aaa".into(),
 8577            is_main: false,
 8578            is_bare: false,
 8579        },
 8580    )
 8581    .await;
 8582
 8583    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8584    let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 8585    main_project
 8586        .update(cx, |p, cx| p.git_scans_complete(cx))
 8587        .await;
 8588    cx.run_until_parked();
 8589
 8590    // Step 2: Open the linked worktree as its own workspace.
 8591    let worktree_project =
 8592        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
 8593    worktree_project
 8594        .update(cx, |p, cx| p.git_scans_complete(cx))
 8595        .await;
 8596    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8597        mw.test_add_workspace(worktree_project.clone(), window, cx)
 8598    });
 8599    cx.run_until_parked();
 8600
 8601    // Step 3: Add an unrelated project.
 8602    fs.insert_tree(
 8603        "/other-project",
 8604        serde_json::json!({
 8605            ".git": {},
 8606            "src": {},
 8607        }),
 8608    )
 8609    .await;
 8610    let other_project = project::Project::test(
 8611        fs.clone() as Arc<dyn fs::Fs>,
 8612        ["/other-project".as_ref()],
 8613        cx,
 8614    )
 8615    .await;
 8616    other_project
 8617        .update(cx, |p, cx| p.git_scans_complete(cx))
 8618        .await;
 8619    multi_workspace.update_in(cx, |mw, window, cx| {
 8620        mw.test_add_workspace(other_project.clone(), window, cx);
 8621    });
 8622    cx.run_until_parked();
 8623
 8624    // Force a full sidebar rebuild with all groups expanded.
 8625    sidebar.update_in(cx, |sidebar, _window, cx| {
 8626        if let Some(mw) = sidebar.multi_workspace.upgrade() {
 8627            mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 8628        }
 8629        sidebar.update_entries(cx);
 8630    });
 8631    cx.run_until_parked();
 8632
 8633    // The linked-worktree workspace must be reachable from at least one
 8634    // sidebar entry — otherwise the user has no way to navigate to it.
 8635    let worktree_ws_id = worktree_workspace.entity_id();
 8636    let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
 8637        let mw = multi_workspace.read(cx);
 8638
 8639        let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
 8640        let reachable: HashSet<gpui::EntityId> = sidebar
 8641            .contents
 8642            .entries
 8643            .iter()
 8644            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 8645            .map(|ws| ws.entity_id())
 8646            .collect();
 8647        (all, reachable)
 8648    });
 8649
 8650    let unreachable = &all_ids - &reachable_ids;
 8651    eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
 8652
 8653    assert!(
 8654        unreachable.is_empty(),
 8655        "workspaces not reachable from any sidebar entry: {:?}\n\
 8656         (linked-worktree workspace id: {:?})",
 8657        unreachable,
 8658        worktree_ws_id,
 8659    );
 8660}
 8661
 8662#[gpui::test]
 8663async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
 8664    // Empty project groups no longer auto-create drafts via reconciliation.
 8665    // A fresh startup with no restorable thread should show only the header.
 8666    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8667    let (multi_workspace, cx) =
 8668        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8669    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8670
 8671    let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8672
 8673    let entries = visible_entries_as_strings(&sidebar, cx);
 8674    assert_eq!(
 8675        entries,
 8676        vec!["v [my-project]"],
 8677        "empty group should show only the header, no auto-created draft"
 8678    );
 8679}
 8680
 8681#[gpui::test]
 8682async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
 8683    // Rule 5: When the app starts and the AgentPanel successfully loads
 8684    // a thread, no spurious draft should appear.
 8685    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8686    let (multi_workspace, cx) =
 8687        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8688    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8689
 8690    // Create and send a message to make a real thread.
 8691    let connection = StubAgentConnection::new();
 8692    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8693        acp::ContentChunk::new("Done".into()),
 8694    )]);
 8695    open_thread_with_connection(&panel, connection, cx);
 8696    send_message(&panel, cx);
 8697    let session_id = active_session_id(&panel, cx);
 8698    save_test_thread_metadata(&session_id, &project, cx).await;
 8699    cx.run_until_parked();
 8700
 8701    // Should show the thread, NOT a spurious draft.
 8702    let entries = visible_entries_as_strings(&sidebar, cx);
 8703    assert_eq!(entries, vec!["v [my-project]", "  Hello *"]);
 8704
 8705    // active_entry should be Thread, not Draft.
 8706    sidebar.read_with(cx, |sidebar, _| {
 8707        assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
 8708    });
 8709}
 8710
 8711#[gpui::test]
 8712async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
 8713    // Rule 9: Clicking a project header should restore whatever the
 8714    // user was last looking at in that group, not create new drafts
 8715    // or jump to the first entry.
 8716    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 8717    let (multi_workspace, cx) =
 8718        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8719    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8720
 8721    // Create two threads in project-a.
 8722    let conn1 = StubAgentConnection::new();
 8723    conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8724        acp::ContentChunk::new("Done".into()),
 8725    )]);
 8726    open_thread_with_connection(&panel_a, conn1, cx);
 8727    send_message(&panel_a, cx);
 8728    let thread_a1 = active_session_id(&panel_a, cx);
 8729    save_test_thread_metadata(&thread_a1, &project_a, cx).await;
 8730
 8731    let conn2 = StubAgentConnection::new();
 8732    conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8733        acp::ContentChunk::new("Done".into()),
 8734    )]);
 8735    open_thread_with_connection(&panel_a, conn2, cx);
 8736    send_message(&panel_a, cx);
 8737    let thread_a2 = active_session_id(&panel_a, cx);
 8738    save_test_thread_metadata(&thread_a2, &project_a, cx).await;
 8739    cx.run_until_parked();
 8740
 8741    // The user is now looking at thread_a2.
 8742    sidebar.read_with(cx, |sidebar, _| {
 8743        assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
 8744    });
 8745
 8746    // Add project-b and switch to it.
 8747    let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
 8748    fs.as_fake()
 8749        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 8750        .await;
 8751    let project_b =
 8752        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8753    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8754        mw.test_add_workspace(project_b.clone(), window, cx)
 8755    });
 8756    let _panel_b = add_agent_panel(&workspace_b, cx);
 8757    cx.run_until_parked();
 8758
 8759    // Now switch BACK to project-a by activating its workspace.
 8760    let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
 8761        mw.workspaces()
 8762            .find(|ws| {
 8763                ws.read(cx)
 8764                    .project()
 8765                    .read(cx)
 8766                    .visible_worktrees(cx)
 8767                    .any(|wt| {
 8768                        wt.read(cx)
 8769                            .abs_path()
 8770                            .to_string_lossy()
 8771                            .contains("project-a")
 8772                    })
 8773            })
 8774            .unwrap()
 8775            .clone()
 8776    });
 8777    multi_workspace.update_in(cx, |mw, window, cx| {
 8778        mw.activate(workspace_a.clone(), window, cx);
 8779    });
 8780    cx.run_until_parked();
 8781
 8782    // The panel should still show thread_a2 (the last thing the user
 8783    // was viewing in project-a), not a draft or thread_a1.
 8784    sidebar.read_with(cx, |sidebar, _| {
 8785        assert_active_thread(
 8786            sidebar,
 8787            &thread_a2,
 8788            "switching back to project-a should restore thread_a2",
 8789        );
 8790    });
 8791
 8792    // No spurious draft entries should have been created in
 8793    // project-a's group (project-b may have a placeholder).
 8794    let entries = visible_entries_as_strings(&sidebar, cx);
 8795    // Find project-a's section and check it has no drafts.
 8796    let project_a_start = entries
 8797        .iter()
 8798        .position(|e| e.contains("project-a"))
 8799        .unwrap();
 8800    let project_a_end = entries[project_a_start + 1..]
 8801        .iter()
 8802        .position(|e| e.starts_with("v "))
 8803        .map(|i| i + project_a_start + 1)
 8804        .unwrap_or(entries.len());
 8805    let project_a_drafts = entries[project_a_start..project_a_end]
 8806        .iter()
 8807        .filter(|e| e.contains("Draft"))
 8808        .count();
 8809    assert_eq!(
 8810        project_a_drafts, 0,
 8811        "switching back to project-a should not create drafts in its group"
 8812    );
 8813}
 8814
 8815#[gpui::test]
 8816async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
 8817    // Clicking the + button when an empty draft already exists should
 8818    // focus the existing draft rather than creating a new one.
 8819    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8820    let (multi_workspace, cx) =
 8821        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8822    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8823
 8824    // Start: no drafts from reconciliation.
 8825    let entries = visible_entries_as_strings(&sidebar, cx);
 8826    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8827    assert_eq!(draft_count, 0, "should start with 0 drafts");
 8828
 8829    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8830    let simulate_plus_button =
 8831        |sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context<Sidebar>| {
 8832            sidebar.create_new_thread(&workspace, window, cx);
 8833        };
 8834
 8835    // First + click: should create a draft.
 8836    sidebar.update_in(cx, |sidebar, window, cx| {
 8837        simulate_plus_button(sidebar, window, cx);
 8838    });
 8839    cx.run_until_parked();
 8840
 8841    let entries = visible_entries_as_strings(&sidebar, cx);
 8842    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8843    assert_eq!(draft_count, 1, "first + click should create a draft");
 8844
 8845    // Second + click with empty draft: should reuse it, not create a new one.
 8846    sidebar.update_in(cx, |sidebar, window, cx| {
 8847        simulate_plus_button(sidebar, window, cx);
 8848    });
 8849    cx.run_until_parked();
 8850
 8851    let entries = visible_entries_as_strings(&sidebar, cx);
 8852    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 8853    assert_eq!(
 8854        draft_count, 1,
 8855        "second + click should reuse the existing empty draft, not create a new one"
 8856    );
 8857
 8858    // The draft should be active.
 8859    assert_eq!(entries[1], "  [~ Draft] *");
 8860}
 8861
 8862#[gpui::test]
 8863async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
 8864    // When a workspace has a draft (from the panel's load fallback)
 8865    // and the user activates it (e.g. by clicking the placeholder or
 8866    // the project header), no extra drafts should be created.
 8867    init_test(cx);
 8868    let fs = FakeFs::new(cx.executor());
 8869    fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
 8870        .await;
 8871    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8872        .await;
 8873    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8874
 8875    let project_a =
 8876        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
 8877    let (multi_workspace, cx) =
 8878        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8879    let sidebar = setup_sidebar(&multi_workspace, cx);
 8880    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8881    let _panel_a = add_agent_panel(&workspace_a, cx);
 8882    cx.run_until_parked();
 8883
 8884    // Add project-b with its own workspace and agent panel.
 8885    let project_b =
 8886        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8887    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8888        mw.test_add_workspace(project_b.clone(), window, cx)
 8889    });
 8890    let _panel_b = add_agent_panel(&workspace_b, cx);
 8891    cx.run_until_parked();
 8892
 8893    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 8894    sidebar.update_in(cx, |sidebar, window, cx| {
 8895        sidebar.create_new_thread(&workspace_b, window, cx);
 8896    });
 8897    cx.run_until_parked();
 8898
 8899    // Count project-b's drafts.
 8900    let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
 8901        let entries = visible_entries_as_strings(&sidebar, cx);
 8902        entries
 8903            .iter()
 8904            .skip_while(|e| !e.contains("project-b"))
 8905            .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
 8906            .filter(|e| e.contains("Draft"))
 8907            .count()
 8908    };
 8909    let drafts_before = count_b_drafts(cx);
 8910
 8911    // Switch away from project-b, then back.
 8912    multi_workspace.update_in(cx, |mw, window, cx| {
 8913        mw.activate(workspace_a.clone(), window, cx);
 8914    });
 8915    cx.run_until_parked();
 8916    multi_workspace.update_in(cx, |mw, window, cx| {
 8917        mw.activate(workspace_b.clone(), window, cx);
 8918    });
 8919    cx.run_until_parked();
 8920
 8921    let drafts_after = count_b_drafts(cx);
 8922    assert_eq!(
 8923        drafts_before, drafts_after,
 8924        "activating workspace should not create extra drafts"
 8925    );
 8926
 8927    // The draft should be highlighted as active after switching back.
 8928    sidebar.read_with(cx, |sidebar, _| {
 8929        assert_active_draft(
 8930            sidebar,
 8931            &workspace_b,
 8932            "draft should be active after switching back to its workspace",
 8933        );
 8934    });
 8935}
 8936
 8937#[gpui::test]
 8938async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
 8939    // Historical threads (not open in any agent panel) should have their
 8940    // worktree paths updated when a folder is added to or removed from the
 8941    // project.
 8942    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
 8943    let (multi_workspace, cx) =
 8944        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8945    let sidebar = setup_sidebar(&multi_workspace, cx);
 8946
 8947    // Save two threads directly into the metadata store (not via the agent
 8948    // panel), so they are purely historical — no open views hold them.
 8949    // Use different timestamps so sort order is deterministic.
 8950    save_thread_metadata(
 8951        acp::SessionId::new(Arc::from("hist-1")),
 8952        Some("Historical 1".into()),
 8953        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8954        None,
 8955        &project,
 8956        cx,
 8957    );
 8958    save_thread_metadata(
 8959        acp::SessionId::new(Arc::from("hist-2")),
 8960        Some("Historical 2".into()),
 8961        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 8962        None,
 8963        &project,
 8964        cx,
 8965    );
 8966    cx.run_until_parked();
 8967    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8968    cx.run_until_parked();
 8969
 8970    // Sanity-check: both threads exist under the initial key [/project-a].
 8971    let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
 8972    cx.update(|_window, cx| {
 8973        let store = ThreadMetadataStore::global(cx).read(cx);
 8974        assert_eq!(
 8975            store
 8976                .entries_for_main_worktree_path(&old_key_paths, None)
 8977                .count(),
 8978            2,
 8979            "should have 2 historical threads under old key before worktree add"
 8980        );
 8981    });
 8982
 8983    // Add a second worktree to the project.
 8984    project
 8985        .update(cx, |project, cx| {
 8986            project.find_or_create_worktree("/project-b", true, cx)
 8987        })
 8988        .await
 8989        .expect("should add worktree");
 8990    cx.run_until_parked();
 8991
 8992    // The historical threads should now be indexed under the new combined
 8993    // key [/project-a, /project-b].
 8994    let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
 8995    cx.update(|_window, cx| {
 8996        let store = ThreadMetadataStore::global(cx).read(cx);
 8997        assert_eq!(
 8998            store
 8999                .entries_for_main_worktree_path(&old_key_paths, None)
 9000                .count(),
 9001            0,
 9002            "should have 0 historical threads under old key after worktree add"
 9003        );
 9004        assert_eq!(
 9005            store
 9006                .entries_for_main_worktree_path(&new_key_paths, None)
 9007                .count(),
 9008            2,
 9009            "should have 2 historical threads under new key after worktree add"
 9010        );
 9011    });
 9012
 9013    // Sidebar should show threads under the new header.
 9014    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9015    cx.run_until_parked();
 9016    assert_eq!(
 9017        visible_entries_as_strings(&sidebar, cx),
 9018        vec![
 9019            "v [project-a, project-b]",
 9020            "  Historical 2",
 9021            "  Historical 1",
 9022        ]
 9023    );
 9024
 9025    // Now remove the second worktree.
 9026    let worktree_id = project.read_with(cx, |project, cx| {
 9027        project
 9028            .visible_worktrees(cx)
 9029            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
 9030            .map(|wt| wt.read(cx).id())
 9031            .expect("should find project-b worktree")
 9032    });
 9033    project.update(cx, |project, cx| {
 9034        project.remove_worktree(worktree_id, cx);
 9035    });
 9036    cx.run_until_parked();
 9037
 9038    // Historical threads should migrate back to the original key.
 9039    cx.update(|_window, cx| {
 9040        let store = ThreadMetadataStore::global(cx).read(cx);
 9041        assert_eq!(
 9042            store
 9043                .entries_for_main_worktree_path(&new_key_paths, None)
 9044                .count(),
 9045            0,
 9046            "should have 0 historical threads under new key after worktree remove"
 9047        );
 9048        assert_eq!(
 9049            store
 9050                .entries_for_main_worktree_path(&old_key_paths, None)
 9051                .count(),
 9052            2,
 9053            "should have 2 historical threads under old key after worktree remove"
 9054        );
 9055    });
 9056
 9057    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9058    cx.run_until_parked();
 9059    assert_eq!(
 9060        visible_entries_as_strings(&sidebar, cx),
 9061        vec!["v [project-a]", "  Historical 2", "  Historical 1",]
 9062    );
 9063}
 9064
 9065#[gpui::test]
 9066async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
 9067    // When two workspaces share the same project group (same main path)
 9068    // but have different folder paths (main repo vs linked worktree),
 9069    // adding a worktree to the main workspace should regroup only that
 9070    // workspace and its threads into the new project group. Threads for the
 9071    // linked worktree workspace should remain under the original group.
 9072    agent_ui::test_support::init_test(cx);
 9073    cx.update(|cx| {
 9074        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 9075        ThreadStore::init_global(cx);
 9076        ThreadMetadataStore::init_global(cx);
 9077        language_model::LanguageModelRegistry::test(cx);
 9078        prompt_store::init(cx);
 9079    });
 9080
 9081    let fs = FakeFs::new(cx.executor());
 9082    fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
 9083        .await;
 9084    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 9085        .await;
 9086    fs.add_linked_worktree_for_repo(
 9087        Path::new("/project/.git"),
 9088        false,
 9089        git::repository::Worktree {
 9090            path: std::path::PathBuf::from("/wt-feature"),
 9091            ref_name: Some("refs/heads/feature".into()),
 9092            sha: "aaa".into(),
 9093            is_main: false,
 9094            is_bare: false,
 9095        },
 9096    )
 9097    .await;
 9098    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 9099
 9100    // Workspace A: main repo at /project.
 9101    let main_project =
 9102        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
 9103    // Workspace B: linked worktree of the same repo (same group, different folder).
 9104    let worktree_project =
 9105        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
 9106
 9107    main_project
 9108        .update(cx, |p, cx| p.git_scans_complete(cx))
 9109        .await;
 9110    worktree_project
 9111        .update(cx, |p, cx| p.git_scans_complete(cx))
 9112        .await;
 9113
 9114    let (multi_workspace, cx) =
 9115        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 9116    let sidebar = setup_sidebar(&multi_workspace, cx);
 9117    multi_workspace.update_in(cx, |mw, window, cx| {
 9118        mw.test_add_workspace(worktree_project.clone(), window, cx);
 9119    });
 9120    cx.run_until_parked();
 9121
 9122    // Save a thread for each workspace's folder paths.
 9123    let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
 9124    let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
 9125    save_thread_metadata(
 9126        acp::SessionId::new(Arc::from("thread-main")),
 9127        Some("Main Thread".into()),
 9128        time_main,
 9129        Some(time_main),
 9130        &main_project,
 9131        cx,
 9132    );
 9133    save_thread_metadata(
 9134        acp::SessionId::new(Arc::from("thread-wt")),
 9135        Some("Worktree Thread".into()),
 9136        time_wt,
 9137        Some(time_wt),
 9138        &worktree_project,
 9139        cx,
 9140    );
 9141    cx.run_until_parked();
 9142
 9143    let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
 9144    let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
 9145
 9146    // Sanity-check: each thread is indexed under its own folder paths, but
 9147    // both appear under the shared sidebar group keyed by the main worktree.
 9148    cx.update(|_window, cx| {
 9149        let store = ThreadMetadataStore::global(cx).read(cx);
 9150        assert_eq!(
 9151            store.entries_for_path(&folder_paths_main, None).count(),
 9152            1,
 9153            "one thread under [/project]"
 9154        );
 9155        assert_eq!(
 9156            store.entries_for_path(&folder_paths_wt, None).count(),
 9157            1,
 9158            "one thread under [/wt-feature]"
 9159        );
 9160    });
 9161    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9162    cx.run_until_parked();
 9163    assert_eq!(
 9164        visible_entries_as_strings(&sidebar, cx),
 9165        vec![
 9166            "v [project]",
 9167            "  Worktree Thread {wt-feature}",
 9168            "  Main Thread",
 9169        ]
 9170    );
 9171
 9172    // Add /project-b to the main project only.
 9173    main_project
 9174        .update(cx, |project, cx| {
 9175            project.find_or_create_worktree("/project-b", true, cx)
 9176        })
 9177        .await
 9178        .expect("should add worktree");
 9179    cx.run_until_parked();
 9180
 9181    // Main Thread (folder paths [/project]) should be regrouped to
 9182    // [/project, /project-b]. Worktree Thread should remain under the
 9183    // original [/project] group.
 9184    let folder_paths_main_b =
 9185        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
 9186    cx.update(|_window, cx| {
 9187        let store = ThreadMetadataStore::global(cx).read(cx);
 9188        assert_eq!(
 9189            store.entries_for_path(&folder_paths_main, None).count(),
 9190            0,
 9191            "main thread should no longer be under old folder paths [/project]"
 9192        );
 9193        assert_eq!(
 9194            store.entries_for_path(&folder_paths_main_b, None).count(),
 9195            1,
 9196            "main thread should now be under [/project, /project-b]"
 9197        );
 9198        assert_eq!(
 9199            store.entries_for_path(&folder_paths_wt, None).count(),
 9200            1,
 9201            "worktree thread should remain unchanged under [/wt-feature]"
 9202        );
 9203    });
 9204
 9205    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9206    cx.run_until_parked();
 9207    assert_eq!(
 9208        visible_entries_as_strings(&sidebar, cx),
 9209        vec![
 9210            "v [project]",
 9211            "  Worktree Thread {wt-feature}",
 9212            "v [project, project-b]",
 9213            "  Main Thread",
 9214        ]
 9215    );
 9216}
 9217
 9218#[gpui::test]
 9219async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
 9220    cx: &mut TestAppContext,
 9221) {
 9222    // When a linked worktree is opened as its own workspace and then a new
 9223    // folder is added to the main project group, the linked worktree
 9224    // workspace must still be reachable from some sidebar entry.
 9225    let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
 9226    let fs = _fs.clone();
 9227
 9228    // Set up git worktree infrastructure.
 9229    fs.insert_tree(
 9230        "/my-project/.git/worktrees/wt-0",
 9231        serde_json::json!({
 9232            "commondir": "../../",
 9233            "HEAD": "ref: refs/heads/wt-0",
 9234        }),
 9235    )
 9236    .await;
 9237    fs.insert_tree(
 9238        "/worktrees/wt-0",
 9239        serde_json::json!({
 9240            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 9241            "src": {},
 9242        }),
 9243    )
 9244    .await;
 9245    fs.add_linked_worktree_for_repo(
 9246        Path::new("/my-project/.git"),
 9247        false,
 9248        git::repository::Worktree {
 9249            path: PathBuf::from("/worktrees/wt-0"),
 9250            ref_name: Some("refs/heads/wt-0".into()),
 9251            sha: "aaa".into(),
 9252            is_main: false,
 9253            is_bare: false,
 9254        },
 9255    )
 9256    .await;
 9257
 9258    // Re-scan so the main project discovers the linked worktree.
 9259    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9260
 9261    let (multi_workspace, cx) =
 9262        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9263    let sidebar = setup_sidebar(&multi_workspace, cx);
 9264
 9265    // Open the linked worktree as its own workspace.
 9266    let worktree_project = project::Project::test(
 9267        fs.clone() as Arc<dyn fs::Fs>,
 9268        ["/worktrees/wt-0".as_ref()],
 9269        cx,
 9270    )
 9271    .await;
 9272    worktree_project
 9273        .update(cx, |p, cx| p.git_scans_complete(cx))
 9274        .await;
 9275    multi_workspace.update_in(cx, |mw, window, cx| {
 9276        mw.test_add_workspace(worktree_project.clone(), window, cx);
 9277    });
 9278    cx.run_until_parked();
 9279
 9280    // Both workspaces should be reachable.
 9281    let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 9282    assert_eq!(workspace_count, 2, "should have 2 workspaces");
 9283
 9284    // Add a new folder to the main project, changing the project group key.
 9285    fs.insert_tree(
 9286        "/other-project",
 9287        serde_json::json!({ ".git": {}, "src": {} }),
 9288    )
 9289    .await;
 9290    project
 9291        .update(cx, |project, cx| {
 9292            project.find_or_create_worktree("/other-project", true, cx)
 9293        })
 9294        .await
 9295        .expect("should add worktree");
 9296    cx.run_until_parked();
 9297
 9298    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9299    cx.run_until_parked();
 9300
 9301    // The linked worktree workspace must still be reachable.
 9302    let entries = visible_entries_as_strings(&sidebar, cx);
 9303    let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
 9304        mw.workspaces().map(|ws| ws.entity_id()).collect()
 9305    });
 9306    sidebar.read_with(cx, |sidebar, cx| {
 9307        let multi_workspace = multi_workspace.read(cx);
 9308        let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
 9309            .contents
 9310            .entries
 9311            .iter()
 9312            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 9313            .map(|ws| ws.entity_id())
 9314            .collect();
 9315        let all: std::collections::HashSet<gpui::EntityId> =
 9316            mw_workspaces.iter().copied().collect();
 9317        let unreachable = &all - &reachable;
 9318        assert!(
 9319            unreachable.is_empty(),
 9320            "all workspaces should be reachable after adding folder; \
 9321             unreachable: {:?}, entries: {:?}",
 9322            unreachable,
 9323            entries,
 9324        );
 9325    });
 9326}
 9327
 9328mod property_test {
 9329    use super::*;
 9330    use gpui::proptest::prelude::*;
 9331
 9332    struct UnopenedWorktree {
 9333        path: String,
 9334        main_workspace_path: String,
 9335    }
 9336
 9337    struct TestState {
 9338        fs: Arc<FakeFs>,
 9339        thread_counter: u32,
 9340        workspace_counter: u32,
 9341        worktree_counter: u32,
 9342        saved_thread_ids: Vec<acp::SessionId>,
 9343        unopened_worktrees: Vec<UnopenedWorktree>,
 9344    }
 9345
 9346    impl TestState {
 9347        fn new(fs: Arc<FakeFs>) -> Self {
 9348            Self {
 9349                fs,
 9350                thread_counter: 0,
 9351                workspace_counter: 1,
 9352                worktree_counter: 0,
 9353                saved_thread_ids: Vec::new(),
 9354                unopened_worktrees: Vec::new(),
 9355            }
 9356        }
 9357
 9358        fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
 9359            let id = self.thread_counter;
 9360            self.thread_counter += 1;
 9361            acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
 9362        }
 9363
 9364        fn next_workspace_path(&mut self) -> String {
 9365            let id = self.workspace_counter;
 9366            self.workspace_counter += 1;
 9367            format!("/prop-project-{id}")
 9368        }
 9369
 9370        fn next_worktree_name(&mut self) -> String {
 9371            let id = self.worktree_counter;
 9372            self.worktree_counter += 1;
 9373            format!("wt-{id}")
 9374        }
 9375    }
 9376
 9377    #[derive(Debug)]
 9378    enum Operation {
 9379        SaveThread { project_group_index: usize },
 9380        SaveWorktreeThread { worktree_index: usize },
 9381        ToggleAgentPanel,
 9382        CreateDraftThread,
 9383        AddProject { use_worktree: bool },
 9384        ArchiveThread { index: usize },
 9385        SwitchToThread { index: usize },
 9386        SwitchToProjectGroup { index: usize },
 9387        AddLinkedWorktree { project_group_index: usize },
 9388        AddWorktreeToProject { project_group_index: usize },
 9389        RemoveWorktreeFromProject { project_group_index: usize },
 9390    }
 9391
 9392    // Distribution (out of 24 slots):
 9393    //   SaveThread:                5 slots (~21%)
 9394    //   SaveWorktreeThread:        2 slots (~8%)
 9395    //   ToggleAgentPanel:          1 slot  (~4%)
 9396    //   CreateDraftThread:         1 slot  (~4%)
 9397    //   AddProject:                1 slot  (~4%)
 9398    //   ArchiveThread:             2 slots (~8%)
 9399    //   SwitchToThread:            2 slots (~8%)
 9400    //   SwitchToProjectGroup:      2 slots (~8%)
 9401    //   AddLinkedWorktree:         4 slots (~17%)
 9402    //   AddWorktreeToProject:      2 slots (~8%)
 9403    //   RemoveWorktreeFromProject: 2 slots (~8%)
 9404    const DISTRIBUTION_SLOTS: u32 = 24;
 9405
 9406    impl TestState {
 9407        fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
 9408            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
 9409
 9410            match raw % DISTRIBUTION_SLOTS {
 9411                0..=4 => Operation::SaveThread {
 9412                    project_group_index: extra % project_group_count,
 9413                },
 9414                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
 9415                    worktree_index: extra % self.unopened_worktrees.len(),
 9416                },
 9417                5..=6 => Operation::SaveThread {
 9418                    project_group_index: extra % project_group_count,
 9419                },
 9420                7 => Operation::ToggleAgentPanel,
 9421                8 => Operation::CreateDraftThread,
 9422                9 => Operation::AddProject {
 9423                    use_worktree: !self.unopened_worktrees.is_empty(),
 9424                },
 9425                10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
 9426                    index: extra % self.saved_thread_ids.len(),
 9427                },
 9428                10..=11 => Operation::AddProject {
 9429                    use_worktree: !self.unopened_worktrees.is_empty(),
 9430                },
 9431                12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
 9432                    index: extra % self.saved_thread_ids.len(),
 9433                },
 9434                12..=13 => Operation::SwitchToProjectGroup {
 9435                    index: extra % project_group_count,
 9436                },
 9437                14..=15 => Operation::SwitchToProjectGroup {
 9438                    index: extra % project_group_count,
 9439                },
 9440                16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
 9441                    project_group_index: extra % project_group_count,
 9442                },
 9443                16..=19 => Operation::SaveThread {
 9444                    project_group_index: extra % project_group_count,
 9445                },
 9446                20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
 9447                    project_group_index: extra % project_group_count,
 9448                },
 9449                20..=21 => Operation::SaveThread {
 9450                    project_group_index: extra % project_group_count,
 9451                },
 9452                22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
 9453                    project_group_index: extra % project_group_count,
 9454                },
 9455                22..=23 => Operation::SaveThread {
 9456                    project_group_index: extra % project_group_count,
 9457                },
 9458                _ => unreachable!(),
 9459            }
 9460        }
 9461    }
 9462
 9463    fn save_thread_to_path_with_main(
 9464        state: &mut TestState,
 9465        path_list: PathList,
 9466        main_worktree_paths: PathList,
 9467        cx: &mut gpui::VisualTestContext,
 9468    ) {
 9469        let session_id = state.next_metadata_only_thread_id();
 9470        let title: SharedString = format!("Thread {}", session_id).into();
 9471        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9472            .unwrap()
 9473            + chrono::Duration::seconds(state.thread_counter as i64);
 9474        let metadata = ThreadMetadata {
 9475            thread_id: ThreadId::new(),
 9476            session_id: Some(session_id),
 9477            agent_id: agent::ZED_AGENT_ID.clone(),
 9478            title: Some(title),
 9479            updated_at,
 9480            created_at: None,
 9481            worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
 9482            archived: false,
 9483            remote_connection: None,
 9484        };
 9485        cx.update(|_, cx| {
 9486            ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
 9487        });
 9488        cx.run_until_parked();
 9489    }
 9490
 9491    async fn perform_operation(
 9492        operation: Operation,
 9493        state: &mut TestState,
 9494        multi_workspace: &Entity<MultiWorkspace>,
 9495        sidebar: &Entity<Sidebar>,
 9496        cx: &mut gpui::VisualTestContext,
 9497    ) {
 9498        match operation {
 9499            Operation::SaveThread {
 9500                project_group_index,
 9501            } => {
 9502                // Find a workspace for this project group and create a real
 9503                // thread via its agent panel.
 9504                let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
 9505                    let keys = mw.project_group_keys();
 9506                    let key = &keys[project_group_index];
 9507                    let ws = mw
 9508                        .workspaces_for_project_group(key, cx)
 9509                        .and_then(|ws| ws.first().cloned())
 9510                        .unwrap_or_else(|| mw.workspace().clone());
 9511                    let project = ws.read(cx).project().clone();
 9512                    (ws, project)
 9513                });
 9514
 9515                let panel =
 9516                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9517                if let Some(panel) = panel {
 9518                    let connection = StubAgentConnection::new();
 9519                    connection.set_next_prompt_updates(vec![
 9520                        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
 9521                            "Done".into(),
 9522                        )),
 9523                    ]);
 9524                    open_thread_with_connection(&panel, connection, cx);
 9525                    send_message(&panel, cx);
 9526                    let session_id = active_session_id(&panel, cx);
 9527                    state.saved_thread_ids.push(session_id.clone());
 9528
 9529                    let title: SharedString = format!("Thread {}", state.thread_counter).into();
 9530                    state.thread_counter += 1;
 9531                    let updated_at =
 9532                        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9533                            .unwrap()
 9534                            + chrono::Duration::seconds(state.thread_counter as i64);
 9535                    save_thread_metadata(session_id, Some(title), updated_at, None, &project, cx);
 9536                }
 9537            }
 9538            Operation::SaveWorktreeThread { worktree_index } => {
 9539                let worktree = &state.unopened_worktrees[worktree_index];
 9540                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
 9541                let main_worktree_paths =
 9542                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
 9543                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
 9544            }
 9545
 9546            Operation::ToggleAgentPanel => {
 9547                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9548                let panel_open =
 9549                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
 9550                workspace.update_in(cx, |workspace, window, cx| {
 9551                    if panel_open {
 9552                        workspace.close_panel::<AgentPanel>(window, cx);
 9553                    } else {
 9554                        workspace.open_panel::<AgentPanel>(window, cx);
 9555                    }
 9556                });
 9557            }
 9558            Operation::CreateDraftThread => {
 9559                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9560                let panel =
 9561                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9562                if let Some(panel) = panel {
 9563                    panel.update_in(cx, |panel, window, cx| {
 9564                        panel.new_thread(&NewThread, window, cx);
 9565                    });
 9566                    cx.run_until_parked();
 9567                }
 9568                workspace.update_in(cx, |workspace, window, cx| {
 9569                    workspace.focus_panel::<AgentPanel>(window, cx);
 9570                });
 9571            }
 9572            Operation::AddProject { use_worktree } => {
 9573                let path = if use_worktree {
 9574                    // Open an existing linked worktree as a project (simulates Cmd+O
 9575                    // on a worktree directory).
 9576                    state.unopened_worktrees.remove(0).path
 9577                } else {
 9578                    // Create a brand new project.
 9579                    let path = state.next_workspace_path();
 9580                    state
 9581                        .fs
 9582                        .insert_tree(
 9583                            &path,
 9584                            serde_json::json!({
 9585                                ".git": {},
 9586                                "src": {},
 9587                            }),
 9588                        )
 9589                        .await;
 9590                    path
 9591                };
 9592                let project = project::Project::test(
 9593                    state.fs.clone() as Arc<dyn fs::Fs>,
 9594                    [path.as_ref()],
 9595                    cx,
 9596                )
 9597                .await;
 9598                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9599                multi_workspace.update_in(cx, |mw, window, cx| {
 9600                    mw.test_add_workspace(project.clone(), window, cx)
 9601                });
 9602            }
 9603
 9604            Operation::ArchiveThread { index } => {
 9605                let session_id = state.saved_thread_ids[index].clone();
 9606                sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 9607                    sidebar.archive_thread(&session_id, window, cx);
 9608                });
 9609                cx.run_until_parked();
 9610                state.saved_thread_ids.remove(index);
 9611            }
 9612            Operation::SwitchToThread { index } => {
 9613                let session_id = state.saved_thread_ids[index].clone();
 9614                // Find the thread's position in the sidebar entries and select it.
 9615                let thread_index = sidebar.read_with(cx, |sidebar, _| {
 9616                    sidebar.contents.entries.iter().position(|entry| {
 9617                        matches!(
 9618                            entry,
 9619                            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
 9620                        )
 9621                    })
 9622                });
 9623                if let Some(ix) = thread_index {
 9624                    sidebar.update_in(cx, |sidebar, window, cx| {
 9625                        sidebar.selection = Some(ix);
 9626                        sidebar.confirm(&Confirm, window, cx);
 9627                    });
 9628                    cx.run_until_parked();
 9629                }
 9630            }
 9631            Operation::SwitchToProjectGroup { index } => {
 9632                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9633                    let keys = mw.project_group_keys();
 9634                    let key = &keys[index];
 9635                    mw.workspaces_for_project_group(key, cx)
 9636                        .and_then(|ws| ws.first().cloned())
 9637                        .unwrap_or_else(|| mw.workspace().clone())
 9638                });
 9639                multi_workspace.update_in(cx, |mw, window, cx| {
 9640                    mw.activate(workspace, window, cx);
 9641                });
 9642            }
 9643            Operation::AddLinkedWorktree {
 9644                project_group_index,
 9645            } => {
 9646                // Get the main worktree path from the project group key.
 9647                let main_path = multi_workspace.read_with(cx, |mw, _| {
 9648                    let keys = mw.project_group_keys();
 9649                    let key = &keys[project_group_index];
 9650                    key.path_list()
 9651                        .paths()
 9652                        .first()
 9653                        .unwrap()
 9654                        .to_string_lossy()
 9655                        .to_string()
 9656                });
 9657                let dot_git = format!("{}/.git", main_path);
 9658                let worktree_name = state.next_worktree_name();
 9659                let worktree_path = format!("/worktrees/{}", worktree_name);
 9660
 9661                state.fs
 9662                    .insert_tree(
 9663                        &worktree_path,
 9664                        serde_json::json!({
 9665                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
 9666                            "src": {},
 9667                        }),
 9668                    )
 9669                    .await;
 9670
 9671                // Also create the worktree metadata dir inside the main repo's .git
 9672                state
 9673                    .fs
 9674                    .insert_tree(
 9675                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
 9676                        serde_json::json!({
 9677                            "commondir": "../../",
 9678                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
 9679                        }),
 9680                    )
 9681                    .await;
 9682
 9683                let dot_git_path = std::path::Path::new(&dot_git);
 9684                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
 9685                state
 9686                    .fs
 9687                    .add_linked_worktree_for_repo(
 9688                        dot_git_path,
 9689                        false,
 9690                        git::repository::Worktree {
 9691                            path: worktree_pathbuf,
 9692                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 9693                            sha: "aaa".into(),
 9694                            is_main: false,
 9695                            is_bare: false,
 9696                        },
 9697                    )
 9698                    .await;
 9699
 9700                // Re-scan the main workspace's project so it discovers the new worktree.
 9701                let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
 9702                    let keys = mw.project_group_keys();
 9703                    let key = &keys[project_group_index];
 9704                    mw.workspaces_for_project_group(key, cx)
 9705                        .and_then(|ws| ws.first().cloned())
 9706                        .unwrap()
 9707                });
 9708                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 9709                main_project
 9710                    .update(cx, |p, cx| p.git_scans_complete(cx))
 9711                    .await;
 9712
 9713                state.unopened_worktrees.push(UnopenedWorktree {
 9714                    path: worktree_path,
 9715                    main_workspace_path: main_path.clone(),
 9716                });
 9717            }
 9718            Operation::AddWorktreeToProject {
 9719                project_group_index,
 9720            } => {
 9721                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9722                    let keys = mw.project_group_keys();
 9723                    let key = &keys[project_group_index];
 9724                    mw.workspaces_for_project_group(key, cx)
 9725                        .and_then(|ws| ws.first().cloned())
 9726                });
 9727                let Some(workspace) = workspace else { return };
 9728                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9729
 9730                let new_path = state.next_workspace_path();
 9731                state
 9732                    .fs
 9733                    .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
 9734                    .await;
 9735
 9736                let result = project
 9737                    .update(cx, |project, cx| {
 9738                        project.find_or_create_worktree(&new_path, true, cx)
 9739                    })
 9740                    .await;
 9741                if result.is_err() {
 9742                    return;
 9743                }
 9744                cx.run_until_parked();
 9745            }
 9746            Operation::RemoveWorktreeFromProject {
 9747                project_group_index,
 9748            } => {
 9749                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9750                    let keys = mw.project_group_keys();
 9751                    let key = &keys[project_group_index];
 9752                    mw.workspaces_for_project_group(key, cx)
 9753                        .and_then(|ws| ws.first().cloned())
 9754                });
 9755                let Some(workspace) = workspace else { return };
 9756                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9757
 9758                let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
 9759                if worktree_count <= 1 {
 9760                    return;
 9761                }
 9762
 9763                let worktree_id = project.read_with(cx, |p, cx| {
 9764                    p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
 9765                });
 9766                if let Some(worktree_id) = worktree_id {
 9767                    project.update(cx, |project, cx| {
 9768                        project.remove_worktree(worktree_id, cx);
 9769                    });
 9770                    cx.run_until_parked();
 9771                }
 9772            }
 9773        }
 9774    }
 9775
 9776    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
 9777        sidebar.update_in(cx, |sidebar, _window, cx| {
 9778            if let Some(mw) = sidebar.multi_workspace.upgrade() {
 9779                mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 9780            }
 9781            sidebar.update_entries(cx);
 9782        });
 9783    }
 9784
 9785    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9786        verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
 9787        verify_no_duplicate_threads(sidebar)?;
 9788        verify_all_threads_are_shown(sidebar, cx)?;
 9789        verify_active_state_matches_current_workspace(sidebar, cx)?;
 9790        verify_all_workspaces_are_reachable(sidebar, cx)?;
 9791        verify_workspace_group_key_integrity(sidebar, cx)?;
 9792        Ok(())
 9793    }
 9794
 9795    fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
 9796        let mut seen: HashSet<acp::SessionId> = HashSet::default();
 9797        let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
 9798
 9799        for entry in &sidebar.contents.entries {
 9800            if let Some(session_id) = entry.session_id() {
 9801                if !seen.insert(session_id.clone()) {
 9802                    let title = match entry {
 9803                        ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
 9804                        _ => "<unknown>".to_string(),
 9805                    };
 9806                    duplicates.push((session_id.clone(), title));
 9807                }
 9808            }
 9809        }
 9810
 9811        anyhow::ensure!(
 9812            duplicates.is_empty(),
 9813            "threads appear more than once in sidebar: {:?}",
 9814            duplicates,
 9815        );
 9816        Ok(())
 9817    }
 9818
 9819    fn verify_every_group_in_multiworkspace_is_shown(
 9820        sidebar: &Sidebar,
 9821        cx: &App,
 9822    ) -> anyhow::Result<()> {
 9823        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9824            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9825        };
 9826
 9827        let mw = multi_workspace.read(cx);
 9828
 9829        // Every project group key in the multi-workspace that has a
 9830        // non-empty path list should appear as a ProjectHeader in the
 9831        // sidebar.
 9832        let all_keys = mw.project_group_keys();
 9833        let expected_keys: HashSet<&ProjectGroupKey> = all_keys
 9834            .iter()
 9835            .filter(|k| !k.path_list().paths().is_empty())
 9836            .collect();
 9837
 9838        let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
 9839            .contents
 9840            .entries
 9841            .iter()
 9842            .filter_map(|entry| match entry {
 9843                ListEntry::ProjectHeader { key, .. } => Some(key),
 9844                _ => None,
 9845            })
 9846            .collect();
 9847
 9848        let missing = &expected_keys - &sidebar_keys;
 9849        let stray = &sidebar_keys - &expected_keys;
 9850
 9851        anyhow::ensure!(
 9852            missing.is_empty() && stray.is_empty(),
 9853            "sidebar project groups don't match multi-workspace.\n\
 9854             Only in multi-workspace (missing): {:?}\n\
 9855             Only in sidebar (stray): {:?}",
 9856            missing,
 9857            stray,
 9858        );
 9859
 9860        Ok(())
 9861    }
 9862
 9863    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9864        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9865            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9866        };
 9867        let workspaces = multi_workspace
 9868            .read(cx)
 9869            .workspaces()
 9870            .cloned()
 9871            .collect::<Vec<_>>();
 9872        let thread_store = ThreadMetadataStore::global(cx);
 9873
 9874        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
 9875            .contents
 9876            .entries
 9877            .iter()
 9878            .filter_map(|entry| entry.session_id().cloned())
 9879            .collect();
 9880
 9881        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
 9882
 9883        // Query using the same approach as the sidebar: iterate project
 9884        // group keys, then do main + legacy queries per group.
 9885        let mw = multi_workspace.read(cx);
 9886        let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
 9887            HashMap::default();
 9888        for workspace in &workspaces {
 9889            let key = workspace.read(cx).project_group_key(cx);
 9890            workspaces_by_group
 9891                .entry(key)
 9892                .or_default()
 9893                .push(workspace.clone());
 9894        }
 9895
 9896        for group_key in mw.project_group_keys() {
 9897            let path_list = group_key.path_list().clone();
 9898            if path_list.paths().is_empty() {
 9899                continue;
 9900            }
 9901
 9902            let group_workspaces = workspaces_by_group
 9903                .get(&group_key)
 9904                .map(|ws| ws.as_slice())
 9905                .unwrap_or_default();
 9906
 9907            // Main code path queries (run for all groups, even without workspaces).
 9908            // Skip drafts (session_id: None) — they are shown via the
 9909            // panel's draft_thread_ids, not by session_id matching.
 9910            for metadata in thread_store
 9911                .read(cx)
 9912                .entries_for_main_worktree_path(&path_list, None)
 9913            {
 9914                if let Some(sid) = metadata.session_id.clone() {
 9915                    metadata_thread_ids.insert(sid);
 9916                }
 9917            }
 9918            for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
 9919                if let Some(sid) = metadata.session_id.clone() {
 9920                    metadata_thread_ids.insert(sid);
 9921                }
 9922            }
 9923
 9924            // Legacy: per-workspace queries for different root paths.
 9925            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
 9926                .iter()
 9927                .flat_map(|ws| {
 9928                    ws.read(cx)
 9929                        .root_paths(cx)
 9930                        .into_iter()
 9931                        .map(|p| p.to_path_buf())
 9932                })
 9933                .collect();
 9934
 9935            for workspace in group_workspaces {
 9936                let ws_path_list = workspace_path_list(workspace, cx);
 9937                if ws_path_list != path_list {
 9938                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
 9939                        if let Some(sid) = metadata.session_id.clone() {
 9940                            metadata_thread_ids.insert(sid);
 9941                        }
 9942                    }
 9943                }
 9944            }
 9945
 9946            for workspace in group_workspaces {
 9947                for snapshot in root_repository_snapshots(workspace, cx) {
 9948                    let repo_path_list =
 9949                        PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
 9950                    if repo_path_list != path_list {
 9951                        continue;
 9952                    }
 9953                    for linked_worktree in snapshot.linked_worktrees() {
 9954                        if covered_paths.contains(&*linked_worktree.path) {
 9955                            continue;
 9956                        }
 9957                        let worktree_path_list =
 9958                            PathList::new(std::slice::from_ref(&linked_worktree.path));
 9959                        for metadata in thread_store
 9960                            .read(cx)
 9961                            .entries_for_path(&worktree_path_list, None)
 9962                        {
 9963                            if let Some(sid) = metadata.session_id.clone() {
 9964                                metadata_thread_ids.insert(sid);
 9965                            }
 9966                        }
 9967                    }
 9968                }
 9969            }
 9970        }
 9971
 9972        anyhow::ensure!(
 9973            sidebar_thread_ids == metadata_thread_ids,
 9974            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
 9975            sidebar_thread_ids,
 9976            metadata_thread_ids,
 9977        );
 9978        Ok(())
 9979    }
 9980
 9981    fn verify_active_state_matches_current_workspace(
 9982        sidebar: &Sidebar,
 9983        cx: &App,
 9984    ) -> anyhow::Result<()> {
 9985        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9986            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9987        };
 9988
 9989        let active_workspace = multi_workspace.read(cx).workspace();
 9990
 9991        // 1. active_entry should be Some when the panel has content.
 9992        //    It may be None when the panel is uninitialized (no drafts,
 9993        //    no threads), which is fine.
 9994        //    It may also temporarily point at a different workspace
 9995        //    when the workspace just changed and the new panel has no
 9996        //    content yet.
 9997        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
 9998        let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
 9999            || panel.read(cx).active_conversation_view().is_some();
10000
10001        let Some(entry) = sidebar.active_entry.as_ref() else {
10002            if panel_has_content {
10003                anyhow::bail!("active_entry is None but panel has content (draft or thread)");
10004            }
10005            return Ok(());
10006        };
10007
10008        // If the entry workspace doesn't match the active workspace
10009        // and the panel has no content, this is a transient state that
10010        // will resolve when the panel gets content.
10011        if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
10012            return Ok(());
10013        }
10014
10015        // 2. The entry's workspace must agree with the multi-workspace's
10016        //    active workspace.
10017        anyhow::ensure!(
10018            entry.workspace().entity_id() == active_workspace.entity_id(),
10019            "active_entry workspace ({:?}) != active workspace ({:?})",
10020            entry.workspace().entity_id(),
10021            active_workspace.entity_id(),
10022        );
10023
10024        // 3. The entry must match the agent panel's current state.
10025        if panel.read(cx).active_thread_id(cx).is_some() {
10026            anyhow::ensure!(
10027                matches!(entry, ActiveEntry { .. }),
10028                "panel shows a tracked draft but active_entry is {:?}",
10029                entry,
10030            );
10031        } else if let Some(thread_id) = panel
10032            .read(cx)
10033            .active_conversation_view()
10034            .map(|cv| cv.read(cx).parent_id())
10035        {
10036            anyhow::ensure!(
10037                matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
10038                "panel has thread {:?} but active_entry is {:?}",
10039                thread_id,
10040                entry,
10041            );
10042        }
10043
10044        // 4. Exactly one entry in sidebar contents must be uniquely
10045        //    identified by the active_entry.
10046        let matching_count = sidebar
10047            .contents
10048            .entries
10049            .iter()
10050            .filter(|e| entry.matches_entry(e))
10051            .count();
10052        if matching_count != 1 {
10053            let thread_entries: Vec<_> = sidebar
10054                .contents
10055                .entries
10056                .iter()
10057                .filter_map(|e| match e {
10058                    ListEntry::Thread(t) => Some(format!(
10059                        "tid={:?} sid={:?} draft={}",
10060                        t.metadata.thread_id, t.metadata.session_id, t.is_draft
10061                    )),
10062                    _ => None,
10063                })
10064                .collect();
10065            let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
10066            let store_entries: Vec<_> = store
10067                .entries()
10068                .map(|m| {
10069                    format!(
10070                        "tid={:?} sid={:?} archived={} paths={:?}",
10071                        m.thread_id,
10072                        m.session_id,
10073                        m.archived,
10074                        m.folder_paths()
10075                    )
10076                })
10077                .collect();
10078            anyhow::bail!(
10079                "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
10080                entry,
10081                matching_count,
10082                thread_entries,
10083                store_entries,
10084            );
10085        }
10086
10087        Ok(())
10088    }
10089
10090    /// Every workspace in the multi-workspace should be "reachable" from
10091    /// the sidebar — meaning there is at least one entry (thread, draft,
10092    /// new-thread, or project header) that, when clicked, would activate
10093    /// that workspace.
10094    fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
10095        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
10096            anyhow::bail!("sidebar should still have an associated multi-workspace");
10097        };
10098
10099        let multi_workspace = multi_workspace.read(cx);
10100
10101        let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
10102            .contents
10103            .entries
10104            .iter()
10105            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
10106            .map(|ws| ws.entity_id())
10107            .collect();
10108
10109        let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
10110            .workspaces()
10111            .map(|ws| ws.entity_id())
10112            .collect();
10113
10114        let unreachable = &all_workspace_ids - &reachable_workspaces;
10115
10116        anyhow::ensure!(
10117            unreachable.is_empty(),
10118            "The following workspaces are not reachable from any sidebar entry: {:?}",
10119            unreachable,
10120        );
10121
10122        Ok(())
10123    }
10124
10125    fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
10126        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
10127            anyhow::bail!("sidebar should still have an associated multi-workspace");
10128        };
10129        multi_workspace
10130            .read(cx)
10131            .assert_project_group_key_integrity(cx)
10132    }
10133
10134    #[gpui::property_test(config = ProptestConfig {
10135        cases: 20,
10136        ..Default::default()
10137    })]
10138    async fn test_sidebar_invariants(
10139        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
10140        raw_operations: Vec<u32>,
10141        cx: &mut TestAppContext,
10142    ) {
10143        use std::sync::atomic::{AtomicUsize, Ordering};
10144        static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
10145
10146        agent_ui::test_support::init_test(cx);
10147        cx.update(|cx| {
10148            cx.set_global(db::AppDatabase::test_new());
10149            cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
10150            cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
10151                format!(
10152                    "PROPTEST_THREAD_METADATA_{}",
10153                    NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
10154                ),
10155            ));
10156
10157            ThreadStore::init_global(cx);
10158            ThreadMetadataStore::init_global(cx);
10159            language_model::LanguageModelRegistry::test(cx);
10160            prompt_store::init(cx);
10161
10162            // Auto-add an AgentPanel to every workspace so that implicitly
10163            // created workspaces (e.g. from thread activation) also have one.
10164            cx.observe_new(
10165                |workspace: &mut Workspace,
10166                 window: Option<&mut Window>,
10167                 cx: &mut gpui::Context<Workspace>| {
10168                    if let Some(window) = window {
10169                        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
10170                        workspace.add_panel(panel, window, cx);
10171                    }
10172                },
10173            )
10174            .detach();
10175        });
10176
10177        let fs = FakeFs::new(cx.executor());
10178        fs.insert_tree(
10179            "/my-project",
10180            serde_json::json!({
10181                ".git": {},
10182                "src": {},
10183            }),
10184        )
10185        .await;
10186        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10187        let project =
10188            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
10189                .await;
10190        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10191
10192        let (multi_workspace, cx) =
10193            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10194        let sidebar = setup_sidebar(&multi_workspace, cx);
10195
10196        let mut state = TestState::new(fs);
10197        let mut executed: Vec<String> = Vec::new();
10198
10199        for &raw_op in &raw_operations {
10200            let project_group_count =
10201                multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
10202            let operation = state.generate_operation(raw_op, project_group_count);
10203            executed.push(format!("{:?}", operation));
10204            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
10205            cx.run_until_parked();
10206
10207            update_sidebar(&sidebar, cx);
10208            cx.run_until_parked();
10209
10210            let result =
10211                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
10212            if let Err(err) = result {
10213                let log = executed.join("\n  ");
10214                panic!(
10215                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
10216                    executed.len(),
10217                );
10218            }
10219        }
10220    }
10221}
10222
10223#[gpui::test]
10224async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
10225    cx: &mut TestAppContext,
10226    server_cx: &mut TestAppContext,
10227) {
10228    init_test(cx);
10229
10230    cx.update(|cx| {
10231        release_channel::init(semver::Version::new(0, 0, 0), cx);
10232    });
10233
10234    let app_state = cx.update(|cx| {
10235        let app_state = workspace::AppState::test(cx);
10236        workspace::init(app_state.clone(), cx);
10237        app_state
10238    });
10239
10240    // Set up the remote server side.
10241    let server_fs = FakeFs::new(server_cx.executor());
10242    server_fs
10243        .insert_tree(
10244            "/project",
10245            serde_json::json!({
10246                ".git": {},
10247                "src": { "main.rs": "fn main() {}" }
10248            }),
10249        )
10250        .await;
10251    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10252
10253    // Create the linked worktree checkout path on the remote server,
10254    // but do not yet register it as a git-linked worktree. The real
10255    // regrouping update in this test should happen only after the
10256    // sidebar opens the closed remote thread.
10257    server_fs
10258        .insert_tree(
10259            "/project-wt-1",
10260            serde_json::json!({
10261                "src": { "main.rs": "fn main() {}" }
10262            }),
10263        )
10264        .await;
10265
10266    server_cx.update(|cx| {
10267        release_channel::init(semver::Version::new(0, 0, 0), cx);
10268    });
10269
10270    let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
10271
10272    server_cx.update(remote_server::HeadlessProject::init);
10273    let server_executor = server_cx.executor();
10274    let _headless = server_cx.new(|cx| {
10275        remote_server::HeadlessProject::new(
10276            remote_server::HeadlessAppState {
10277                session: server_session,
10278                fs: server_fs.clone(),
10279                http_client: Arc::new(http_client::BlockedHttpClient),
10280                node_runtime: node_runtime::NodeRuntime::unavailable(),
10281                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10282                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10283                startup_time: std::time::Instant::now(),
10284            },
10285            false,
10286            cx,
10287        )
10288    });
10289
10290    // Connect the client side and build a remote project.
10291    let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
10292    let project = cx.update(|cx| {
10293        let project_client = client::Client::new(
10294            Arc::new(clock::FakeSystemClock::new()),
10295            http_client::FakeHttpClient::with_404_response(),
10296            cx,
10297        );
10298        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
10299        project::Project::remote(
10300            remote_client,
10301            project_client,
10302            node_runtime::NodeRuntime::unavailable(),
10303            user_store,
10304            app_state.languages.clone(),
10305            app_state.fs.clone(),
10306            false,
10307            cx,
10308        )
10309    });
10310
10311    // Open the remote worktree.
10312    project
10313        .update(cx, |project, cx| {
10314            project.find_or_create_worktree(Path::new("/project"), true, cx)
10315        })
10316        .await
10317        .expect("should open remote worktree");
10318    cx.run_until_parked();
10319
10320    // Verify the project is remote.
10321    project.read_with(cx, |project, cx| {
10322        assert!(!project.is_local(), "project should be remote");
10323        assert!(
10324            project.remote_connection_options(cx).is_some(),
10325            "project should have remote connection options"
10326        );
10327    });
10328
10329    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10330
10331    // Create MultiWorkspace with the remote project.
10332    let (multi_workspace, cx) =
10333        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10334    let sidebar = setup_sidebar(&multi_workspace, cx);
10335
10336    cx.run_until_parked();
10337
10338    // Save a thread for the main remote workspace (folder_paths match
10339    // the open workspace, so it will be classified as Open).
10340    let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
10341    save_thread_metadata(
10342        main_thread_id.clone(),
10343        Some("Main Thread".into()),
10344        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10345        None,
10346        &project,
10347        cx,
10348    );
10349    cx.run_until_parked();
10350
10351    // Save a thread whose folder_paths point to a linked worktree path
10352    // that doesn't have an open workspace ("/project-wt-1"), but whose
10353    // main_worktree_paths match the project group key so it appears
10354    // in the sidebar under the same remote group. This simulates a
10355    // linked worktree workspace that was closed.
10356    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10357    let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
10358        (
10359            p.project_group_key(cx).path_list().clone(),
10360            p.remote_connection_options(cx),
10361        )
10362    });
10363    cx.update(|_window, cx| {
10364        let metadata = ThreadMetadata {
10365            thread_id: ThreadId::new(),
10366            session_id: Some(remote_thread_id.clone()),
10367            agent_id: agent::ZED_AGENT_ID.clone(),
10368            title: Some("Worktree Thread".into()),
10369            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10370            created_at: None,
10371            worktree_paths: WorktreePaths::from_path_lists(
10372                main_worktree_paths,
10373                PathList::new(&[PathBuf::from("/project-wt-1")]),
10374            )
10375            .unwrap(),
10376            archived: false,
10377            remote_connection,
10378        };
10379        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10380    });
10381    cx.run_until_parked();
10382
10383    focus_sidebar(&sidebar, cx);
10384    sidebar.update_in(cx, |sidebar, _window, _cx| {
10385        sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10386            matches!(
10387                entry,
10388                ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10389            )
10390        });
10391    });
10392
10393    let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10394    let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10395
10396    sidebar
10397        .update(cx, |_, cx| {
10398            cx.observe_self(move |sidebar, _cx| {
10399                let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10400                    if let ListEntry::ProjectHeader { label, .. } = entry {
10401                        Some(label.as_ref())
10402                    } else {
10403                        None
10404                    }
10405                });
10406
10407                let Some(project_header) = project_headers.next() else {
10408                    saw_separate_project_header_for_observer
10409                        .store(true, std::sync::atomic::Ordering::SeqCst);
10410                    return;
10411                };
10412
10413                if project_header != "project" || project_headers.next().is_some() {
10414                    saw_separate_project_header_for_observer
10415                        .store(true, std::sync::atomic::Ordering::SeqCst);
10416                }
10417            })
10418        })
10419        .detach();
10420
10421    multi_workspace.update(cx, |multi_workspace, cx| {
10422        let workspace = multi_workspace.workspace().clone();
10423        workspace.update(cx, |workspace: &mut Workspace, cx| {
10424            let remote_client = workspace
10425                .project()
10426                .read(cx)
10427                .remote_client()
10428                .expect("main remote project should have a remote client");
10429            remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10430                remote_client.force_server_not_running(cx);
10431            });
10432        });
10433    });
10434    cx.run_until_parked();
10435
10436    let (server_session_2, connect_guard_2) =
10437        remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10438    let _headless_2 = server_cx.new(|cx| {
10439        remote_server::HeadlessProject::new(
10440            remote_server::HeadlessAppState {
10441                session: server_session_2,
10442                fs: server_fs.clone(),
10443                http_client: Arc::new(http_client::BlockedHttpClient),
10444                node_runtime: node_runtime::NodeRuntime::unavailable(),
10445                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10446                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10447                startup_time: std::time::Instant::now(),
10448            },
10449            false,
10450            cx,
10451        )
10452    });
10453    drop(connect_guard_2);
10454
10455    let window = cx.windows()[0];
10456    cx.update_window(window, |_, window, cx| {
10457        window.dispatch_action(Confirm.boxed_clone(), cx);
10458    })
10459    .unwrap();
10460
10461    cx.run_until_parked();
10462
10463    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10464        assert_eq!(
10465            mw.workspaces().count(),
10466            2,
10467            "confirming a closed remote thread should open a second workspace"
10468        );
10469        mw.workspaces()
10470            .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10471            .unwrap()
10472            .clone()
10473    });
10474
10475    server_fs
10476        .add_linked_worktree_for_repo(
10477            Path::new("/project/.git"),
10478            true,
10479            git::repository::Worktree {
10480                path: PathBuf::from("/project-wt-1"),
10481                ref_name: Some("refs/heads/feature-wt".into()),
10482                sha: "abc123".into(),
10483                is_main: false,
10484                is_bare: false,
10485            },
10486        )
10487        .await;
10488
10489    server_cx.run_until_parked();
10490    cx.run_until_parked();
10491    server_cx.run_until_parked();
10492    cx.run_until_parked();
10493
10494    let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10495    let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10496        workspace.project().read(cx).project_group_key(cx)
10497    });
10498
10499    assert_eq!(
10500        group_after_update,
10501        project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10502        "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10503         final sidebar entries: {:?}",
10504        entries_after_update,
10505    );
10506
10507    sidebar.update(cx, |sidebar, _cx| {
10508        assert_remote_project_integration_sidebar_state(
10509            sidebar,
10510            &main_thread_id,
10511            &remote_thread_id,
10512        );
10513    });
10514
10515    assert!(
10516        !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10517        "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10518         final group: {:?}; final sidebar entries: {:?}",
10519        group_after_update,
10520        entries_after_update,
10521    );
10522}
10523
10524#[gpui::test]
10525async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10526    // When the thread's folder_paths don't exactly match any workspace's
10527    // root paths (e.g. because a folder was added to the workspace after
10528    // the thread was created), workspace_to_remove is None. But the linked
10529    // worktree workspace still needs to be removed so that its worktree
10530    // entities are released, allowing git worktree removal to proceed.
10531    //
10532    // With the fix, archive_thread scans roots_to_archive for any linked
10533    // worktree workspaces and includes them in the removal set, even when
10534    // the thread's folder_paths don't match the workspace's root paths.
10535    init_test(cx);
10536    let fs = FakeFs::new(cx.executor());
10537
10538    fs.insert_tree(
10539        "/project",
10540        serde_json::json!({
10541            ".git": {
10542                "worktrees": {
10543                    "feature-a": {
10544                        "commondir": "../../",
10545                        "HEAD": "ref: refs/heads/feature-a",
10546                    },
10547                },
10548            },
10549            "src": {},
10550        }),
10551    )
10552    .await;
10553
10554    fs.insert_tree(
10555        "/wt-feature-a",
10556        serde_json::json!({
10557            ".git": "gitdir: /project/.git/worktrees/feature-a",
10558            "src": {
10559                "main.rs": "fn main() {}",
10560            },
10561        }),
10562    )
10563    .await;
10564
10565    fs.add_linked_worktree_for_repo(
10566        Path::new("/project/.git"),
10567        false,
10568        git::repository::Worktree {
10569            path: PathBuf::from("/wt-feature-a"),
10570            ref_name: Some("refs/heads/feature-a".into()),
10571            sha: "abc".into(),
10572            is_main: false,
10573            is_bare: false,
10574        },
10575    )
10576    .await;
10577
10578    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10579
10580    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10581    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
10582
10583    main_project
10584        .update(cx, |p, cx| p.git_scans_complete(cx))
10585        .await;
10586    worktree_project
10587        .update(cx, |p, cx| p.git_scans_complete(cx))
10588        .await;
10589
10590    let (multi_workspace, cx) =
10591        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10592    let sidebar = setup_sidebar(&multi_workspace, cx);
10593
10594    multi_workspace.update_in(cx, |mw, window, cx| {
10595        mw.test_add_workspace(worktree_project.clone(), window, cx)
10596    });
10597
10598    // Save thread metadata using folder_paths that DON'T match the
10599    // workspace's root paths. This simulates the case where the workspace's
10600    // paths diverged (e.g. a folder was added after thread creation).
10601    // This causes workspace_to_remove to be None because
10602    // workspace_for_paths can't find a workspace with these exact paths.
10603    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10604    save_thread_metadata_with_main_paths(
10605        "worktree-thread",
10606        "Worktree Thread",
10607        PathList::new(&[
10608            PathBuf::from("/wt-feature-a"),
10609            PathBuf::from("/nonexistent"),
10610        ]),
10611        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10612        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10613        cx,
10614    );
10615
10616    // Also save a main thread so the sidebar has something to show.
10617    save_thread_metadata(
10618        acp::SessionId::new(Arc::from("main-thread")),
10619        Some("Main Thread".into()),
10620        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10621        None,
10622        &main_project,
10623        cx,
10624    );
10625    cx.run_until_parked();
10626
10627    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10628    cx.run_until_parked();
10629
10630    assert_eq!(
10631        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10632        2,
10633        "should start with 2 workspaces (main + linked worktree)"
10634    );
10635
10636    // Archive the worktree thread.
10637    sidebar.update_in(cx, |sidebar, window, cx| {
10638        sidebar.archive_thread(&wt_thread_id, window, cx);
10639    });
10640
10641    cx.run_until_parked();
10642
10643    // The linked worktree workspace should have been removed, even though
10644    // workspace_to_remove was None (paths didn't match).
10645    assert_eq!(
10646        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10647        1,
10648        "linked worktree workspace should be removed after archiving, \
10649         even when folder_paths don't match workspace root paths"
10650    );
10651
10652    // The thread should still be archived (not unarchived due to an error).
10653    let still_archived = cx.update(|_, cx| {
10654        ThreadMetadataStore::global(cx)
10655            .read(cx)
10656            .entry_by_session(&wt_thread_id)
10657            .map(|t| t.archived)
10658    });
10659    assert_eq!(
10660        still_archived,
10661        Some(true),
10662        "thread should still be archived (not rolled back due to error)"
10663    );
10664
10665    // The linked worktree directory should be removed from disk.
10666    assert!(
10667        !fs.is_dir(Path::new("/wt-feature-a")).await,
10668        "linked worktree directory should be removed from disk"
10669    );
10670}
10671
10672#[gpui::test]
10673async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10674    // When a workspace contains both a worktree being archived and other
10675    // worktrees that should remain, only the editor items referencing the
10676    // archived worktree should be closed — the workspace itself must be
10677    // preserved.
10678    init_test(cx);
10679    let fs = FakeFs::new(cx.executor());
10680
10681    fs.insert_tree(
10682        "/main-repo",
10683        serde_json::json!({
10684            ".git": {
10685                "worktrees": {
10686                    "feature-b": {
10687                        "commondir": "../../",
10688                        "HEAD": "ref: refs/heads/feature-b",
10689                    },
10690                },
10691            },
10692            "src": {
10693                "lib.rs": "pub fn hello() {}",
10694            },
10695        }),
10696    )
10697    .await;
10698
10699    fs.insert_tree(
10700        "/wt-feature-b",
10701        serde_json::json!({
10702            ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10703            "src": {
10704                "main.rs": "fn main() { hello(); }",
10705            },
10706        }),
10707    )
10708    .await;
10709
10710    fs.add_linked_worktree_for_repo(
10711        Path::new("/main-repo/.git"),
10712        false,
10713        git::repository::Worktree {
10714            path: PathBuf::from("/wt-feature-b"),
10715            ref_name: Some("refs/heads/feature-b".into()),
10716            sha: "def".into(),
10717            is_main: false,
10718            is_bare: false,
10719        },
10720    )
10721    .await;
10722
10723    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10724
10725    // Create a single project that contains BOTH the main repo and the
10726    // linked worktree — this makes it a "mixed" workspace.
10727    let mixed_project = project::Project::test(
10728        fs.clone(),
10729        ["/main-repo".as_ref(), "/wt-feature-b".as_ref()],
10730        cx,
10731    )
10732    .await;
10733
10734    mixed_project
10735        .update(cx, |p, cx| p.git_scans_complete(cx))
10736        .await;
10737
10738    let (multi_workspace, cx) = cx
10739        .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10740    let sidebar = setup_sidebar(&multi_workspace, cx);
10741
10742    // Open editor items in both worktrees so we can verify which ones
10743    // get closed.
10744    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10745
10746    let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10747        ws.project()
10748            .read(cx)
10749            .visible_worktrees(cx)
10750            .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10751            .collect()
10752    });
10753
10754    let main_repo_wt_id = worktree_ids
10755        .iter()
10756        .find(|(_, path)| path.ends_with("main-repo"))
10757        .map(|(id, _)| *id)
10758        .expect("should find main-repo worktree");
10759
10760    let feature_b_wt_id = worktree_ids
10761        .iter()
10762        .find(|(_, path)| path.ends_with("wt-feature-b"))
10763        .map(|(id, _)| *id)
10764        .expect("should find wt-feature-b worktree");
10765
10766    // Open files from both worktrees.
10767    let main_repo_path = project::ProjectPath {
10768        worktree_id: main_repo_wt_id,
10769        path: Arc::from(rel_path("src/lib.rs")),
10770    };
10771    let feature_b_path = project::ProjectPath {
10772        worktree_id: feature_b_wt_id,
10773        path: Arc::from(rel_path("src/main.rs")),
10774    };
10775
10776    workspace
10777        .update_in(cx, |ws, window, cx| {
10778            ws.open_path(main_repo_path.clone(), None, true, window, cx)
10779        })
10780        .await
10781        .expect("should open main-repo file");
10782    workspace
10783        .update_in(cx, |ws, window, cx| {
10784            ws.open_path(feature_b_path.clone(), None, true, window, cx)
10785        })
10786        .await
10787        .expect("should open feature-b file");
10788
10789    cx.run_until_parked();
10790
10791    // Verify both items are open.
10792    let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10793        ws.panes()
10794            .iter()
10795            .flat_map(|pane| {
10796                pane.read(cx)
10797                    .items()
10798                    .filter_map(|item| item.project_path(cx))
10799            })
10800            .collect()
10801    });
10802    assert!(
10803        open_paths_before
10804            .iter()
10805            .any(|pp| pp.worktree_id == main_repo_wt_id),
10806        "main-repo file should be open"
10807    );
10808    assert!(
10809        open_paths_before
10810            .iter()
10811            .any(|pp| pp.worktree_id == feature_b_wt_id),
10812        "feature-b file should be open"
10813    );
10814
10815    // Save thread metadata for the linked worktree with deliberately
10816    // mismatched folder_paths to trigger the scan-based detection.
10817    save_thread_metadata_with_main_paths(
10818        "feature-b-thread",
10819        "Feature B Thread",
10820        PathList::new(&[
10821            PathBuf::from("/wt-feature-b"),
10822            PathBuf::from("/nonexistent"),
10823        ]),
10824        PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10825        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10826        cx,
10827    );
10828
10829    // Save another thread that references only the main repo (not the
10830    // linked worktree) so archiving the feature-b thread's worktree isn't
10831    // blocked by another unarchived thread referencing the same path.
10832    save_thread_metadata_with_main_paths(
10833        "other-thread",
10834        "Other Thread",
10835        PathList::new(&[PathBuf::from("/main-repo")]),
10836        PathList::new(&[PathBuf::from("/main-repo")]),
10837        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10838        cx,
10839    );
10840    cx.run_until_parked();
10841
10842    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10843    cx.run_until_parked();
10844
10845    // There should still be exactly 1 workspace.
10846    assert_eq!(
10847        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10848        1,
10849        "should have 1 workspace (the mixed workspace)"
10850    );
10851
10852    // Archive the feature-b thread.
10853    let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10854    sidebar.update_in(cx, |sidebar, window, cx| {
10855        sidebar.archive_thread(&fb_session_id, window, cx);
10856    });
10857
10858    cx.run_until_parked();
10859
10860    // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10861    assert_eq!(
10862        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10863        1,
10864        "mixed workspace should be preserved"
10865    );
10866
10867    // Only the feature-b editor item should have been closed.
10868    let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10869        ws.panes()
10870            .iter()
10871            .flat_map(|pane| {
10872                pane.read(cx)
10873                    .items()
10874                    .filter_map(|item| item.project_path(cx))
10875            })
10876            .collect()
10877    });
10878    assert!(
10879        open_paths_after
10880            .iter()
10881            .any(|pp| pp.worktree_id == main_repo_wt_id),
10882        "main-repo file should still be open"
10883    );
10884    assert!(
10885        !open_paths_after
10886            .iter()
10887            .any(|pp| pp.worktree_id == feature_b_wt_id),
10888        "feature-b file should have been closed"
10889    );
10890}
10891
10892#[test]
10893fn test_worktree_info_branch_names_for_main_worktrees() {
10894    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10895    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10896
10897    let branch_by_path: HashMap<PathBuf, SharedString> =
10898        [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10899            .into_iter()
10900            .collect();
10901
10902    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10903    assert_eq!(infos.len(), 1);
10904    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10905    assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10906    assert_eq!(infos[0].name, SharedString::from("myapp"));
10907}
10908
10909#[test]
10910fn test_worktree_info_branch_names_for_linked_worktrees() {
10911    let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10912    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10913    let worktree_paths =
10914        WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10915
10916    let branch_by_path: HashMap<PathBuf, SharedString> = [(
10917        PathBuf::from("/projects/myapp-feature"),
10918        "feature-branch".into(),
10919    )]
10920    .into_iter()
10921    .collect();
10922
10923    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10924    assert_eq!(infos.len(), 1);
10925    assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10926    assert_eq!(
10927        infos[0].branch_name,
10928        Some(SharedString::from("feature-branch"))
10929    );
10930}
10931
10932#[test]
10933fn test_worktree_info_missing_branch_returns_none() {
10934    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10935    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10936
10937    let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10938
10939    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10940    assert_eq!(infos.len(), 1);
10941    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10942    assert_eq!(infos[0].branch_name, None);
10943    assert_eq!(infos[0].name, SharedString::from("myapp"));
10944}