sidebar_tests.rs

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