sidebar_tests.rs

    1use super::*;
    2use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
    3use agent::ThreadStore;
    4use agent_ui::{
    5    ThreadId,
    6    test_support::{
    7        active_session_id, active_thread_id, open_thread_with_connection, send_message,
    8    },
    9    thread_metadata_store::{ThreadMetadata, WorktreePaths},
   10};
   11use chrono::DateTime;
   12use fs::{FakeFs, Fs};
   13use gpui::TestAppContext;
   14use pretty_assertions::assert_eq;
   15use project::AgentId;
   16use settings::SettingsStore;
   17use std::{
   18    path::{Path, PathBuf},
   19    sync::Arc,
   20};
   21use util::{path_list::PathList, rel_path::rel_path};
   22
   23fn init_test(cx: &mut TestAppContext) {
   24    cx.update(|cx| {
   25        let settings_store = SettingsStore::test(cx);
   26        cx.set_global(settings_store);
   27        theme_settings::init(theme::LoadThemes::JustBase, cx);
   28        editor::init(cx);
   29        ThreadStore::init_global(cx);
   30        ThreadMetadataStore::init_global(cx);
   31        language_model::LanguageModelRegistry::test(cx);
   32        prompt_store::init(cx);
   33    });
   34}
   35
   36#[track_caller]
   37fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
   38    let active = sidebar.active_entry.as_ref();
   39    let matches = active.is_some_and(|entry| {
   40        // Match by session_id directly on active_entry.
   41        entry.session_id.as_ref() == Some(session_id)
   42            // Or match by finding the thread in sidebar entries.
   43            || sidebar.contents.entries.iter().any(|list_entry| {
   44                matches!(list_entry, ListEntry::Thread(t)
   45                    if t.metadata.session_id.as_ref() == Some(session_id)
   46                        && entry.matches_entry(list_entry))
   47            })
   48    });
   49    assert!(
   50        matches,
   51        "{msg}: expected active_entry for session {session_id:?}, got {:?}",
   52        active,
   53    );
   54}
   55
   56#[track_caller]
   57fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   58    let thread_id = sidebar
   59        .contents
   60        .entries
   61        .iter()
   62        .find_map(|entry| match entry {
   63            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
   64                Some(t.metadata.thread_id)
   65            }
   66            _ => None,
   67        });
   68    match thread_id {
   69        Some(tid) => {
   70            matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid)
   71        }
   72        // Thread not in sidebar entries — can't confirm it's active.
   73        None => false,
   74    }
   75}
   76
   77#[track_caller]
   78fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
   79    assert!(
   80        matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace),
   81        "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
   82        workspace.entity_id(),
   83        sidebar.active_entry,
   84    );
   85}
   86
   87fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   88    sidebar
   89        .contents
   90        .entries
   91        .iter()
   92        .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
   93}
   94
   95#[track_caller]
   96fn assert_remote_project_integration_sidebar_state(
   97    sidebar: &mut Sidebar,
   98    main_thread_id: &acp::SessionId,
   99    remote_thread_id: &acp::SessionId,
  100) {
  101    let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
  102        if let ListEntry::ProjectHeader { label, .. } = entry {
  103            Some(label.as_ref())
  104        } else {
  105            None
  106        }
  107    });
  108
  109    let Some(project_header) = project_headers.next() else {
  110        panic!("expected exactly one sidebar project header named `project`, found none");
  111    };
  112    assert_eq!(
  113        project_header, "project",
  114        "expected the only sidebar project header to be `project`"
  115    );
  116    if let Some(unexpected_header) = project_headers.next() {
  117        panic!(
  118            "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
  119        );
  120    }
  121
  122    let mut saw_main_thread = false;
  123    let mut saw_remote_thread = false;
  124    for entry in &sidebar.contents.entries {
  125        match entry {
  126            ListEntry::ProjectHeader { label, .. } => {
  127                assert_eq!(
  128                    label.as_ref(),
  129                    "project",
  130                    "expected the only sidebar project header to be `project`"
  131                );
  132            }
  133            ListEntry::Thread(thread)
  134                if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
  135            {
  136                saw_main_thread = true;
  137            }
  138            ListEntry::Thread(thread)
  139                if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
  140            {
  141                saw_remote_thread = true;
  142            }
  143            ListEntry::Thread(thread) => {
  144                let title = thread.metadata.display_title();
  145                panic!(
  146                    "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
  147                    title
  148                );
  149            }
  150        }
  151    }
  152
  153    assert!(
  154        saw_main_thread,
  155        "expected the sidebar to keep showing `Main Thread` under `project`"
  156    );
  157    assert!(
  158        saw_remote_thread,
  159        "expected the sidebar to keep showing `Worktree Thread` under `project`"
  160    );
  161}
  162
  163async fn init_test_project(
  164    worktree_path: &str,
  165    cx: &mut TestAppContext,
  166) -> Entity<project::Project> {
  167    init_test(cx);
  168    let fs = FakeFs::new(cx.executor());
  169    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
  170        .await;
  171    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
  172    project::Project::test(fs, [worktree_path.as_ref()], cx).await
  173}
  174
  175fn setup_sidebar(
  176    multi_workspace: &Entity<MultiWorkspace>,
  177    cx: &mut gpui::VisualTestContext,
  178) -> Entity<Sidebar> {
  179    let sidebar = setup_sidebar_closed(multi_workspace, cx);
  180    multi_workspace.update_in(cx, |mw, window, cx| {
  181        mw.toggle_sidebar(window, cx);
  182    });
  183    cx.run_until_parked();
  184    sidebar
  185}
  186
  187fn setup_sidebar_closed(
  188    multi_workspace: &Entity<MultiWorkspace>,
  189    cx: &mut gpui::VisualTestContext,
  190) -> Entity<Sidebar> {
  191    let multi_workspace = multi_workspace.clone();
  192    let sidebar =
  193        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  194    multi_workspace.update(cx, |mw, cx| {
  195        mw.register_sidebar(sidebar.clone(), cx);
  196    });
  197    cx.run_until_parked();
  198    sidebar
  199}
  200
  201async fn save_n_test_threads(
  202    count: u32,
  203    project: &Entity<project::Project>,
  204    cx: &mut gpui::VisualTestContext,
  205) {
  206    for i in 0..count {
  207        save_thread_metadata(
  208            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
  209            Some(format!("Thread {}", i + 1).into()),
  210            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
  211            None,
  212            None,
  213            project,
  214            cx,
  215        )
  216    }
  217    cx.run_until_parked();
  218}
  219
  220async fn save_test_thread_metadata(
  221    session_id: &acp::SessionId,
  222    project: &Entity<project::Project>,
  223    cx: &mut TestAppContext,
  224) {
  225    save_thread_metadata(
  226        session_id.clone(),
  227        Some("Test".into()),
  228        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  229        None,
  230        None,
  231        project,
  232        cx,
  233    )
  234}
  235
  236async fn save_named_thread_metadata(
  237    session_id: &str,
  238    title: &str,
  239    project: &Entity<project::Project>,
  240    cx: &mut gpui::VisualTestContext,
  241) {
  242    save_thread_metadata(
  243        acp::SessionId::new(Arc::from(session_id)),
  244        Some(SharedString::from(title.to_string())),
  245        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  246        None,
  247        None,
  248        project,
  249        cx,
  250    );
  251    cx.run_until_parked();
  252}
  253
  254/// Spins up a fresh remote project backed by a headless server sharing
  255/// `server_fs`, opens the given worktree path on it, and returns the
  256/// project together with the headless entity (which the caller must keep
  257/// alive for the duration of the test) and the `RemoteConnectionOptions`
  258/// used for the fake server. Passing those options back into
  259/// `reuse_opts` on a subsequent call makes the new project share the
  260/// same `RemoteConnectionIdentity`, matching how Zed treats multiple
  261/// projects on the same SSH host.
  262async fn start_remote_project(
  263    server_fs: &Arc<FakeFs>,
  264    worktree_path: &Path,
  265    app_state: &Arc<workspace::AppState>,
  266    reuse_opts: Option<&remote::RemoteConnectionOptions>,
  267    cx: &mut TestAppContext,
  268    server_cx: &mut TestAppContext,
  269) -> (
  270    Entity<project::Project>,
  271    Entity<remote_server::HeadlessProject>,
  272    remote::RemoteConnectionOptions,
  273) {
  274    // Bare `_` on the guard so it's dropped immediately; holding onto it
  275    // would deadlock `connect_mock` below since the client waits on the
  276    // guard before completing the mock handshake.
  277    let (opts, server_session) = match reuse_opts {
  278        Some(existing) => {
  279            let (session, _) = remote::RemoteClient::fake_server_with_opts(existing, cx, server_cx);
  280            (existing.clone(), session)
  281        }
  282        None => {
  283            let (opts, session, _) = remote::RemoteClient::fake_server(cx, server_cx);
  284            (opts, session)
  285        }
  286    };
  287
  288    server_cx.update(remote_server::HeadlessProject::init);
  289    let server_executor = server_cx.executor();
  290    let fs = server_fs.clone();
  291    let headless = server_cx.new(|cx| {
  292        remote_server::HeadlessProject::new(
  293            remote_server::HeadlessAppState {
  294                session: server_session,
  295                fs,
  296                http_client: Arc::new(http_client::BlockedHttpClient),
  297                node_runtime: node_runtime::NodeRuntime::unavailable(),
  298                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
  299                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
  300                startup_time: std::time::Instant::now(),
  301            },
  302            false,
  303            cx,
  304        )
  305    });
  306
  307    let remote_client = remote::RemoteClient::connect_mock(opts.clone(), cx).await;
  308    let project = cx.update(|cx| {
  309        let project_client = client::Client::new(
  310            Arc::new(clock::FakeSystemClock::new()),
  311            http_client::FakeHttpClient::with_404_response(),
  312            cx,
  313        );
  314        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
  315        project::Project::remote(
  316            remote_client,
  317            project_client,
  318            node_runtime::NodeRuntime::unavailable(),
  319            user_store,
  320            app_state.languages.clone(),
  321            app_state.fs.clone(),
  322            false,
  323            cx,
  324        )
  325    });
  326
  327    project
  328        .update(cx, |project, cx| {
  329            project.find_or_create_worktree(worktree_path, true, cx)
  330        })
  331        .await
  332        .expect("should open remote worktree");
  333    cx.run_until_parked();
  334
  335    (project, headless, opts)
  336}
  337
  338fn save_thread_metadata(
  339    session_id: acp::SessionId,
  340    title: Option<SharedString>,
  341    updated_at: DateTime<Utc>,
  342    created_at: Option<DateTime<Utc>>,
  343    interacted_at: Option<DateTime<Utc>>,
  344    project: &Entity<project::Project>,
  345    cx: &mut TestAppContext,
  346) {
  347    cx.update(|cx| {
  348        let worktree_paths = project.read(cx).worktree_paths(cx);
  349        let remote_connection = project.read(cx).remote_connection_options(cx);
  350        let thread_id = ThreadMetadataStore::global(cx)
  351            .read(cx)
  352            .entries()
  353            .find(|e| e.session_id.as_ref() == Some(&session_id))
  354            .map(|e| e.thread_id)
  355            .unwrap_or_else(ThreadId::new);
  356        let metadata = ThreadMetadata {
  357            thread_id,
  358            session_id: Some(session_id),
  359            agent_id: agent::ZED_AGENT_ID.clone(),
  360            title,
  361            updated_at,
  362            created_at,
  363            interacted_at,
  364            worktree_paths,
  365            archived: false,
  366            remote_connection,
  367        };
  368        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  369    });
  370    cx.run_until_parked();
  371}
  372
  373fn save_thread_metadata_with_main_paths(
  374    session_id: &str,
  375    title: &str,
  376    folder_paths: PathList,
  377    main_worktree_paths: PathList,
  378    updated_at: DateTime<Utc>,
  379    cx: &mut TestAppContext,
  380) {
  381    let session_id = acp::SessionId::new(Arc::from(session_id));
  382    let title = SharedString::from(title.to_string());
  383    let thread_id = cx.update(|cx| {
  384        ThreadMetadataStore::global(cx)
  385            .read(cx)
  386            .entries()
  387            .find(|e| e.session_id.as_ref() == Some(&session_id))
  388            .map(|e| e.thread_id)
  389            .unwrap_or_else(ThreadId::new)
  390    });
  391    let metadata = ThreadMetadata {
  392        thread_id,
  393        session_id: Some(session_id),
  394        agent_id: agent::ZED_AGENT_ID.clone(),
  395        title: Some(title),
  396        updated_at,
  397        created_at: None,
  398        interacted_at: None,
  399        worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
  400        archived: false,
  401        remote_connection: None,
  402    };
  403    cx.update(|cx| {
  404        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  405    });
  406    cx.run_until_parked();
  407}
  408
  409fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
  410    sidebar.update_in(cx, |_, window, cx| {
  411        cx.focus_self(window);
  412    });
  413    cx.run_until_parked();
  414}
  415
  416fn request_test_tool_authorization(
  417    thread: &Entity<AcpThread>,
  418    tool_call_id: &str,
  419    option_id: &str,
  420    cx: &mut gpui::VisualTestContext,
  421) {
  422    let tool_call_id = acp::ToolCallId::new(tool_call_id);
  423    let label = format!("Tool {tool_call_id}");
  424    let option_id = acp::PermissionOptionId::new(option_id);
  425    let _authorization_task = cx.update(|_, cx| {
  426        thread.update(cx, |thread, cx| {
  427            thread
  428                .request_tool_call_authorization(
  429                    acp::ToolCall::new(tool_call_id, label)
  430                        .kind(acp::ToolKind::Edit)
  431                        .into(),
  432                    PermissionOptions::Flat(vec![acp::PermissionOption::new(
  433                        option_id,
  434                        "Allow",
  435                        acp::PermissionOptionKind::AllowOnce,
  436                    )]),
  437                    cx,
  438                )
  439                .unwrap()
  440        })
  441    });
  442    cx.run_until_parked();
  443}
  444
  445fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
  446    let mut seen = Vec::new();
  447    let mut chips = Vec::new();
  448    for wt in worktrees {
  449        if wt.kind == ui::WorktreeKind::Main {
  450            continue;
  451        }
  452        let Some(name) = wt.worktree_name.as_ref() else {
  453            continue;
  454        };
  455        if !seen.contains(name) {
  456            seen.push(name.clone());
  457            chips.push(format!("{{{}}}", name));
  458        }
  459    }
  460    if chips.is_empty() {
  461        String::new()
  462    } else {
  463        format!(" {}", chips.join(", "))
  464    }
  465}
  466
  467fn visible_entries_as_strings(
  468    sidebar: &Entity<Sidebar>,
  469    cx: &mut gpui::VisualTestContext,
  470) -> Vec<String> {
  471    sidebar.read_with(cx, |sidebar, cx| {
  472        sidebar
  473            .contents
  474            .entries
  475            .iter()
  476            .enumerate()
  477            .map(|(ix, entry)| {
  478                let selected = if sidebar.selection == Some(ix) {
  479                    "  <== selected"
  480                } else {
  481                    ""
  482                };
  483                match entry {
  484                    ListEntry::ProjectHeader {
  485                        label,
  486                        key,
  487                        highlight_positions: _,
  488                        ..
  489                    } => {
  490                        let icon = if sidebar.is_group_collapsed(key, cx) {
  491                            ">"
  492                        } else {
  493                            "v"
  494                        };
  495                        format!("{} [{}]{}", icon, label, selected)
  496                    }
  497                    ListEntry::Thread(thread) => {
  498                        let title = thread.metadata.display_title();
  499                        let worktree = format_linked_worktree_chips(&thread.worktrees);
  500
  501                        {
  502                            let live = if thread.is_live { " *" } else { "" };
  503                            let status_str = match thread.status {
  504                                AgentThreadStatus::Running => " (running)",
  505                                AgentThreadStatus::Error => " (error)",
  506                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
  507                                _ => "",
  508                            };
  509                            let notified = if sidebar
  510                                .contents
  511                                .is_thread_notified(&thread.metadata.thread_id)
  512                            {
  513                                " (!)"
  514                            } else {
  515                                ""
  516                            };
  517                            format!("  {title}{worktree}{live}{status_str}{notified}{selected}")
  518                        }
  519                    }
  520                }
  521            })
  522            .collect()
  523    })
  524}
  525
  526#[gpui::test]
  527async fn test_serialization_round_trip(cx: &mut TestAppContext) {
  528    let project = init_test_project("/my-project", cx).await;
  529    let (multi_workspace, cx) =
  530        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  531    let sidebar = setup_sidebar(&multi_workspace, cx);
  532
  533    save_n_test_threads(3, &project, cx).await;
  534
  535    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  536
  537    // Set a custom width and collapse the group.
  538    sidebar.update_in(cx, |sidebar, window, cx| {
  539        sidebar.set_width(Some(px(420.0)), cx);
  540        sidebar.toggle_collapse(&project_group_key, window, cx);
  541    });
  542    cx.run_until_parked();
  543
  544    // Capture the serialized state from the first sidebar.
  545    let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
  546    let serialized = serialized.expect("serialized_state should return Some");
  547
  548    // Create a fresh sidebar and restore into it.
  549    let sidebar2 =
  550        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  551    cx.run_until_parked();
  552
  553    sidebar2.update_in(cx, |sidebar, window, cx| {
  554        sidebar.restore_serialized_state(&serialized, window, cx);
  555    });
  556    cx.run_until_parked();
  557
  558    // Assert all serialized fields match.
  559    let width1 = sidebar.read_with(cx, |s, _| s.width);
  560    let width2 = sidebar2.read_with(cx, |s, _| s.width);
  561
  562    assert_eq!(width1, width2);
  563    assert_eq!(width1, px(420.0));
  564}
  565
  566#[gpui::test]
  567async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
  568    // A regression test to ensure that restoring a serialized archive view does not panic.
  569    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  570    let (multi_workspace, cx) =
  571        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  572    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  573    cx.update(|_window, cx| {
  574        AgentRegistryStore::init_test_global(cx, vec![]);
  575    });
  576
  577    let serialized = serde_json::to_string(&SerializedSidebar {
  578        width: Some(400.0),
  579        active_view: SerializedSidebarView::History,
  580    })
  581    .expect("serialization should succeed");
  582
  583    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
  584        if let Some(sidebar) = multi_workspace.sidebar() {
  585            sidebar.restore_serialized_state(&serialized, window, cx);
  586        }
  587    });
  588    cx.run_until_parked();
  589
  590    // After the deferred `show_archive` runs, the view should be Archive.
  591    sidebar.read_with(cx, |sidebar, _cx| {
  592        assert!(
  593            matches!(sidebar.view, SidebarView::Archive(_)),
  594            "expected sidebar view to be Archive after restore, got ThreadList"
  595        );
  596    });
  597}
  598
  599#[gpui::test]
  600async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
  601    let project = init_test_project("/my-project", cx).await;
  602    let (multi_workspace, cx) =
  603        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  604    let sidebar = setup_sidebar(&multi_workspace, cx);
  605
  606    let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
  607    let weak_sidebar = sidebar.downgrade();
  608    let weak_multi_workspace = multi_workspace.downgrade();
  609
  610    drop(sidebar);
  611    drop(multi_workspace);
  612    cx.update(|window, _cx| window.remove_window());
  613    cx.run_until_parked();
  614
  615    weak_multi_workspace.assert_released();
  616    weak_sidebar.assert_released();
  617    weak_workspace.assert_released();
  618}
  619
  620#[gpui::test]
  621async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
  622    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  623    let (multi_workspace, cx) =
  624        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  625    let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  626
  627    assert_eq!(
  628        visible_entries_as_strings(&_sidebar, cx),
  629        vec!["v [my-project]"]
  630    );
  631}
  632
  633#[gpui::test]
  634async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
  635    let project = init_test_project("/my-project", cx).await;
  636    let (multi_workspace, cx) =
  637        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  638    let sidebar = setup_sidebar(&multi_workspace, cx);
  639
  640    save_thread_metadata(
  641        acp::SessionId::new(Arc::from("thread-1")),
  642        Some("Fix crash in project panel".into()),
  643        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
  644        None,
  645        None,
  646        &project,
  647        cx,
  648    );
  649
  650    save_thread_metadata(
  651        acp::SessionId::new(Arc::from("thread-2")),
  652        Some("Add inline diff view".into()),
  653        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
  654        None,
  655        None,
  656        &project,
  657        cx,
  658    );
  659    cx.run_until_parked();
  660
  661    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  662    cx.run_until_parked();
  663
  664    assert_eq!(
  665        visible_entries_as_strings(&sidebar, cx),
  666        vec![
  667            //
  668            "v [my-project]",
  669            "  Fix crash in project panel",
  670            "  Add inline diff view",
  671        ]
  672    );
  673}
  674
  675#[gpui::test]
  676async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
  677    let project = init_test_project("/project-a", cx).await;
  678    let (multi_workspace, cx) =
  679        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  680    let sidebar = setup_sidebar(&multi_workspace, cx);
  681
  682    // Single workspace with a thread
  683    save_thread_metadata(
  684        acp::SessionId::new(Arc::from("thread-a1")),
  685        Some("Thread A1".into()),
  686        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  687        None,
  688        None,
  689        &project,
  690        cx,
  691    );
  692    cx.run_until_parked();
  693
  694    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  695    cx.run_until_parked();
  696
  697    assert_eq!(
  698        visible_entries_as_strings(&sidebar, cx),
  699        vec![
  700            //
  701            "v [project-a]",
  702            "  Thread A1",
  703        ]
  704    );
  705
  706    // Add a second workspace
  707    multi_workspace.update_in(cx, |mw, window, cx| {
  708        mw.create_test_workspace(window, cx).detach();
  709    });
  710    cx.run_until_parked();
  711
  712    assert_eq!(
  713        visible_entries_as_strings(&sidebar, cx),
  714        vec![
  715            //
  716            "v [project-a]",
  717            "  Thread A1",
  718        ]
  719    );
  720}
  721
  722#[gpui::test]
  723async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
  724    let project = init_test_project("/my-project", cx).await;
  725    let (multi_workspace, cx) =
  726        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  727    let sidebar = setup_sidebar(&multi_workspace, cx);
  728
  729    save_n_test_threads(1, &project, cx).await;
  730
  731    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  732
  733    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  734    cx.run_until_parked();
  735
  736    assert_eq!(
  737        visible_entries_as_strings(&sidebar, cx),
  738        vec![
  739            //
  740            "v [my-project]",
  741            "  Thread 1",
  742        ]
  743    );
  744
  745    // Collapse
  746    sidebar.update_in(cx, |s, window, cx| {
  747        s.toggle_collapse(&project_group_key, window, cx);
  748    });
  749    cx.run_until_parked();
  750
  751    assert_eq!(
  752        visible_entries_as_strings(&sidebar, cx),
  753        vec![
  754            //
  755            "> [my-project]",
  756        ]
  757    );
  758
  759    // Expand
  760    sidebar.update_in(cx, |s, window, cx| {
  761        s.toggle_collapse(&project_group_key, window, cx);
  762    });
  763    cx.run_until_parked();
  764
  765    assert_eq!(
  766        visible_entries_as_strings(&sidebar, cx),
  767        vec![
  768            //
  769            "v [my-project]",
  770            "  Thread 1",
  771        ]
  772    );
  773}
  774
  775#[gpui::test]
  776async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
  777    // When a worktree is added to a project, the project group key changes.
  778    // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
  779    // UI state must survive the key change.
  780    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
  781    let (multi_workspace, cx) =
  782        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  783    let sidebar = setup_sidebar(&multi_workspace, cx);
  784
  785    save_n_test_threads(2, &project, cx).await;
  786    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  787    cx.run_until_parked();
  788
  789    assert_eq!(
  790        visible_entries_as_strings(&sidebar, cx),
  791        vec!["v [project-a]", "  Thread 2", "  Thread 1",]
  792    );
  793
  794    // Collapse the group.
  795    let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  796    sidebar.update_in(cx, |sidebar, window, cx| {
  797        sidebar.toggle_collapse(&old_key, window, cx);
  798    });
  799    cx.run_until_parked();
  800
  801    assert_eq!(
  802        visible_entries_as_strings(&sidebar, cx),
  803        vec!["> [project-a]"]
  804    );
  805
  806    // Add a second worktree — the key changes from [/project-a] to
  807    // [/project-a, /project-b].
  808    project
  809        .update(cx, |project, cx| {
  810            project.find_or_create_worktree("/project-b", true, cx)
  811        })
  812        .await
  813        .expect("should add worktree");
  814    cx.run_until_parked();
  815
  816    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  817    cx.run_until_parked();
  818
  819    // The group should still be collapsed under the new key.
  820    assert_eq!(
  821        visible_entries_as_strings(&sidebar, cx),
  822        vec!["> [project-a, project-b]"]
  823    );
  824}
  825
  826#[gpui::test]
  827async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
  828    use workspace::ProjectGroup;
  829
  830    let project = init_test_project("/my-project", cx).await;
  831    let (multi_workspace, cx) =
  832        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  833    let sidebar = setup_sidebar(&multi_workspace, cx);
  834
  835    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
  836    let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
  837    let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
  838
  839    // Set the collapsed group state through multi_workspace
  840    multi_workspace.update(cx, |mw, _cx| {
  841        mw.test_add_project_group(ProjectGroup {
  842            key: ProjectGroupKey::new(None, collapsed_path.clone()),
  843            workspaces: Vec::new(),
  844            expanded: false,
  845        });
  846    });
  847
  848    sidebar.update_in(cx, |s, _window, _cx| {
  849        let notified_thread_id = ThreadId::new();
  850        s.contents.notified_threads.insert(notified_thread_id);
  851        s.contents.entries = vec![
  852            // Expanded project header
  853            ListEntry::ProjectHeader {
  854                key: ProjectGroupKey::new(None, expanded_path.clone()),
  855                label: "expanded-project".into(),
  856                highlight_positions: Vec::new(),
  857                has_running_threads: false,
  858                waiting_thread_count: 0,
  859                is_active: true,
  860                has_threads: true,
  861            },
  862            ListEntry::Thread(ThreadEntry {
  863                metadata: ThreadMetadata {
  864                    thread_id: ThreadId::new(),
  865                    session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
  866                    agent_id: AgentId::new("zed-agent"),
  867                    worktree_paths: WorktreePaths::default(),
  868                    title: Some("Completed thread".into()),
  869                    updated_at: Utc::now(),
  870                    created_at: Some(Utc::now()),
  871                    interacted_at: None,
  872                    archived: false,
  873                    remote_connection: None,
  874                },
  875                icon: IconName::ZedAgent,
  876                icon_from_external_svg: None,
  877                status: AgentThreadStatus::Completed,
  878                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  879                is_live: false,
  880                is_background: false,
  881                is_title_generating: false,
  882                highlight_positions: Vec::new(),
  883                worktrees: Vec::new(),
  884                diff_stats: DiffStats::default(),
  885            }),
  886            // Active thread with Running status
  887            ListEntry::Thread(ThreadEntry {
  888                metadata: ThreadMetadata {
  889                    thread_id: ThreadId::new(),
  890                    session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
  891                    agent_id: AgentId::new("zed-agent"),
  892                    worktree_paths: WorktreePaths::default(),
  893                    title: Some("Running thread".into()),
  894                    updated_at: Utc::now(),
  895                    created_at: Some(Utc::now()),
  896                    interacted_at: None,
  897                    archived: false,
  898                    remote_connection: None,
  899                },
  900                icon: IconName::ZedAgent,
  901                icon_from_external_svg: None,
  902                status: AgentThreadStatus::Running,
  903                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  904                is_live: true,
  905                is_background: false,
  906                is_title_generating: false,
  907                highlight_positions: Vec::new(),
  908                worktrees: Vec::new(),
  909                diff_stats: DiffStats::default(),
  910            }),
  911            // Active thread with Error status
  912            ListEntry::Thread(ThreadEntry {
  913                metadata: ThreadMetadata {
  914                    thread_id: ThreadId::new(),
  915                    session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
  916                    agent_id: AgentId::new("zed-agent"),
  917                    worktree_paths: WorktreePaths::default(),
  918                    title: Some("Error thread".into()),
  919                    updated_at: Utc::now(),
  920                    created_at: Some(Utc::now()),
  921                    interacted_at: None,
  922                    archived: false,
  923                    remote_connection: None,
  924                },
  925                icon: IconName::ZedAgent,
  926                icon_from_external_svg: None,
  927                status: AgentThreadStatus::Error,
  928                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  929                is_live: true,
  930                is_background: false,
  931                is_title_generating: false,
  932                highlight_positions: Vec::new(),
  933                worktrees: Vec::new(),
  934                diff_stats: DiffStats::default(),
  935            }),
  936            // Thread with WaitingForConfirmation status, not active
  937            // remote_connection: None,
  938            ListEntry::Thread(ThreadEntry {
  939                metadata: ThreadMetadata {
  940                    thread_id: ThreadId::new(),
  941                    session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
  942                    agent_id: AgentId::new("zed-agent"),
  943                    worktree_paths: WorktreePaths::default(),
  944                    title: Some("Waiting thread".into()),
  945                    updated_at: Utc::now(),
  946                    created_at: Some(Utc::now()),
  947                    interacted_at: None,
  948                    archived: false,
  949                    remote_connection: None,
  950                },
  951                icon: IconName::ZedAgent,
  952                icon_from_external_svg: None,
  953                status: AgentThreadStatus::WaitingForConfirmation,
  954                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  955                is_live: false,
  956                is_background: false,
  957                is_title_generating: false,
  958                highlight_positions: Vec::new(),
  959                worktrees: Vec::new(),
  960                diff_stats: DiffStats::default(),
  961            }),
  962            // Background thread that completed (should show notification)
  963            // remote_connection: None,
  964            ListEntry::Thread(ThreadEntry {
  965                metadata: ThreadMetadata {
  966                    thread_id: notified_thread_id,
  967                    session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
  968                    agent_id: AgentId::new("zed-agent"),
  969                    worktree_paths: WorktreePaths::default(),
  970                    title: Some("Notified thread".into()),
  971                    updated_at: Utc::now(),
  972                    created_at: Some(Utc::now()),
  973                    interacted_at: None,
  974                    archived: false,
  975                    remote_connection: None,
  976                },
  977                icon: IconName::ZedAgent,
  978                icon_from_external_svg: None,
  979                status: AgentThreadStatus::Completed,
  980                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  981                is_live: true,
  982                is_background: true,
  983                is_title_generating: false,
  984                highlight_positions: Vec::new(),
  985                worktrees: Vec::new(),
  986                diff_stats: DiffStats::default(),
  987            }),
  988            // Collapsed project header
  989            ListEntry::ProjectHeader {
  990                key: ProjectGroupKey::new(None, collapsed_path.clone()),
  991                label: "collapsed-project".into(),
  992                highlight_positions: Vec::new(),
  993                has_running_threads: false,
  994                waiting_thread_count: 0,
  995                is_active: false,
  996                has_threads: false,
  997            },
  998        ];
  999
 1000        // Select the Running thread (index 2)
 1001        s.selection = Some(2);
 1002    });
 1003
 1004    assert_eq!(
 1005        visible_entries_as_strings(&sidebar, cx),
 1006        vec![
 1007            //
 1008            "v [expanded-project]",
 1009            "  Completed thread",
 1010            "  Running thread * (running)  <== selected",
 1011            "  Error thread * (error)",
 1012            "  Waiting thread (waiting)",
 1013            "  Notified thread * (!)",
 1014            "> [collapsed-project]",
 1015        ]
 1016    );
 1017
 1018    // Move selection to the collapsed header
 1019    sidebar.update_in(cx, |s, _window, _cx| {
 1020        s.selection = Some(6);
 1021    });
 1022
 1023    assert_eq!(
 1024        visible_entries_as_strings(&sidebar, cx).last().cloned(),
 1025        Some("> [collapsed-project]  <== selected".to_string()),
 1026    );
 1027
 1028    // Clear selection
 1029    sidebar.update_in(cx, |s, _window, _cx| {
 1030        s.selection = None;
 1031    });
 1032
 1033    // No entry should have the selected marker
 1034    let entries = visible_entries_as_strings(&sidebar, cx);
 1035    for entry in &entries {
 1036        assert!(
 1037            !entry.contains("<== selected"),
 1038            "unexpected selection marker in: {}",
 1039            entry
 1040        );
 1041    }
 1042}
 1043
 1044#[gpui::test]
 1045async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
 1046    let project = init_test_project("/my-project", cx).await;
 1047    let (multi_workspace, cx) =
 1048        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1049    let sidebar = setup_sidebar(&multi_workspace, cx);
 1050
 1051    save_n_test_threads(3, &project, cx).await;
 1052
 1053    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1054    cx.run_until_parked();
 1055
 1056    // Entries: [header, thread3, thread2, thread1]
 1057    // Focusing the sidebar does not set a selection; select_next/select_previous
 1058    // handle None gracefully by starting from the first or last entry.
 1059    focus_sidebar(&sidebar, cx);
 1060    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1061
 1062    // First SelectNext from None starts at index 0
 1063    cx.dispatch_action(SelectNext);
 1064    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1065
 1066    // Move down through remaining entries
 1067    cx.dispatch_action(SelectNext);
 1068    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1069
 1070    cx.dispatch_action(SelectNext);
 1071    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1072
 1073    cx.dispatch_action(SelectNext);
 1074    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1075
 1076    // At the end, wraps back to first entry
 1077    cx.dispatch_action(SelectNext);
 1078    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1079
 1080    // Navigate back to the end
 1081    cx.dispatch_action(SelectNext);
 1082    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1083    cx.dispatch_action(SelectNext);
 1084    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1085    cx.dispatch_action(SelectNext);
 1086    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1087
 1088    // Move back up
 1089    cx.dispatch_action(SelectPrevious);
 1090    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1091
 1092    cx.dispatch_action(SelectPrevious);
 1093    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1094
 1095    cx.dispatch_action(SelectPrevious);
 1096    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1097
 1098    // At the top, selection clears (focus returns to editor)
 1099    cx.dispatch_action(SelectPrevious);
 1100    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1101}
 1102
 1103#[gpui::test]
 1104async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
 1105    let project = init_test_project("/my-project", cx).await;
 1106    let (multi_workspace, cx) =
 1107        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1108    let sidebar = setup_sidebar(&multi_workspace, cx);
 1109
 1110    save_n_test_threads(3, &project, cx).await;
 1111    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1112    cx.run_until_parked();
 1113
 1114    focus_sidebar(&sidebar, cx);
 1115
 1116    // SelectLast jumps to the end
 1117    cx.dispatch_action(SelectLast);
 1118    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1119
 1120    // SelectFirst jumps to the beginning
 1121    cx.dispatch_action(SelectFirst);
 1122    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1123}
 1124
 1125#[gpui::test]
 1126async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
 1127    let project = init_test_project("/my-project", cx).await;
 1128    let (multi_workspace, cx) =
 1129        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1130    let sidebar = setup_sidebar(&multi_workspace, cx);
 1131
 1132    // Initially no selection
 1133    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1134
 1135    // Open the sidebar so it's rendered, then focus it to trigger focus_in.
 1136    // focus_in no longer sets a default selection.
 1137    focus_sidebar(&sidebar, cx);
 1138    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1139
 1140    // Manually set a selection, blur, then refocus — selection should be preserved
 1141    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1142        sidebar.selection = Some(0);
 1143    });
 1144
 1145    cx.update(|window, _cx| {
 1146        window.blur();
 1147    });
 1148    cx.run_until_parked();
 1149
 1150    sidebar.update_in(cx, |_, window, cx| {
 1151        cx.focus_self(window);
 1152    });
 1153    cx.run_until_parked();
 1154    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1155}
 1156
 1157#[gpui::test]
 1158async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
 1159    let project = init_test_project("/my-project", cx).await;
 1160    let (multi_workspace, cx) =
 1161        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1162    let sidebar = setup_sidebar(&multi_workspace, cx);
 1163
 1164    save_n_test_threads(1, &project, cx).await;
 1165    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1166    cx.run_until_parked();
 1167
 1168    assert_eq!(
 1169        visible_entries_as_strings(&sidebar, cx),
 1170        vec![
 1171            //
 1172            "v [my-project]",
 1173            "  Thread 1",
 1174        ]
 1175    );
 1176
 1177    // Focus the sidebar and select the header
 1178    focus_sidebar(&sidebar, cx);
 1179    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1180        sidebar.selection = Some(0);
 1181    });
 1182
 1183    // Confirm on project header collapses the group
 1184    cx.dispatch_action(Confirm);
 1185    cx.run_until_parked();
 1186
 1187    assert_eq!(
 1188        visible_entries_as_strings(&sidebar, cx),
 1189        vec![
 1190            //
 1191            "> [my-project]  <== selected",
 1192        ]
 1193    );
 1194
 1195    // Confirm again expands the group
 1196    cx.dispatch_action(Confirm);
 1197    cx.run_until_parked();
 1198
 1199    assert_eq!(
 1200        visible_entries_as_strings(&sidebar, cx),
 1201        vec![
 1202            //
 1203            "v [my-project]  <== selected",
 1204            "  Thread 1",
 1205        ]
 1206    );
 1207}
 1208
 1209#[gpui::test]
 1210async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
 1211    let project = init_test_project("/my-project", cx).await;
 1212    let (multi_workspace, cx) =
 1213        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1214    let sidebar = setup_sidebar(&multi_workspace, cx);
 1215
 1216    save_n_test_threads(1, &project, cx).await;
 1217    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1218    cx.run_until_parked();
 1219
 1220    assert_eq!(
 1221        visible_entries_as_strings(&sidebar, cx),
 1222        vec![
 1223            //
 1224            "v [my-project]",
 1225            "  Thread 1",
 1226        ]
 1227    );
 1228
 1229    // Focus sidebar and manually select the header (index 0). Press left to collapse.
 1230    focus_sidebar(&sidebar, cx);
 1231    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1232        sidebar.selection = Some(0);
 1233    });
 1234
 1235    cx.dispatch_action(SelectParent);
 1236    cx.run_until_parked();
 1237
 1238    assert_eq!(
 1239        visible_entries_as_strings(&sidebar, cx),
 1240        vec![
 1241            //
 1242            "> [my-project]  <== selected",
 1243        ]
 1244    );
 1245
 1246    // Press right to expand
 1247    cx.dispatch_action(SelectChild);
 1248    cx.run_until_parked();
 1249
 1250    assert_eq!(
 1251        visible_entries_as_strings(&sidebar, cx),
 1252        vec![
 1253            //
 1254            "v [my-project]  <== selected",
 1255            "  Thread 1",
 1256        ]
 1257    );
 1258
 1259    // Press right again on already-expanded header moves selection down
 1260    cx.dispatch_action(SelectChild);
 1261    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1262}
 1263
 1264#[gpui::test]
 1265async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
 1266    let project = init_test_project("/my-project", cx).await;
 1267    let (multi_workspace, cx) =
 1268        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1269    let sidebar = setup_sidebar(&multi_workspace, cx);
 1270
 1271    save_n_test_threads(1, &project, cx).await;
 1272    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1273    cx.run_until_parked();
 1274
 1275    // Focus sidebar (selection starts at None), then navigate down to the thread (child)
 1276    focus_sidebar(&sidebar, cx);
 1277    cx.dispatch_action(SelectNext);
 1278    cx.dispatch_action(SelectNext);
 1279    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1280
 1281    assert_eq!(
 1282        visible_entries_as_strings(&sidebar, cx),
 1283        vec![
 1284            //
 1285            "v [my-project]",
 1286            "  Thread 1  <== selected",
 1287        ]
 1288    );
 1289
 1290    // Pressing left on a child collapses the parent group and selects it
 1291    cx.dispatch_action(SelectParent);
 1292    cx.run_until_parked();
 1293
 1294    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1295    assert_eq!(
 1296        visible_entries_as_strings(&sidebar, cx),
 1297        vec![
 1298            //
 1299            "> [my-project]  <== selected",
 1300        ]
 1301    );
 1302}
 1303
 1304#[gpui::test]
 1305async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
 1306    let project = init_test_project_with_agent_panel("/empty-project", cx).await;
 1307    let (multi_workspace, cx) =
 1308        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1309    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1310
 1311    // An empty project has only the header (no auto-created draft).
 1312    assert_eq!(
 1313        visible_entries_as_strings(&sidebar, cx),
 1314        vec!["v [empty-project]"]
 1315    );
 1316
 1317    // Focus sidebar — focus_in does not set a selection
 1318    focus_sidebar(&sidebar, cx);
 1319    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1320
 1321    // First SelectNext from None starts at index 0 (header)
 1322    cx.dispatch_action(SelectNext);
 1323    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1324
 1325    // SelectNext with only one entry stays at index 0
 1326    cx.dispatch_action(SelectNext);
 1327    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1328
 1329    // SelectPrevious from first entry clears selection (returns to editor)
 1330    cx.dispatch_action(SelectPrevious);
 1331    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1332
 1333    // SelectPrevious from None selects the last entry
 1334    cx.dispatch_action(SelectPrevious);
 1335    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1336}
 1337
 1338#[gpui::test]
 1339async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
 1340    let project = init_test_project("/my-project", cx).await;
 1341    let (multi_workspace, cx) =
 1342        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1343    let sidebar = setup_sidebar(&multi_workspace, cx);
 1344
 1345    save_n_test_threads(1, &project, cx).await;
 1346    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1347    cx.run_until_parked();
 1348
 1349    // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
 1350    focus_sidebar(&sidebar, cx);
 1351    cx.dispatch_action(SelectNext);
 1352    cx.dispatch_action(SelectNext);
 1353    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1354
 1355    // Collapse the group, which removes the thread from the list
 1356    cx.dispatch_action(SelectParent);
 1357    cx.run_until_parked();
 1358
 1359    // Selection should be clamped to the last valid index (0 = header)
 1360    let selection = sidebar.read_with(cx, |s, _| s.selection);
 1361    let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
 1362    assert!(
 1363        selection.unwrap_or(0) < entry_count,
 1364        "selection {} should be within bounds (entries: {})",
 1365        selection.unwrap_or(0),
 1366        entry_count,
 1367    );
 1368}
 1369
 1370async fn init_test_project_with_agent_panel(
 1371    worktree_path: &str,
 1372    cx: &mut TestAppContext,
 1373) -> Entity<project::Project> {
 1374    agent_ui::test_support::init_test(cx);
 1375    cx.update(|cx| {
 1376        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 1377        ThreadStore::init_global(cx);
 1378        ThreadMetadataStore::init_global(cx);
 1379        language_model::LanguageModelRegistry::test(cx);
 1380        prompt_store::init(cx);
 1381    });
 1382
 1383    let fs = FakeFs::new(cx.executor());
 1384    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
 1385        .await;
 1386    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 1387    project::Project::test(fs, [worktree_path.as_ref()], cx).await
 1388}
 1389
 1390fn add_agent_panel(
 1391    workspace: &Entity<Workspace>,
 1392    cx: &mut gpui::VisualTestContext,
 1393) -> Entity<AgentPanel> {
 1394    workspace.update_in(cx, |workspace, window, cx| {
 1395        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 1396        workspace.add_panel(panel.clone(), window, cx);
 1397        panel
 1398    })
 1399}
 1400
 1401fn setup_sidebar_with_agent_panel(
 1402    multi_workspace: &Entity<MultiWorkspace>,
 1403    cx: &mut gpui::VisualTestContext,
 1404) -> (Entity<Sidebar>, Entity<AgentPanel>) {
 1405    let sidebar = setup_sidebar(multi_workspace, cx);
 1406    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 1407    let panel = add_agent_panel(&workspace, cx);
 1408    (sidebar, panel)
 1409}
 1410
 1411#[gpui::test]
 1412async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
 1413    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1414    let (multi_workspace, cx) =
 1415        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1416    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1417
 1418    // Open thread A and keep it generating.
 1419    let connection = StubAgentConnection::new();
 1420    open_thread_with_connection(&panel, connection.clone(), cx);
 1421    send_message(&panel, cx);
 1422
 1423    let session_id_a = active_session_id(&panel, cx);
 1424    save_test_thread_metadata(&session_id_a, &project, cx).await;
 1425
 1426    cx.update(|_, cx| {
 1427        connection.send_update(
 1428            session_id_a.clone(),
 1429            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 1430            cx,
 1431        );
 1432    });
 1433    cx.run_until_parked();
 1434
 1435    // Open thread B (idle, default response) — thread A goes to background.
 1436    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1437        acp::ContentChunk::new("Done".into()),
 1438    )]);
 1439    open_thread_with_connection(&panel, connection, cx);
 1440    send_message(&panel, cx);
 1441
 1442    let session_id_b = active_session_id(&panel, cx);
 1443    save_test_thread_metadata(&session_id_b, &project, cx).await;
 1444
 1445    cx.run_until_parked();
 1446
 1447    let mut entries = visible_entries_as_strings(&sidebar, cx);
 1448    entries[1..].sort();
 1449    assert_eq!(
 1450        entries,
 1451        vec![
 1452            //
 1453            "v [my-project]",
 1454            "  Hello *",
 1455            "  Hello * (running)",
 1456        ]
 1457    );
 1458}
 1459
 1460#[gpui::test]
 1461async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
 1462    cx: &mut TestAppContext,
 1463) {
 1464    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1465    let (multi_workspace, cx) =
 1466        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1467    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1468
 1469    let connection = StubAgentConnection::new().with_supports_load_session(true);
 1470    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1471        acp::ContentChunk::new("Done".into()),
 1472    )]);
 1473    open_thread_with_connection(&panel, connection, cx);
 1474    send_message(&panel, cx);
 1475
 1476    let parent_session_id = active_session_id(&panel, cx);
 1477    save_test_thread_metadata(&parent_session_id, &project, cx).await;
 1478
 1479    let subagent_session_id = acp::SessionId::new("subagent-session");
 1480    cx.update(|_, cx| {
 1481        let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
 1482        parent_thread.update(cx, |thread: &mut AcpThread, cx| {
 1483            thread.subagent_spawned(subagent_session_id.clone(), cx);
 1484        });
 1485    });
 1486    cx.run_until_parked();
 1487
 1488    let subagent_thread = panel.read_with(cx, |panel, cx| {
 1489        panel
 1490            .active_conversation_view()
 1491            .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id))
 1492            .map(|thread_view| thread_view.read(cx).thread.clone())
 1493            .expect("Expected subagent thread to be loaded into the conversation")
 1494    });
 1495    request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
 1496
 1497    let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
 1498        sidebar
 1499            .contents
 1500            .entries
 1501            .iter()
 1502            .find_map(|entry| match entry {
 1503                ListEntry::Thread(thread)
 1504                    if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
 1505                {
 1506                    Some(thread.status)
 1507                }
 1508                _ => None,
 1509            })
 1510            .expect("Expected parent thread entry in sidebar")
 1511    });
 1512
 1513    assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
 1514}
 1515
 1516#[gpui::test]
 1517async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
 1518    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 1519    let (multi_workspace, cx) =
 1520        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1521    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1522
 1523    // Open thread on workspace A and keep it generating.
 1524    let connection_a = StubAgentConnection::new();
 1525    open_thread_with_connection(&panel_a, connection_a.clone(), cx);
 1526    send_message(&panel_a, cx);
 1527
 1528    let session_id_a = active_session_id(&panel_a, cx);
 1529    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 1530
 1531    cx.update(|_, cx| {
 1532        connection_a.send_update(
 1533            session_id_a.clone(),
 1534            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
 1535            cx,
 1536        );
 1537    });
 1538    cx.run_until_parked();
 1539
 1540    // Add a second workspace and activate it (making workspace A the background).
 1541    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 1542    let project_b = project::Project::test(fs, [], cx).await;
 1543    multi_workspace.update_in(cx, |mw, window, cx| {
 1544        mw.test_add_workspace(project_b, window, cx);
 1545    });
 1546    cx.run_until_parked();
 1547
 1548    // Thread A is still running; no notification yet.
 1549    assert_eq!(
 1550        visible_entries_as_strings(&sidebar, cx),
 1551        vec![
 1552            //
 1553            "v [project-a]",
 1554            "  Hello * (running)",
 1555        ]
 1556    );
 1557
 1558    // Complete thread A's turn (transition Running → Completed).
 1559    connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
 1560    cx.run_until_parked();
 1561
 1562    // The completed background thread shows a notification indicator.
 1563    assert_eq!(
 1564        visible_entries_as_strings(&sidebar, cx),
 1565        vec![
 1566            //
 1567            "v [project-a]",
 1568            "  Hello * (!)",
 1569        ]
 1570    );
 1571}
 1572
 1573fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
 1574    sidebar.update_in(cx, |sidebar, window, cx| {
 1575        window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
 1576        sidebar.filter_editor.update(cx, |editor, cx| {
 1577            editor.set_text(query, window, cx);
 1578        });
 1579    });
 1580    cx.run_until_parked();
 1581}
 1582
 1583#[gpui::test]
 1584async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
 1585    let project = init_test_project("/my-project", cx).await;
 1586    let (multi_workspace, cx) =
 1587        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1588    let sidebar = setup_sidebar(&multi_workspace, cx);
 1589
 1590    for (id, title, hour) in [
 1591        ("t-1", "Fix crash in project panel", 3),
 1592        ("t-2", "Add inline diff view", 2),
 1593        ("t-3", "Refactor settings module", 1),
 1594    ] {
 1595        save_thread_metadata(
 1596            acp::SessionId::new(Arc::from(id)),
 1597            Some(title.into()),
 1598            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1599            None,
 1600            None,
 1601            &project,
 1602            cx,
 1603        );
 1604    }
 1605    cx.run_until_parked();
 1606
 1607    assert_eq!(
 1608        visible_entries_as_strings(&sidebar, cx),
 1609        vec![
 1610            //
 1611            "v [my-project]",
 1612            "  Fix crash in project panel",
 1613            "  Add inline diff view",
 1614            "  Refactor settings module",
 1615        ]
 1616    );
 1617
 1618    // User types "diff" in the search box — only the matching thread remains,
 1619    // with its workspace header preserved for context.
 1620    type_in_search(&sidebar, "diff", cx);
 1621    assert_eq!(
 1622        visible_entries_as_strings(&sidebar, cx),
 1623        vec![
 1624            //
 1625            "v [my-project]",
 1626            "  Add inline diff view  <== selected",
 1627        ]
 1628    );
 1629
 1630    // User changes query to something with no matches — list is empty.
 1631    type_in_search(&sidebar, "nonexistent", cx);
 1632    assert_eq!(
 1633        visible_entries_as_strings(&sidebar, cx),
 1634        Vec::<String>::new()
 1635    );
 1636}
 1637
 1638#[gpui::test]
 1639async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
 1640    // Scenario: A user remembers a thread title but not the exact casing.
 1641    // Search should match case-insensitively so they can still find it.
 1642    let project = init_test_project("/my-project", cx).await;
 1643    let (multi_workspace, cx) =
 1644        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1645    let sidebar = setup_sidebar(&multi_workspace, cx);
 1646
 1647    save_thread_metadata(
 1648        acp::SessionId::new(Arc::from("thread-1")),
 1649        Some("Fix Crash In Project Panel".into()),
 1650        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 1651        None,
 1652        None,
 1653        &project,
 1654        cx,
 1655    );
 1656    cx.run_until_parked();
 1657
 1658    // Lowercase query matches mixed-case title.
 1659    type_in_search(&sidebar, "fix crash", cx);
 1660    assert_eq!(
 1661        visible_entries_as_strings(&sidebar, cx),
 1662        vec![
 1663            //
 1664            "v [my-project]",
 1665            "  Fix Crash In Project Panel  <== selected",
 1666        ]
 1667    );
 1668
 1669    // Uppercase query also matches the same title.
 1670    type_in_search(&sidebar, "FIX CRASH", cx);
 1671    assert_eq!(
 1672        visible_entries_as_strings(&sidebar, cx),
 1673        vec![
 1674            //
 1675            "v [my-project]",
 1676            "  Fix Crash In Project Panel  <== selected",
 1677        ]
 1678    );
 1679}
 1680
 1681#[gpui::test]
 1682async fn test_escape_from_search_focuses_first_thread(cx: &mut TestAppContext) {
 1683    // Scenario: A user searches, finds what they need, then presses Escape
 1684    // in the search field to hand keyboard control back to the thread list.
 1685    let project = init_test_project("/my-project", cx).await;
 1686    let (multi_workspace, cx) =
 1687        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1688    let sidebar = setup_sidebar(&multi_workspace, cx);
 1689
 1690    for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
 1691        save_thread_metadata(
 1692            acp::SessionId::new(Arc::from(id)),
 1693            Some(title.into()),
 1694            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1695            None,
 1696            None,
 1697            &project,
 1698            cx,
 1699        )
 1700    }
 1701    cx.run_until_parked();
 1702
 1703    // Confirm the full list is showing.
 1704    assert_eq!(
 1705        visible_entries_as_strings(&sidebar, cx),
 1706        vec![
 1707            //
 1708            "v [my-project]",
 1709            "  Alpha thread",
 1710            "  Beta thread",
 1711        ]
 1712    );
 1713
 1714    // User types a search query to filter down.
 1715    focus_sidebar(&sidebar, cx);
 1716    type_in_search(&sidebar, "alpha", cx);
 1717    assert_eq!(
 1718        visible_entries_as_strings(&sidebar, cx),
 1719        vec![
 1720            //
 1721            "v [my-project]",
 1722            "  Alpha thread  <== selected",
 1723        ]
 1724    );
 1725
 1726    // First Escape clears the search text, restoring the full list.
 1727    // Focus stays on the filter editor.
 1728    cx.dispatch_action(Cancel);
 1729    cx.run_until_parked();
 1730    assert_eq!(
 1731        visible_entries_as_strings(&sidebar, cx),
 1732        vec![
 1733            //
 1734            "v [my-project]",
 1735            "  Alpha thread",
 1736            "  Beta thread",
 1737        ]
 1738    );
 1739    sidebar.update_in(cx, |sidebar, window, cx| {
 1740        assert!(sidebar.filter_editor.read(cx).is_focused(window));
 1741        assert!(!sidebar.focus_handle.is_focused(window));
 1742    });
 1743
 1744    // Second Escape moves focus from the empty search field to the thread list.
 1745    cx.dispatch_action(Cancel);
 1746    cx.run_until_parked();
 1747    sidebar.update_in(cx, |sidebar, window, cx| {
 1748        assert_eq!(sidebar.selection, Some(1));
 1749        assert!(sidebar.focus_handle.is_focused(window));
 1750        assert!(!sidebar.filter_editor.read(cx).is_focused(window));
 1751    });
 1752}
 1753
 1754#[gpui::test]
 1755async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
 1756    let project_a = init_test_project("/project-a", cx).await;
 1757    let (multi_workspace, cx) =
 1758        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1759    let sidebar = setup_sidebar(&multi_workspace, cx);
 1760
 1761    for (id, title, hour) in [
 1762        ("a1", "Fix bug in sidebar", 2),
 1763        ("a2", "Add tests for editor", 1),
 1764    ] {
 1765        save_thread_metadata(
 1766            acp::SessionId::new(Arc::from(id)),
 1767            Some(title.into()),
 1768            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1769            None,
 1770            None,
 1771            &project_a,
 1772            cx,
 1773        )
 1774    }
 1775
 1776    // Add a second workspace.
 1777    multi_workspace.update_in(cx, |mw, window, cx| {
 1778        mw.create_test_workspace(window, cx).detach();
 1779    });
 1780    cx.run_until_parked();
 1781
 1782    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 1783        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 1784    });
 1785
 1786    for (id, title, hour) in [
 1787        ("b1", "Refactor sidebar layout", 3),
 1788        ("b2", "Fix typo in README", 1),
 1789    ] {
 1790        save_thread_metadata(
 1791            acp::SessionId::new(Arc::from(id)),
 1792            Some(title.into()),
 1793            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1794            None,
 1795            None,
 1796            &project_b,
 1797            cx,
 1798        )
 1799    }
 1800    cx.run_until_parked();
 1801
 1802    assert_eq!(
 1803        visible_entries_as_strings(&sidebar, cx),
 1804        vec![
 1805            //
 1806            "v [project-a]",
 1807            "  Fix bug in sidebar",
 1808            "  Add tests for editor",
 1809        ]
 1810    );
 1811
 1812    // "sidebar" matches a thread in each workspace — both headers stay visible.
 1813    type_in_search(&sidebar, "sidebar", cx);
 1814    assert_eq!(
 1815        visible_entries_as_strings(&sidebar, cx),
 1816        vec![
 1817            //
 1818            "v [project-a]",
 1819            "  Fix bug in sidebar  <== selected",
 1820        ]
 1821    );
 1822
 1823    // "typo" only matches in the second workspace — the first header disappears.
 1824    type_in_search(&sidebar, "typo", cx);
 1825    assert_eq!(
 1826        visible_entries_as_strings(&sidebar, cx),
 1827        Vec::<String>::new()
 1828    );
 1829
 1830    // "project-a" matches the first workspace name — the header appears
 1831    // with all child threads included.
 1832    type_in_search(&sidebar, "project-a", cx);
 1833    assert_eq!(
 1834        visible_entries_as_strings(&sidebar, cx),
 1835        vec![
 1836            //
 1837            "v [project-a]",
 1838            "  Fix bug in sidebar  <== selected",
 1839            "  Add tests for editor",
 1840        ]
 1841    );
 1842}
 1843
 1844#[gpui::test]
 1845async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
 1846    let project_a = init_test_project("/alpha-project", cx).await;
 1847    let (multi_workspace, cx) =
 1848        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1849    let sidebar = setup_sidebar(&multi_workspace, cx);
 1850
 1851    for (id, title, hour) in [
 1852        ("a1", "Fix bug in sidebar", 2),
 1853        ("a2", "Add tests for editor", 1),
 1854    ] {
 1855        save_thread_metadata(
 1856            acp::SessionId::new(Arc::from(id)),
 1857            Some(title.into()),
 1858            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1859            None,
 1860            None,
 1861            &project_a,
 1862            cx,
 1863        )
 1864    }
 1865
 1866    // Add a second workspace.
 1867    multi_workspace.update_in(cx, |mw, window, cx| {
 1868        mw.create_test_workspace(window, cx).detach();
 1869    });
 1870    cx.run_until_parked();
 1871
 1872    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 1873        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 1874    });
 1875
 1876    for (id, title, hour) in [
 1877        ("b1", "Refactor sidebar layout", 3),
 1878        ("b2", "Fix typo in README", 1),
 1879    ] {
 1880        save_thread_metadata(
 1881            acp::SessionId::new(Arc::from(id)),
 1882            Some(title.into()),
 1883            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1884            None,
 1885            None,
 1886            &project_b,
 1887            cx,
 1888        )
 1889    }
 1890    cx.run_until_parked();
 1891
 1892    // "alpha" matches the workspace name "alpha-project" but no thread titles.
 1893    // The workspace header should appear with all child threads included.
 1894    type_in_search(&sidebar, "alpha", cx);
 1895    assert_eq!(
 1896        visible_entries_as_strings(&sidebar, cx),
 1897        vec![
 1898            //
 1899            "v [alpha-project]",
 1900            "  Fix bug in sidebar  <== selected",
 1901            "  Add tests for editor",
 1902        ]
 1903    );
 1904
 1905    // "sidebar" matches thread titles in both workspaces but not workspace names.
 1906    // Both headers appear with their matching threads.
 1907    type_in_search(&sidebar, "sidebar", cx);
 1908    assert_eq!(
 1909        visible_entries_as_strings(&sidebar, cx),
 1910        vec![
 1911            //
 1912            "v [alpha-project]",
 1913            "  Fix bug in sidebar  <== selected",
 1914        ]
 1915    );
 1916
 1917    // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
 1918    // doesn't match) — but does not match either workspace name or any thread.
 1919    // Actually let's test something simpler: a query that matches both a workspace
 1920    // name AND some threads in that workspace. Matching threads should still appear.
 1921    type_in_search(&sidebar, "fix", cx);
 1922    assert_eq!(
 1923        visible_entries_as_strings(&sidebar, cx),
 1924        vec![
 1925            //
 1926            "v [alpha-project]",
 1927            "  Fix bug in sidebar  <== selected",
 1928        ]
 1929    );
 1930
 1931    // A query that matches a workspace name AND a thread in that same workspace.
 1932    // Both the header (highlighted) and all child threads should appear.
 1933    type_in_search(&sidebar, "alpha", cx);
 1934    assert_eq!(
 1935        visible_entries_as_strings(&sidebar, cx),
 1936        vec![
 1937            //
 1938            "v [alpha-project]",
 1939            "  Fix bug in sidebar  <== selected",
 1940            "  Add tests for editor",
 1941        ]
 1942    );
 1943
 1944    // Now search for something that matches only a workspace name when there
 1945    // are also threads with matching titles — the non-matching workspace's
 1946    // threads should still appear if their titles match.
 1947    type_in_search(&sidebar, "alp", cx);
 1948    assert_eq!(
 1949        visible_entries_as_strings(&sidebar, cx),
 1950        vec![
 1951            //
 1952            "v [alpha-project]",
 1953            "  Fix bug in sidebar  <== selected",
 1954            "  Add tests for editor",
 1955        ]
 1956    );
 1957}
 1958
 1959#[gpui::test]
 1960async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
 1961    let project = init_test_project("/my-project", cx).await;
 1962    let (multi_workspace, cx) =
 1963        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1964    let sidebar = setup_sidebar(&multi_workspace, cx);
 1965
 1966    save_thread_metadata(
 1967        acp::SessionId::new(Arc::from("thread-1")),
 1968        Some("Important thread".into()),
 1969        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 1970        None,
 1971        None,
 1972        &project,
 1973        cx,
 1974    );
 1975    cx.run_until_parked();
 1976
 1977    // User focuses the sidebar and collapses the group using keyboard:
 1978    // manually select the header, then press SelectParent to collapse.
 1979    focus_sidebar(&sidebar, cx);
 1980    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1981        sidebar.selection = Some(0);
 1982    });
 1983    cx.dispatch_action(SelectParent);
 1984    cx.run_until_parked();
 1985
 1986    assert_eq!(
 1987        visible_entries_as_strings(&sidebar, cx),
 1988        vec![
 1989            //
 1990            "> [my-project]  <== selected",
 1991        ]
 1992    );
 1993
 1994    // User types a search — the thread appears even though its group is collapsed.
 1995    type_in_search(&sidebar, "important", cx);
 1996    assert_eq!(
 1997        visible_entries_as_strings(&sidebar, cx),
 1998        vec![
 1999            //
 2000            "> [my-project]",
 2001            "  Important thread  <== selected",
 2002        ]
 2003    );
 2004}
 2005
 2006#[gpui::test]
 2007async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
 2008    let project = init_test_project("/my-project", cx).await;
 2009    let (multi_workspace, cx) =
 2010        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2011    let sidebar = setup_sidebar(&multi_workspace, cx);
 2012
 2013    for (id, title, hour) in [
 2014        ("t-1", "Fix crash in panel", 3),
 2015        ("t-2", "Fix lint warnings", 2),
 2016        ("t-3", "Add new feature", 1),
 2017    ] {
 2018        save_thread_metadata(
 2019            acp::SessionId::new(Arc::from(id)),
 2020            Some(title.into()),
 2021            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2022            None,
 2023            None,
 2024            &project,
 2025            cx,
 2026        )
 2027    }
 2028    cx.run_until_parked();
 2029
 2030    focus_sidebar(&sidebar, cx);
 2031
 2032    // User types "fix" — two threads match.
 2033    type_in_search(&sidebar, "fix", cx);
 2034    assert_eq!(
 2035        visible_entries_as_strings(&sidebar, cx),
 2036        vec![
 2037            //
 2038            "v [my-project]",
 2039            "  Fix crash in panel  <== selected",
 2040            "  Fix lint warnings",
 2041        ]
 2042    );
 2043
 2044    // Selection starts on the first matching thread. User presses
 2045    // SelectNext to move to the second match.
 2046    cx.dispatch_action(SelectNext);
 2047    assert_eq!(
 2048        visible_entries_as_strings(&sidebar, cx),
 2049        vec![
 2050            //
 2051            "v [my-project]",
 2052            "  Fix crash in panel",
 2053            "  Fix lint warnings  <== selected",
 2054        ]
 2055    );
 2056
 2057    // User can also jump back with SelectPrevious.
 2058    cx.dispatch_action(SelectPrevious);
 2059    assert_eq!(
 2060        visible_entries_as_strings(&sidebar, cx),
 2061        vec![
 2062            //
 2063            "v [my-project]",
 2064            "  Fix crash in panel  <== selected",
 2065            "  Fix lint warnings",
 2066        ]
 2067    );
 2068}
 2069
 2070#[gpui::test]
 2071async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
 2072    let project = init_test_project("/my-project", cx).await;
 2073    let (multi_workspace, cx) =
 2074        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2075    let sidebar = setup_sidebar(&multi_workspace, cx);
 2076
 2077    multi_workspace.update_in(cx, |mw, window, cx| {
 2078        mw.create_test_workspace(window, cx).detach();
 2079    });
 2080    cx.run_until_parked();
 2081
 2082    let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
 2083        (
 2084            mw.workspaces().next().unwrap().clone(),
 2085            mw.workspaces().nth(1).unwrap().clone(),
 2086        )
 2087    });
 2088
 2089    save_thread_metadata(
 2090        acp::SessionId::new(Arc::from("hist-1")),
 2091        Some("Historical Thread".into()),
 2092        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2093        None,
 2094        None,
 2095        &project,
 2096        cx,
 2097    );
 2098    cx.run_until_parked();
 2099    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2100    cx.run_until_parked();
 2101
 2102    assert_eq!(
 2103        visible_entries_as_strings(&sidebar, cx),
 2104        vec![
 2105            //
 2106            "v [my-project]",
 2107            "  Historical Thread",
 2108        ]
 2109    );
 2110
 2111    // Switch to workspace 1 so we can verify the confirm switches back.
 2112    multi_workspace.update_in(cx, |mw, window, cx| {
 2113        let workspace = mw.workspaces().nth(1).unwrap().clone();
 2114        mw.activate(workspace, None, window, cx);
 2115    });
 2116    cx.run_until_parked();
 2117    assert_eq!(
 2118        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2119        workspace_1
 2120    );
 2121
 2122    // Confirm on the historical (non-live) thread at index 1.
 2123    // Before a previous fix, the workspace field was Option<usize> and
 2124    // historical threads had None, so activate_thread early-returned
 2125    // without switching the workspace.
 2126    sidebar.update_in(cx, |sidebar, window, cx| {
 2127        sidebar.selection = Some(1);
 2128        sidebar.confirm(&Confirm, window, cx);
 2129    });
 2130    cx.run_until_parked();
 2131
 2132    assert_eq!(
 2133        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2134        workspace_0
 2135    );
 2136}
 2137
 2138#[gpui::test]
 2139async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
 2140    cx: &mut TestAppContext,
 2141) {
 2142    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2143    let (multi_workspace, cx) =
 2144        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2145    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2146
 2147    let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
 2148    let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
 2149    save_thread_metadata(
 2150        newer_session_id,
 2151        Some("Newer Historical Thread".into()),
 2152        newer_timestamp,
 2153        Some(newer_timestamp),
 2154        None,
 2155        &project,
 2156        cx,
 2157    );
 2158
 2159    let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
 2160    let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
 2161    save_thread_metadata(
 2162        older_session_id.clone(),
 2163        Some("Older Historical Thread".into()),
 2164        older_timestamp,
 2165        Some(older_timestamp),
 2166        None,
 2167        &project,
 2168        cx,
 2169    );
 2170
 2171    cx.run_until_parked();
 2172    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2173    cx.run_until_parked();
 2174
 2175    let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2176        .into_iter()
 2177        .filter(|entry| entry.contains("Historical Thread"))
 2178        .collect();
 2179    assert_eq!(
 2180        historical_entries_before,
 2181        vec![
 2182            "  Newer Historical Thread".to_string(),
 2183            "  Older Historical Thread".to_string(),
 2184        ],
 2185        "expected the sidebar to sort historical threads by their saved timestamp before activation"
 2186    );
 2187
 2188    let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
 2189        sidebar
 2190            .contents
 2191            .entries
 2192            .iter()
 2193            .position(|entry| {
 2194                matches!(entry, ListEntry::Thread(thread)
 2195                    if thread.metadata.session_id.as_ref() == Some(&older_session_id))
 2196            })
 2197            .expect("expected Older Historical Thread to appear in the sidebar")
 2198    });
 2199
 2200    sidebar.update_in(cx, |sidebar, window, cx| {
 2201        sidebar.selection = Some(older_entry_index);
 2202        sidebar.confirm(&Confirm, window, cx);
 2203    });
 2204    cx.run_until_parked();
 2205
 2206    let older_metadata = cx.update(|_, cx| {
 2207        ThreadMetadataStore::global(cx)
 2208            .read(cx)
 2209            .entry_by_session(&older_session_id)
 2210            .cloned()
 2211            .expect("expected metadata for Older Historical Thread after activation")
 2212    });
 2213    assert_eq!(
 2214        older_metadata.created_at,
 2215        Some(older_timestamp),
 2216        "activating a historical thread should not rewrite its saved created_at timestamp"
 2217    );
 2218
 2219    let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2220        .into_iter()
 2221        .filter(|entry| entry.contains("Historical Thread"))
 2222        .collect();
 2223    assert_eq!(
 2224        historical_entries_after,
 2225        vec![
 2226            "  Newer Historical Thread".to_string(),
 2227            "  Older Historical Thread  <== selected".to_string(),
 2228        ],
 2229        "activating an older historical thread should not reorder it ahead of a newer historical thread"
 2230    );
 2231}
 2232
 2233#[gpui::test]
 2234async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
 2235    cx: &mut TestAppContext,
 2236) {
 2237    use workspace::ProjectGroup;
 2238
 2239    agent_ui::test_support::init_test(cx);
 2240    cx.update(|cx| {
 2241        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 2242        ThreadStore::init_global(cx);
 2243        ThreadMetadataStore::init_global(cx);
 2244        language_model::LanguageModelRegistry::test(cx);
 2245        prompt_store::init(cx);
 2246    });
 2247
 2248    let fs = FakeFs::new(cx.executor());
 2249    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 2250        .await;
 2251    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2252        .await;
 2253    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 2254
 2255    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 2256    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 2257
 2258    let (multi_workspace, cx) =
 2259        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2260    let sidebar = setup_sidebar(&multi_workspace, cx);
 2261
 2262    let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
 2263    multi_workspace.update(cx, |mw, _cx| {
 2264        mw.test_add_project_group(ProjectGroup {
 2265            key: project_b_key.clone(),
 2266            workspaces: Vec::new(),
 2267            expanded: true,
 2268        });
 2269    });
 2270
 2271    let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
 2272    save_thread_metadata(
 2273        session_id.clone(),
 2274        Some("Historical Thread in New Group".into()),
 2275        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2276        None,
 2277        None,
 2278        &project_b,
 2279        cx,
 2280    );
 2281    cx.run_until_parked();
 2282
 2283    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2284    cx.run_until_parked();
 2285
 2286    let entries_before = visible_entries_as_strings(&sidebar, cx);
 2287    assert_eq!(
 2288        entries_before,
 2289        vec![
 2290            "v [project-a]",
 2291            "v [project-b]",
 2292            "  Historical Thread in New Group",
 2293        ],
 2294        "expected the closed project group to show the historical thread before first open"
 2295    );
 2296
 2297    assert_eq!(
 2298        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2299        1,
 2300        "should start without an open workspace for the new project group"
 2301    );
 2302
 2303    sidebar.update_in(cx, |sidebar, window, cx| {
 2304        sidebar.selection = Some(2);
 2305        sidebar.confirm(&Confirm, window, cx);
 2306    });
 2307
 2308    cx.run_until_parked();
 2309
 2310    assert_eq!(
 2311        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2312        2,
 2313        "confirming the historical thread should open a workspace for the new project group"
 2314    );
 2315
 2316    let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
 2317        mw.workspaces()
 2318            .find(|workspace| {
 2319                PathList::new(&workspace.read(cx).root_paths(cx))
 2320                    == project_b_key.path_list().clone()
 2321            })
 2322            .cloned()
 2323            .expect("expected workspace for project-b after opening the historical thread")
 2324    });
 2325
 2326    assert_eq!(
 2327        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2328        workspace_b,
 2329        "opening the historical thread should activate the new project's workspace"
 2330    );
 2331
 2332    let panel = workspace_b.read_with(cx, |workspace, cx| {
 2333        workspace
 2334            .panel::<AgentPanel>(cx)
 2335            .expect("expected first-open activation to bootstrap the agent panel")
 2336    });
 2337
 2338    let expected_thread_id = cx.update(|_, cx| {
 2339        ThreadMetadataStore::global(cx)
 2340            .read(cx)
 2341            .entries()
 2342            .find(|e| e.session_id.as_ref() == Some(&session_id))
 2343            .map(|e| e.thread_id)
 2344            .expect("metadata should still map session id to thread id")
 2345    });
 2346
 2347    assert_eq!(
 2348        panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 2349        Some(expected_thread_id),
 2350        "expected the agent panel to activate the real historical thread rather than a draft"
 2351    );
 2352
 2353    let entries_after = visible_entries_as_strings(&sidebar, cx);
 2354    let matching_rows: Vec<_> = entries_after
 2355        .iter()
 2356        .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
 2357        .cloned()
 2358        .collect();
 2359    assert_eq!(
 2360        matching_rows.len(),
 2361        1,
 2362        "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
 2363    );
 2364    assert!(
 2365        matching_rows[0].contains("Historical Thread in New Group"),
 2366        "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
 2367    );
 2368    assert!(
 2369        !matching_rows[0].contains("Draft"),
 2370        "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
 2371    );
 2372}
 2373
 2374#[gpui::test]
 2375async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
 2376    let project = init_test_project("/my-project", cx).await;
 2377    let (multi_workspace, cx) =
 2378        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2379    let sidebar = setup_sidebar(&multi_workspace, cx);
 2380
 2381    save_thread_metadata(
 2382        acp::SessionId::new(Arc::from("t-1")),
 2383        Some("Thread A".into()),
 2384        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 2385        None,
 2386        None,
 2387        &project,
 2388        cx,
 2389    );
 2390
 2391    save_thread_metadata(
 2392        acp::SessionId::new(Arc::from("t-2")),
 2393        Some("Thread B".into()),
 2394        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 2395        None,
 2396        None,
 2397        &project,
 2398        cx,
 2399    );
 2400
 2401    cx.run_until_parked();
 2402    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2403    cx.run_until_parked();
 2404
 2405    assert_eq!(
 2406        visible_entries_as_strings(&sidebar, cx),
 2407        vec![
 2408            //
 2409            "v [my-project]",
 2410            "  Thread A",
 2411            "  Thread B",
 2412        ]
 2413    );
 2414
 2415    // Keyboard confirm preserves selection.
 2416    sidebar.update_in(cx, |sidebar, window, cx| {
 2417        sidebar.selection = Some(1);
 2418        sidebar.confirm(&Confirm, window, cx);
 2419    });
 2420    assert_eq!(
 2421        sidebar.read_with(cx, |sidebar, _| sidebar.selection),
 2422        Some(1)
 2423    );
 2424
 2425    // Click handlers clear selection to None so no highlight lingers
 2426    // after a click regardless of focus state. The hover style provides
 2427    // visual feedback during mouse interaction instead.
 2428    sidebar.update_in(cx, |sidebar, window, cx| {
 2429        sidebar.selection = None;
 2430        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 2431        let project_group_key = ProjectGroupKey::new(None, path_list);
 2432        sidebar.toggle_collapse(&project_group_key, window, cx);
 2433    });
 2434    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2435
 2436    // When the user tabs back into the sidebar, focus_in no longer
 2437    // restores selection — it stays None.
 2438    sidebar.update_in(cx, |sidebar, window, cx| {
 2439        sidebar.focus_in(window, cx);
 2440    });
 2441    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2442}
 2443
 2444#[gpui::test]
 2445async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
 2446    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2447    let (multi_workspace, cx) =
 2448        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2449    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2450
 2451    let connection = StubAgentConnection::new();
 2452    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2453        acp::ContentChunk::new("Hi there!".into()),
 2454    )]);
 2455    open_thread_with_connection(&panel, connection, cx);
 2456    send_message(&panel, cx);
 2457
 2458    let session_id = active_session_id(&panel, cx);
 2459    save_test_thread_metadata(&session_id, &project, cx).await;
 2460    cx.run_until_parked();
 2461
 2462    assert_eq!(
 2463        visible_entries_as_strings(&sidebar, cx),
 2464        vec![
 2465            //
 2466            "v [my-project]",
 2467            "  Hello *",
 2468        ]
 2469    );
 2470
 2471    // Simulate the agent generating a title. The notification chain is:
 2472    // AcpThread::set_title emits TitleUpdated →
 2473    // ConnectionView::handle_thread_event calls cx.notify() →
 2474    // AgentPanel observer fires and emits AgentPanelEvent →
 2475    // Sidebar subscription calls update_entries / rebuild_contents.
 2476    //
 2477    // Before the fix, handle_thread_event did NOT call cx.notify() for
 2478    // TitleUpdated, so the AgentPanel observer never fired and the
 2479    // sidebar kept showing the old title.
 2480    let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
 2481    thread.update(cx, |thread, cx| {
 2482        thread
 2483            .set_title("Friendly Greeting with AI".into(), cx)
 2484            .detach();
 2485    });
 2486    cx.run_until_parked();
 2487
 2488    assert_eq!(
 2489        visible_entries_as_strings(&sidebar, cx),
 2490        vec![
 2491            //
 2492            "v [my-project]",
 2493            "  Friendly Greeting with AI *",
 2494        ]
 2495    );
 2496}
 2497
 2498#[gpui::test]
 2499async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
 2500    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 2501    let (multi_workspace, cx) =
 2502        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2503    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2504
 2505    // Save a thread so it appears in the list.
 2506    let connection_a = StubAgentConnection::new();
 2507    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2508        acp::ContentChunk::new("Done".into()),
 2509    )]);
 2510    open_thread_with_connection(&panel_a, connection_a, cx);
 2511    send_message(&panel_a, cx);
 2512    let session_id_a = active_session_id(&panel_a, cx);
 2513    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 2514
 2515    // Add a second workspace with its own agent panel.
 2516    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 2517    fs.as_fake()
 2518        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2519        .await;
 2520    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
 2521    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 2522        mw.test_add_workspace(project_b.clone(), window, cx)
 2523    });
 2524    let panel_b = add_agent_panel(&workspace_b, cx);
 2525    cx.run_until_parked();
 2526
 2527    let workspace_a =
 2528        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 2529
 2530    // ── 1. Initial state: focused thread derived from active panel ─────
 2531    sidebar.read_with(cx, |sidebar, _cx| {
 2532        assert_active_thread(
 2533            sidebar,
 2534            &session_id_a,
 2535            "The active panel's thread should be focused on startup",
 2536        );
 2537    });
 2538
 2539    let thread_metadata_a = cx.update(|_window, cx| {
 2540        ThreadMetadataStore::global(cx)
 2541            .read(cx)
 2542            .entry_by_session(&session_id_a)
 2543            .cloned()
 2544            .expect("session_id_a should exist in metadata store")
 2545    });
 2546    sidebar.update_in(cx, |sidebar, window, cx| {
 2547        sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
 2548    });
 2549    cx.run_until_parked();
 2550
 2551    sidebar.read_with(cx, |sidebar, _cx| {
 2552        assert_active_thread(
 2553            sidebar,
 2554            &session_id_a,
 2555            "After clicking a thread, it should be the focused thread",
 2556        );
 2557        assert!(
 2558            has_thread_entry(sidebar, &session_id_a),
 2559            "The clicked thread should be present in the entries"
 2560        );
 2561    });
 2562
 2563    workspace_a.read_with(cx, |workspace, cx| {
 2564        assert!(
 2565            workspace.panel::<AgentPanel>(cx).is_some(),
 2566            "Agent panel should exist"
 2567        );
 2568        let dock = workspace.left_dock().read(cx);
 2569        assert!(
 2570            dock.is_open(),
 2571            "Clicking a thread should open the agent panel dock"
 2572        );
 2573    });
 2574
 2575    let connection_b = StubAgentConnection::new();
 2576    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2577        acp::ContentChunk::new("Thread B".into()),
 2578    )]);
 2579    open_thread_with_connection(&panel_b, connection_b, cx);
 2580    send_message(&panel_b, cx);
 2581    let session_id_b = active_session_id(&panel_b, cx);
 2582    save_test_thread_metadata(&session_id_b, &project_b, cx).await;
 2583    cx.run_until_parked();
 2584
 2585    // Workspace A is currently active. Click a thread in workspace B,
 2586    // which also triggers a workspace switch.
 2587    let thread_metadata_b = cx.update(|_window, cx| {
 2588        ThreadMetadataStore::global(cx)
 2589            .read(cx)
 2590            .entry_by_session(&session_id_b)
 2591            .cloned()
 2592            .expect("session_id_b should exist in metadata store")
 2593    });
 2594    sidebar.update_in(cx, |sidebar, window, cx| {
 2595        sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
 2596    });
 2597    cx.run_until_parked();
 2598
 2599    sidebar.read_with(cx, |sidebar, _cx| {
 2600        assert_active_thread(
 2601            sidebar,
 2602            &session_id_b,
 2603            "Clicking a thread in another workspace should focus that thread",
 2604        );
 2605        assert!(
 2606            has_thread_entry(sidebar, &session_id_b),
 2607            "The cross-workspace thread should be present in the entries"
 2608        );
 2609    });
 2610
 2611    multi_workspace.update_in(cx, |mw, window, cx| {
 2612        let workspace = mw.workspaces().next().unwrap().clone();
 2613        mw.activate(workspace, None, window, cx);
 2614    });
 2615    cx.run_until_parked();
 2616
 2617    sidebar.read_with(cx, |sidebar, _cx| {
 2618        assert_active_thread(
 2619            sidebar,
 2620            &session_id_a,
 2621            "Switching workspace should seed focused_thread from the new active panel",
 2622        );
 2623        assert!(
 2624            has_thread_entry(sidebar, &session_id_a),
 2625            "The seeded thread should be present in the entries"
 2626        );
 2627    });
 2628
 2629    let connection_b2 = StubAgentConnection::new();
 2630    connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2631        acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
 2632    )]);
 2633    open_thread_with_connection(&panel_b, connection_b2, cx);
 2634    send_message(&panel_b, cx);
 2635    let session_id_b2 = active_session_id(&panel_b, cx);
 2636    save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
 2637    cx.run_until_parked();
 2638
 2639    // Panel B is not the active workspace's panel (workspace A is
 2640    // active), so opening a thread there should not change focused_thread.
 2641    // This prevents running threads in background workspaces from causing
 2642    // the selection highlight to jump around.
 2643    sidebar.read_with(cx, |sidebar, _cx| {
 2644        assert_active_thread(
 2645            sidebar,
 2646            &session_id_a,
 2647            "Opening a thread in a non-active panel should not change focused_thread",
 2648        );
 2649    });
 2650
 2651    workspace_b.update_in(cx, |workspace, window, cx| {
 2652        workspace.focus_handle(cx).focus(window, cx);
 2653    });
 2654    cx.run_until_parked();
 2655
 2656    sidebar.read_with(cx, |sidebar, _cx| {
 2657        assert_active_thread(
 2658            sidebar,
 2659            &session_id_a,
 2660            "Defocusing the sidebar should not change focused_thread",
 2661        );
 2662    });
 2663
 2664    // Switching workspaces via the multi_workspace (simulates clicking
 2665    // a workspace header) should clear focused_thread.
 2666    multi_workspace.update_in(cx, |mw, window, cx| {
 2667        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
 2668        if let Some(workspace) = workspace {
 2669            mw.activate(workspace, None, window, cx);
 2670        }
 2671    });
 2672    cx.run_until_parked();
 2673
 2674    sidebar.read_with(cx, |sidebar, _cx| {
 2675        assert_active_thread(
 2676            sidebar,
 2677            &session_id_b2,
 2678            "Switching workspace should seed focused_thread from the new active panel",
 2679        );
 2680        assert!(
 2681            has_thread_entry(sidebar, &session_id_b2),
 2682            "The seeded thread should be present in the entries"
 2683        );
 2684    });
 2685
 2686    // ── 8. Focusing the agent panel thread keeps focused_thread ────
 2687    // Workspace B still has session_id_b2 loaded in the agent panel.
 2688    // Clicking into the thread (simulated by focusing its view) should
 2689    // keep focused_thread since it was already seeded on workspace switch.
 2690    panel_b.update_in(cx, |panel, window, cx| {
 2691        if let Some(thread_view) = panel.active_conversation_view() {
 2692            thread_view.read(cx).focus_handle(cx).focus(window, cx);
 2693        }
 2694    });
 2695    cx.run_until_parked();
 2696
 2697    sidebar.read_with(cx, |sidebar, _cx| {
 2698        assert_active_thread(
 2699            sidebar,
 2700            &session_id_b2,
 2701            "Focusing the agent panel thread should set focused_thread",
 2702        );
 2703        assert!(
 2704            has_thread_entry(sidebar, &session_id_b2),
 2705            "The focused thread should be present in the entries"
 2706        );
 2707    });
 2708}
 2709
 2710#[gpui::test]
 2711async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
 2712    let project = init_test_project_with_agent_panel("/project-a", cx).await;
 2713    let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
 2714    let (multi_workspace, cx) =
 2715        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2716    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2717
 2718    // Start a thread and send a message so it has history.
 2719    let connection = StubAgentConnection::new();
 2720    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2721        acp::ContentChunk::new("Done".into()),
 2722    )]);
 2723    open_thread_with_connection(&panel, connection, cx);
 2724    send_message(&panel, cx);
 2725    let session_id = active_session_id(&panel, cx);
 2726    save_test_thread_metadata(&session_id, &project, cx).await;
 2727    cx.run_until_parked();
 2728
 2729    // Verify the thread appears in the sidebar.
 2730    assert_eq!(
 2731        visible_entries_as_strings(&sidebar, cx),
 2732        vec![
 2733            //
 2734            "v [project-a]",
 2735            "  Hello *",
 2736        ]
 2737    );
 2738
 2739    // The "New Thread" button should NOT be in "active/draft" state
 2740    // because the panel has a thread with messages.
 2741    sidebar.read_with(cx, |sidebar, _cx| {
 2742        assert!(
 2743            matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
 2744            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
 2745            sidebar.active_entry,
 2746        );
 2747    });
 2748
 2749    // Now add a second folder to the workspace, changing the path_list.
 2750    fs.as_fake()
 2751        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2752        .await;
 2753    project
 2754        .update(cx, |project, cx| {
 2755            project.find_or_create_worktree("/project-b", true, cx)
 2756        })
 2757        .await
 2758        .expect("should add worktree");
 2759    cx.run_until_parked();
 2760
 2761    // The workspace path_list is now [project-a, project-b]. The active
 2762    // thread's metadata was re-saved with the new paths by the agent panel's
 2763    // project subscription. The old [project-a] key is replaced by the new
 2764    // key since no other workspace claims it.
 2765    let entries = visible_entries_as_strings(&sidebar, cx);
 2766    // After adding a worktree, the thread migrates to the new group key.
 2767    // A reconciliation draft may appear during the transition.
 2768    assert!(
 2769        entries.contains(&"  Hello *".to_string()),
 2770        "thread should still be present after adding folder: {entries:?}"
 2771    );
 2772    assert_eq!(entries[0], "v [project-a, project-b]");
 2773
 2774    // The "New Thread" button must still be clickable (not stuck in
 2775    // "active/draft" state). Verify that `active_thread_is_draft` is
 2776    // false — the panel still has the old thread with messages.
 2777    sidebar.read_with(cx, |sidebar, _cx| {
 2778        assert!(
 2779            matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
 2780            "After adding a folder the panel still has a thread with messages, \
 2781                 so active_entry should be Thread, got {:?}",
 2782            sidebar.active_entry,
 2783        );
 2784    });
 2785
 2786    // Actually click "New Thread" by calling create_new_thread and
 2787    // verify a new draft is created.
 2788    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 2789    sidebar.update_in(cx, |sidebar, window, cx| {
 2790        sidebar.create_new_thread(&workspace, window, cx);
 2791    });
 2792    cx.run_until_parked();
 2793
 2794    // After creating a new thread, the panel should now be in draft
 2795    // state (no messages on the new thread).
 2796    sidebar.read_with(cx, |sidebar, _cx| {
 2797        assert_active_draft(
 2798            sidebar,
 2799            &workspace,
 2800            "After creating a new thread active_entry should be Draft",
 2801        );
 2802    });
 2803}
 2804#[gpui::test]
 2805async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
 2806    // When the user presses Cmd-N (NewThread action) while viewing a
 2807    // non-empty thread, the panel should switch to the draft thread.
 2808    // Drafts are not shown as sidebar rows.
 2809    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2810    let (multi_workspace, cx) =
 2811        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2812    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2813
 2814    // Create a non-empty thread (has messages).
 2815    let connection = StubAgentConnection::new();
 2816    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2817        acp::ContentChunk::new("Done".into()),
 2818    )]);
 2819    open_thread_with_connection(&panel, connection, cx);
 2820    send_message(&panel, cx);
 2821
 2822    let session_id = active_session_id(&panel, cx);
 2823    save_test_thread_metadata(&session_id, &project, cx).await;
 2824    cx.run_until_parked();
 2825
 2826    assert_eq!(
 2827        visible_entries_as_strings(&sidebar, cx),
 2828        vec![
 2829            //
 2830            "v [my-project]",
 2831            "  Hello *",
 2832        ]
 2833    );
 2834
 2835    // Simulate cmd-n
 2836    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 2837    panel.update_in(cx, |panel, window, cx| {
 2838        panel.new_thread(&NewThread, window, cx);
 2839    });
 2840    workspace.update_in(cx, |workspace, window, cx| {
 2841        workspace.focus_panel::<AgentPanel>(window, cx);
 2842    });
 2843    cx.run_until_parked();
 2844
 2845    // Drafts are not shown as sidebar rows, so entries stay the same.
 2846    assert_eq!(
 2847        visible_entries_as_strings(&sidebar, cx),
 2848        vec!["v [my-project]", "  Hello *"],
 2849        "After Cmd-N the sidebar should not show a Draft entry"
 2850    );
 2851
 2852    // The panel should be on the draft and active_entry should track it.
 2853    panel.read_with(cx, |panel, cx| {
 2854        assert!(
 2855            panel.active_thread_is_draft(cx),
 2856            "panel should be showing the draft after Cmd-N",
 2857        );
 2858    });
 2859    sidebar.read_with(cx, |sidebar, _cx| {
 2860        assert_active_draft(
 2861            sidebar,
 2862            &workspace,
 2863            "active_entry should be Draft after Cmd-N",
 2864        );
 2865    });
 2866}
 2867
 2868#[gpui::test]
 2869async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
 2870    // When the active workspace is an absorbed git worktree, cmd-n
 2871    // should activate the draft thread in the panel. Drafts are not
 2872    // shown as sidebar rows.
 2873    agent_ui::test_support::init_test(cx);
 2874    cx.update(|cx| {
 2875        ThreadStore::init_global(cx);
 2876        ThreadMetadataStore::init_global(cx);
 2877        language_model::LanguageModelRegistry::test(cx);
 2878        prompt_store::init(cx);
 2879    });
 2880
 2881    let fs = FakeFs::new(cx.executor());
 2882
 2883    // Main repo with a linked worktree.
 2884    fs.insert_tree(
 2885        "/project",
 2886        serde_json::json!({
 2887            ".git": {},
 2888            "src": {},
 2889        }),
 2890    )
 2891    .await;
 2892
 2893    // Worktree checkout pointing back to the main repo.
 2894    fs.add_linked_worktree_for_repo(
 2895        Path::new("/project/.git"),
 2896        false,
 2897        git::repository::Worktree {
 2898            path: std::path::PathBuf::from("/wt-feature-a"),
 2899            ref_name: Some("refs/heads/feature-a".into()),
 2900            sha: "aaa".into(),
 2901            is_main: false,
 2902            is_bare: false,
 2903        },
 2904    )
 2905    .await;
 2906
 2907    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 2908
 2909    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 2910    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 2911
 2912    main_project
 2913        .update(cx, |p, cx| p.git_scans_complete(cx))
 2914        .await;
 2915    worktree_project
 2916        .update(cx, |p, cx| p.git_scans_complete(cx))
 2917        .await;
 2918
 2919    let (multi_workspace, cx) =
 2920        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 2921
 2922    let sidebar = setup_sidebar(&multi_workspace, cx);
 2923
 2924    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 2925        mw.test_add_workspace(worktree_project.clone(), window, cx)
 2926    });
 2927
 2928    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 2929
 2930    // Switch to the worktree workspace.
 2931    multi_workspace.update_in(cx, |mw, window, cx| {
 2932        let workspace = mw.workspaces().nth(1).unwrap().clone();
 2933        mw.activate(workspace, None, window, cx);
 2934    });
 2935
 2936    // Create a non-empty thread in the worktree workspace.
 2937    let connection = StubAgentConnection::new();
 2938    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2939        acp::ContentChunk::new("Done".into()),
 2940    )]);
 2941    open_thread_with_connection(&worktree_panel, connection, cx);
 2942    send_message(&worktree_panel, cx);
 2943
 2944    let session_id = active_session_id(&worktree_panel, cx);
 2945    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 2946    cx.run_until_parked();
 2947
 2948    assert_eq!(
 2949        visible_entries_as_strings(&sidebar, cx),
 2950        vec![
 2951            //
 2952            "v [project]",
 2953            "  Hello {wt-feature-a} *",
 2954        ]
 2955    );
 2956
 2957    // Simulate Cmd-N in the worktree workspace.
 2958    worktree_panel.update_in(cx, |panel, window, cx| {
 2959        panel.new_thread(&NewThread, window, cx);
 2960    });
 2961    worktree_workspace.update_in(cx, |workspace, window, cx| {
 2962        workspace.focus_panel::<AgentPanel>(window, cx);
 2963    });
 2964    cx.run_until_parked();
 2965
 2966    // Drafts are not shown as sidebar rows, so entries stay the same.
 2967    assert_eq!(
 2968        visible_entries_as_strings(&sidebar, cx),
 2969        vec![
 2970            //
 2971            "v [project]",
 2972            "  Hello {wt-feature-a} *"
 2973        ],
 2974        "After Cmd-N the sidebar should not show a Draft entry"
 2975    );
 2976
 2977    // The panel should be on the draft and active_entry should track it.
 2978    worktree_panel.read_with(cx, |panel, cx| {
 2979        assert!(
 2980            panel.active_thread_is_draft(cx),
 2981            "panel should be showing the draft after Cmd-N",
 2982        );
 2983    });
 2984    sidebar.read_with(cx, |sidebar, _cx| {
 2985        assert_active_draft(
 2986            sidebar,
 2987            &worktree_workspace,
 2988            "active_entry should be Draft after Cmd-N",
 2989        );
 2990    });
 2991}
 2992
 2993async fn init_test_project_with_git(
 2994    worktree_path: &str,
 2995    cx: &mut TestAppContext,
 2996) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
 2997    init_test(cx);
 2998    let fs = FakeFs::new(cx.executor());
 2999    fs.insert_tree(
 3000        worktree_path,
 3001        serde_json::json!({
 3002            ".git": {},
 3003            "src": {},
 3004        }),
 3005    )
 3006    .await;
 3007    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3008    let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
 3009    (project, fs)
 3010}
 3011
 3012#[gpui::test]
 3013async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
 3014    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3015
 3016    fs.as_fake()
 3017        .add_linked_worktree_for_repo(
 3018            Path::new("/project/.git"),
 3019            false,
 3020            git::repository::Worktree {
 3021                path: std::path::PathBuf::from("/wt/rosewood"),
 3022                ref_name: Some("refs/heads/rosewood".into()),
 3023                sha: "abc".into(),
 3024                is_main: false,
 3025                is_bare: false,
 3026            },
 3027        )
 3028        .await;
 3029
 3030    project
 3031        .update(cx, |project, cx| project.git_scans_complete(cx))
 3032        .await;
 3033
 3034    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3035    worktree_project
 3036        .update(cx, |p, cx| p.git_scans_complete(cx))
 3037        .await;
 3038
 3039    let (multi_workspace, cx) =
 3040        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3041    let sidebar = setup_sidebar(&multi_workspace, cx);
 3042
 3043    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
 3044    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
 3045
 3046    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3047    cx.run_until_parked();
 3048
 3049    // Search for "rosewood" — should match the worktree name, not the title.
 3050    type_in_search(&sidebar, "rosewood", cx);
 3051
 3052    assert_eq!(
 3053        visible_entries_as_strings(&sidebar, cx),
 3054        vec![
 3055            //
 3056            "v [project]",
 3057            "  Fix Bug {rosewood}  <== selected",
 3058        ],
 3059    );
 3060}
 3061
 3062#[gpui::test]
 3063async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
 3064    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3065
 3066    project
 3067        .update(cx, |project, cx| project.git_scans_complete(cx))
 3068        .await;
 3069
 3070    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3071    worktree_project
 3072        .update(cx, |p, cx| p.git_scans_complete(cx))
 3073        .await;
 3074
 3075    let (multi_workspace, cx) =
 3076        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3077    let sidebar = setup_sidebar(&multi_workspace, cx);
 3078
 3079    // Save a thread against a worktree path with the correct main
 3080    // worktree association (as if the git state had been resolved).
 3081    save_thread_metadata_with_main_paths(
 3082        "wt-thread",
 3083        "Worktree Thread",
 3084        PathList::new(&[PathBuf::from("/wt/rosewood")]),
 3085        PathList::new(&[PathBuf::from("/project")]),
 3086        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3087        cx,
 3088    );
 3089
 3090    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3091    cx.run_until_parked();
 3092
 3093    // Thread is visible because its main_worktree_paths match the group.
 3094    // The chip name is derived from the path even before git discovery.
 3095    assert_eq!(
 3096        visible_entries_as_strings(&sidebar, cx),
 3097        vec!["v [project]", "  Worktree Thread {rosewood}"]
 3098    );
 3099
 3100    // Now add the worktree to the git state and trigger a rescan.
 3101    fs.as_fake()
 3102        .add_linked_worktree_for_repo(
 3103            Path::new("/project/.git"),
 3104            true,
 3105            git::repository::Worktree {
 3106                path: std::path::PathBuf::from("/wt/rosewood"),
 3107                ref_name: Some("refs/heads/rosewood".into()),
 3108                sha: "abc".into(),
 3109                is_main: false,
 3110                is_bare: false,
 3111            },
 3112        )
 3113        .await;
 3114
 3115    cx.run_until_parked();
 3116
 3117    assert_eq!(
 3118        visible_entries_as_strings(&sidebar, cx),
 3119        vec![
 3120            //
 3121            "v [project]",
 3122            "  Worktree Thread {rosewood}",
 3123        ]
 3124    );
 3125}
 3126
 3127#[gpui::test]
 3128async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
 3129    init_test(cx);
 3130    let fs = FakeFs::new(cx.executor());
 3131
 3132    // Create the main repo directory (not opened as a workspace yet).
 3133    fs.insert_tree(
 3134        "/project",
 3135        serde_json::json!({
 3136            ".git": {
 3137            },
 3138            "src": {},
 3139        }),
 3140    )
 3141    .await;
 3142
 3143    // Two worktree checkouts whose .git files point back to the main repo.
 3144    fs.add_linked_worktree_for_repo(
 3145        Path::new("/project/.git"),
 3146        false,
 3147        git::repository::Worktree {
 3148            path: std::path::PathBuf::from("/wt-feature-a"),
 3149            ref_name: Some("refs/heads/feature-a".into()),
 3150            sha: "aaa".into(),
 3151            is_main: false,
 3152            is_bare: false,
 3153        },
 3154    )
 3155    .await;
 3156    fs.add_linked_worktree_for_repo(
 3157        Path::new("/project/.git"),
 3158        false,
 3159        git::repository::Worktree {
 3160            path: std::path::PathBuf::from("/wt-feature-b"),
 3161            ref_name: Some("refs/heads/feature-b".into()),
 3162            sha: "bbb".into(),
 3163            is_main: false,
 3164            is_bare: false,
 3165        },
 3166    )
 3167    .await;
 3168
 3169    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3170
 3171    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3172    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3173
 3174    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3175    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3176
 3177    // Open both worktrees as workspaces — no main repo yet.
 3178    let (multi_workspace, cx) =
 3179        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3180    multi_workspace.update_in(cx, |mw, window, cx| {
 3181        mw.test_add_workspace(project_b.clone(), window, cx);
 3182    });
 3183    let sidebar = setup_sidebar(&multi_workspace, cx);
 3184
 3185    save_thread_metadata(
 3186        acp::SessionId::new(Arc::from("thread-a")),
 3187        Some("Thread A".into()),
 3188        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3189        None,
 3190        None,
 3191        &project_a,
 3192        cx,
 3193    );
 3194    save_thread_metadata(
 3195        acp::SessionId::new(Arc::from("thread-b")),
 3196        Some("Thread B".into()),
 3197        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 3198        None,
 3199        None,
 3200        &project_b,
 3201        cx,
 3202    );
 3203
 3204    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3205    cx.run_until_parked();
 3206
 3207    // Without the main repo, each worktree has its own header.
 3208    assert_eq!(
 3209        visible_entries_as_strings(&sidebar, cx),
 3210        vec![
 3211            //
 3212            "v [project]",
 3213            "  Thread B {wt-feature-b}",
 3214            "  Thread A {wt-feature-a}",
 3215        ]
 3216    );
 3217
 3218    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3219    main_project
 3220        .update(cx, |p, cx| p.git_scans_complete(cx))
 3221        .await;
 3222
 3223    multi_workspace.update_in(cx, |mw, window, cx| {
 3224        mw.test_add_workspace(main_project.clone(), window, cx);
 3225    });
 3226    cx.run_until_parked();
 3227
 3228    // Both worktree workspaces should now be absorbed under the main
 3229    // repo header, with worktree chips.
 3230    assert_eq!(
 3231        visible_entries_as_strings(&sidebar, cx),
 3232        vec![
 3233            //
 3234            "v [project]",
 3235            "  Thread B {wt-feature-b}",
 3236            "  Thread A {wt-feature-a}",
 3237        ]
 3238    );
 3239}
 3240
 3241#[gpui::test]
 3242async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
 3243    // When a group has two workspaces — one with threads and one
 3244    // without — the threadless workspace should appear as a
 3245    // "New Thread" button with its worktree chip.
 3246    init_test(cx);
 3247    let fs = FakeFs::new(cx.executor());
 3248
 3249    // Main repo with two linked worktrees.
 3250    fs.insert_tree(
 3251        "/project",
 3252        serde_json::json!({
 3253            ".git": {},
 3254            "src": {},
 3255        }),
 3256    )
 3257    .await;
 3258    fs.add_linked_worktree_for_repo(
 3259        Path::new("/project/.git"),
 3260        false,
 3261        git::repository::Worktree {
 3262            path: std::path::PathBuf::from("/wt-feature-a"),
 3263            ref_name: Some("refs/heads/feature-a".into()),
 3264            sha: "aaa".into(),
 3265            is_main: false,
 3266            is_bare: false,
 3267        },
 3268    )
 3269    .await;
 3270    fs.add_linked_worktree_for_repo(
 3271        Path::new("/project/.git"),
 3272        false,
 3273        git::repository::Worktree {
 3274            path: std::path::PathBuf::from("/wt-feature-b"),
 3275            ref_name: Some("refs/heads/feature-b".into()),
 3276            sha: "bbb".into(),
 3277            is_main: false,
 3278            is_bare: false,
 3279        },
 3280    )
 3281    .await;
 3282
 3283    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3284
 3285    // Workspace A: worktree feature-a (has threads).
 3286    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3287    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3288
 3289    // Workspace B: worktree feature-b (no threads).
 3290    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3291    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3292
 3293    let (multi_workspace, cx) =
 3294        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3295    multi_workspace.update_in(cx, |mw, window, cx| {
 3296        mw.test_add_workspace(project_b.clone(), window, cx);
 3297    });
 3298    let sidebar = setup_sidebar(&multi_workspace, cx);
 3299
 3300    // Only save a thread for workspace A.
 3301    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
 3302
 3303    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3304    cx.run_until_parked();
 3305
 3306    // Workspace A's thread appears normally. Workspace B (threadless)
 3307    // appears as a "New Thread" button with its worktree chip.
 3308    assert_eq!(
 3309        visible_entries_as_strings(&sidebar, cx),
 3310        vec!["v [project]", "  Thread A {wt-feature-a}",]
 3311    );
 3312}
 3313
 3314#[gpui::test]
 3315async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
 3316    // A thread created in a workspace with roots from different git
 3317    // worktrees should show a chip for each distinct worktree name.
 3318    init_test(cx);
 3319    let fs = FakeFs::new(cx.executor());
 3320
 3321    // Two main repos.
 3322    fs.insert_tree(
 3323        "/project_a",
 3324        serde_json::json!({
 3325            ".git": {},
 3326            "src": {},
 3327        }),
 3328    )
 3329    .await;
 3330    fs.insert_tree(
 3331        "/project_b",
 3332        serde_json::json!({
 3333            ".git": {},
 3334            "src": {},
 3335        }),
 3336    )
 3337    .await;
 3338
 3339    // Worktree checkouts.
 3340    for repo in &["project_a", "project_b"] {
 3341        let git_path = format!("/{repo}/.git");
 3342        for branch in &["olivetti", "selectric"] {
 3343            fs.add_linked_worktree_for_repo(
 3344                Path::new(&git_path),
 3345                false,
 3346                git::repository::Worktree {
 3347                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
 3348                    ref_name: Some(format!("refs/heads/{branch}").into()),
 3349                    sha: "aaa".into(),
 3350                    is_main: false,
 3351                    is_bare: false,
 3352                },
 3353            )
 3354            .await;
 3355        }
 3356    }
 3357
 3358    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3359
 3360    // Open a workspace with the worktree checkout paths as roots
 3361    // (this is the workspace the thread was created in).
 3362    let project = project::Project::test(
 3363        fs.clone(),
 3364        [
 3365            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3366            "/worktrees/project_b/selectric/project_b".as_ref(),
 3367        ],
 3368        cx,
 3369    )
 3370    .await;
 3371    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3372
 3373    let (multi_workspace, cx) =
 3374        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3375    let sidebar = setup_sidebar(&multi_workspace, cx);
 3376
 3377    // Save a thread under the same paths as the workspace roots.
 3378    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
 3379
 3380    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3381    cx.run_until_parked();
 3382
 3383    // Should show two distinct worktree chips.
 3384    assert_eq!(
 3385        visible_entries_as_strings(&sidebar, cx),
 3386        vec![
 3387            //
 3388            "v [project_a, project_b]",
 3389            "  Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
 3390        ]
 3391    );
 3392}
 3393
 3394#[gpui::test]
 3395async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
 3396    // When a thread's roots span multiple repos but share the same
 3397    // worktree name (e.g. both in "olivetti"), only one chip should
 3398    // appear.
 3399    init_test(cx);
 3400    let fs = FakeFs::new(cx.executor());
 3401
 3402    fs.insert_tree(
 3403        "/project_a",
 3404        serde_json::json!({
 3405            ".git": {},
 3406            "src": {},
 3407        }),
 3408    )
 3409    .await;
 3410    fs.insert_tree(
 3411        "/project_b",
 3412        serde_json::json!({
 3413            ".git": {},
 3414            "src": {},
 3415        }),
 3416    )
 3417    .await;
 3418
 3419    for repo in &["project_a", "project_b"] {
 3420        let git_path = format!("/{repo}/.git");
 3421        fs.add_linked_worktree_for_repo(
 3422            Path::new(&git_path),
 3423            false,
 3424            git::repository::Worktree {
 3425                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
 3426                ref_name: Some("refs/heads/olivetti".into()),
 3427                sha: "aaa".into(),
 3428                is_main: false,
 3429                is_bare: false,
 3430            },
 3431        )
 3432        .await;
 3433    }
 3434
 3435    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3436
 3437    let project = project::Project::test(
 3438        fs.clone(),
 3439        [
 3440            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3441            "/worktrees/project_b/olivetti/project_b".as_ref(),
 3442        ],
 3443        cx,
 3444    )
 3445    .await;
 3446    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3447
 3448    let (multi_workspace, cx) =
 3449        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3450    let sidebar = setup_sidebar(&multi_workspace, cx);
 3451
 3452    // Thread with roots in both repos' "olivetti" worktrees.
 3453    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
 3454
 3455    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3456    cx.run_until_parked();
 3457
 3458    // Both worktree paths have the name "olivetti", so only one chip.
 3459    assert_eq!(
 3460        visible_entries_as_strings(&sidebar, cx),
 3461        vec![
 3462            //
 3463            "v [project_a, project_b]",
 3464            "  Same Branch Thread {olivetti}",
 3465        ]
 3466    );
 3467}
 3468
 3469#[gpui::test]
 3470async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
 3471    // When a worktree workspace is absorbed under the main repo, a
 3472    // running thread in the worktree's agent panel should still show
 3473    // live status (spinner + "(running)") in the sidebar.
 3474    agent_ui::test_support::init_test(cx);
 3475    cx.update(|cx| {
 3476        ThreadStore::init_global(cx);
 3477        ThreadMetadataStore::init_global(cx);
 3478        language_model::LanguageModelRegistry::test(cx);
 3479        prompt_store::init(cx);
 3480    });
 3481
 3482    let fs = FakeFs::new(cx.executor());
 3483
 3484    // Main repo with a linked worktree.
 3485    fs.insert_tree(
 3486        "/project",
 3487        serde_json::json!({
 3488            ".git": {},
 3489            "src": {},
 3490        }),
 3491    )
 3492    .await;
 3493
 3494    // Worktree checkout pointing back to the main repo.
 3495    fs.add_linked_worktree_for_repo(
 3496        Path::new("/project/.git"),
 3497        false,
 3498        git::repository::Worktree {
 3499            path: std::path::PathBuf::from("/wt-feature-a"),
 3500            ref_name: Some("refs/heads/feature-a".into()),
 3501            sha: "aaa".into(),
 3502            is_main: false,
 3503            is_bare: false,
 3504        },
 3505    )
 3506    .await;
 3507
 3508    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3509
 3510    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3511    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3512
 3513    main_project
 3514        .update(cx, |p, cx| p.git_scans_complete(cx))
 3515        .await;
 3516    worktree_project
 3517        .update(cx, |p, cx| p.git_scans_complete(cx))
 3518        .await;
 3519
 3520    // Create the MultiWorkspace with both projects.
 3521    let (multi_workspace, cx) =
 3522        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3523
 3524    let sidebar = setup_sidebar(&multi_workspace, cx);
 3525
 3526    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3527        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3528    });
 3529
 3530    // Add an agent panel to the worktree workspace so we can run a
 3531    // thread inside it.
 3532    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3533
 3534    // Switch back to the main workspace before setting up the sidebar.
 3535    multi_workspace.update_in(cx, |mw, window, cx| {
 3536        let workspace = mw.workspaces().next().unwrap().clone();
 3537        mw.activate(workspace, None, window, cx);
 3538    });
 3539
 3540    // Start a thread in the worktree workspace's panel and keep it
 3541    // generating (don't resolve it).
 3542    let connection = StubAgentConnection::new();
 3543    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 3544    send_message(&worktree_panel, cx);
 3545
 3546    let session_id = active_session_id(&worktree_panel, cx);
 3547
 3548    // Save metadata so the sidebar knows about this thread.
 3549    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3550
 3551    // Keep the thread generating by sending a chunk without ending
 3552    // the turn.
 3553    cx.update(|_, cx| {
 3554        connection.send_update(
 3555            session_id.clone(),
 3556            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 3557            cx,
 3558        );
 3559    });
 3560    cx.run_until_parked();
 3561
 3562    // The worktree thread should be absorbed under the main project
 3563    // and show live running status.
 3564    let entries = visible_entries_as_strings(&sidebar, cx);
 3565    assert_eq!(
 3566        entries,
 3567        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 3568    );
 3569}
 3570
 3571#[gpui::test]
 3572async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
 3573    agent_ui::test_support::init_test(cx);
 3574    cx.update(|cx| {
 3575        ThreadStore::init_global(cx);
 3576        ThreadMetadataStore::init_global(cx);
 3577        language_model::LanguageModelRegistry::test(cx);
 3578        prompt_store::init(cx);
 3579    });
 3580
 3581    let fs = FakeFs::new(cx.executor());
 3582
 3583    fs.insert_tree(
 3584        "/project",
 3585        serde_json::json!({
 3586            ".git": {},
 3587            "src": {},
 3588        }),
 3589    )
 3590    .await;
 3591
 3592    fs.add_linked_worktree_for_repo(
 3593        Path::new("/project/.git"),
 3594        false,
 3595        git::repository::Worktree {
 3596            path: std::path::PathBuf::from("/wt-feature-a"),
 3597            ref_name: Some("refs/heads/feature-a".into()),
 3598            sha: "aaa".into(),
 3599            is_main: false,
 3600            is_bare: false,
 3601        },
 3602    )
 3603    .await;
 3604
 3605    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3606
 3607    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3608    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3609
 3610    main_project
 3611        .update(cx, |p, cx| p.git_scans_complete(cx))
 3612        .await;
 3613    worktree_project
 3614        .update(cx, |p, cx| p.git_scans_complete(cx))
 3615        .await;
 3616
 3617    let (multi_workspace, cx) =
 3618        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3619
 3620    let sidebar = setup_sidebar(&multi_workspace, cx);
 3621
 3622    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3623        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3624    });
 3625
 3626    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3627
 3628    multi_workspace.update_in(cx, |mw, window, cx| {
 3629        let workspace = mw.workspaces().next().unwrap().clone();
 3630        mw.activate(workspace, None, window, cx);
 3631    });
 3632
 3633    let connection = StubAgentConnection::new();
 3634    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 3635    send_message(&worktree_panel, cx);
 3636
 3637    let session_id = active_session_id(&worktree_panel, cx);
 3638    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3639
 3640    cx.update(|_, cx| {
 3641        connection.send_update(
 3642            session_id.clone(),
 3643            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 3644            cx,
 3645        );
 3646    });
 3647    cx.run_until_parked();
 3648
 3649    assert_eq!(
 3650        visible_entries_as_strings(&sidebar, cx),
 3651        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 3652    );
 3653
 3654    connection.end_turn(session_id, acp::StopReason::EndTurn);
 3655    cx.run_until_parked();
 3656
 3657    assert_eq!(
 3658        visible_entries_as_strings(&sidebar, cx),
 3659        vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
 3660    );
 3661}
 3662
 3663#[gpui::test]
 3664async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
 3665    init_test(cx);
 3666    let fs = FakeFs::new(cx.executor());
 3667
 3668    fs.insert_tree(
 3669        "/project",
 3670        serde_json::json!({
 3671            ".git": {},
 3672            "src": {},
 3673        }),
 3674    )
 3675    .await;
 3676
 3677    fs.add_linked_worktree_for_repo(
 3678        Path::new("/project/.git"),
 3679        false,
 3680        git::repository::Worktree {
 3681            path: std::path::PathBuf::from("/wt-feature-a"),
 3682            ref_name: Some("refs/heads/feature-a".into()),
 3683            sha: "aaa".into(),
 3684            is_main: false,
 3685            is_bare: false,
 3686        },
 3687    )
 3688    .await;
 3689
 3690    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3691
 3692    // Only open the main repo — no workspace for the worktree.
 3693    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3694    main_project
 3695        .update(cx, |p, cx| p.git_scans_complete(cx))
 3696        .await;
 3697
 3698    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3699    worktree_project
 3700        .update(cx, |p, cx| p.git_scans_complete(cx))
 3701        .await;
 3702
 3703    let (multi_workspace, cx) =
 3704        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3705    let sidebar = setup_sidebar(&multi_workspace, cx);
 3706
 3707    // Save a thread for the worktree path (no workspace for it).
 3708    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 3709
 3710    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3711    cx.run_until_parked();
 3712
 3713    // Thread should appear under the main repo with a worktree chip.
 3714    assert_eq!(
 3715        visible_entries_as_strings(&sidebar, cx),
 3716        vec![
 3717            //
 3718            "v [project]",
 3719            "  WT Thread {wt-feature-a}",
 3720        ],
 3721    );
 3722
 3723    // Only 1 workspace should exist.
 3724    assert_eq!(
 3725        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 3726        1,
 3727    );
 3728
 3729    // Focus the sidebar and select the worktree thread.
 3730    focus_sidebar(&sidebar, cx);
 3731    sidebar.update_in(cx, |sidebar, _window, _cx| {
 3732        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 3733    });
 3734
 3735    // Confirm to open the worktree thread.
 3736    cx.dispatch_action(Confirm);
 3737    cx.run_until_parked();
 3738
 3739    // A new workspace should have been created for the worktree path.
 3740    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
 3741        assert_eq!(
 3742            mw.workspaces().count(),
 3743            2,
 3744            "confirming a worktree thread without a workspace should open one",
 3745        );
 3746        mw.workspaces().nth(1).unwrap().clone()
 3747    });
 3748
 3749    let new_path_list =
 3750        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 3751    assert_eq!(
 3752        new_path_list,
 3753        PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
 3754        "the new workspace should have been opened for the worktree path",
 3755    );
 3756}
 3757
 3758#[gpui::test]
 3759async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
 3760    cx: &mut TestAppContext,
 3761) {
 3762    init_test(cx);
 3763    let fs = FakeFs::new(cx.executor());
 3764
 3765    fs.insert_tree(
 3766        "/project",
 3767        serde_json::json!({
 3768            ".git": {},
 3769            "src": {},
 3770        }),
 3771    )
 3772    .await;
 3773
 3774    fs.add_linked_worktree_for_repo(
 3775        Path::new("/project/.git"),
 3776        false,
 3777        git::repository::Worktree {
 3778            path: std::path::PathBuf::from("/wt-feature-a"),
 3779            ref_name: Some("refs/heads/feature-a".into()),
 3780            sha: "aaa".into(),
 3781            is_main: false,
 3782            is_bare: false,
 3783        },
 3784    )
 3785    .await;
 3786
 3787    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3788
 3789    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3790    main_project
 3791        .update(cx, |p, cx| p.git_scans_complete(cx))
 3792        .await;
 3793
 3794    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3795    worktree_project
 3796        .update(cx, |p, cx| p.git_scans_complete(cx))
 3797        .await;
 3798
 3799    let (multi_workspace, cx) =
 3800        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3801    let sidebar = setup_sidebar(&multi_workspace, cx);
 3802
 3803    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 3804
 3805    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3806    cx.run_until_parked();
 3807
 3808    assert_eq!(
 3809        visible_entries_as_strings(&sidebar, cx),
 3810        vec![
 3811            //
 3812            "v [project]",
 3813            "  WT Thread {wt-feature-a}",
 3814        ],
 3815    );
 3816
 3817    focus_sidebar(&sidebar, cx);
 3818    sidebar.update_in(cx, |sidebar, _window, _cx| {
 3819        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 3820    });
 3821
 3822    let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
 3823        let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
 3824            if let ListEntry::ProjectHeader { label, .. } = entry {
 3825                Some(label.as_ref())
 3826            } else {
 3827                None
 3828            }
 3829        });
 3830
 3831        let Some(project_header) = project_headers.next() else {
 3832            panic!("expected exactly one sidebar project header named `project`, found none");
 3833        };
 3834        assert_eq!(
 3835            project_header, "project",
 3836            "expected the only sidebar project header to be `project`"
 3837        );
 3838        if let Some(unexpected_header) = project_headers.next() {
 3839            panic!(
 3840                "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
 3841            );
 3842        }
 3843
 3844        let mut saw_expected_thread = false;
 3845        for entry in &sidebar.contents.entries {
 3846            match entry {
 3847                ListEntry::ProjectHeader { label, .. } => {
 3848                    assert_eq!(
 3849                        label.as_ref(),
 3850                        "project",
 3851                        "expected the only sidebar project header to be `project`"
 3852                    );
 3853                }
 3854                ListEntry::Thread(thread)
 3855                    if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
 3856                        && thread
 3857                            .worktrees
 3858                            .first()
 3859                            .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
 3860                            == Some("wt-feature-a") =>
 3861                {
 3862                    saw_expected_thread = true;
 3863                }
 3864                ListEntry::Thread(thread) => {
 3865                    let title = thread.metadata.display_title();
 3866                    let worktree_name = thread
 3867                        .worktrees
 3868                        .first()
 3869                        .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
 3870                        .unwrap_or("<none>");
 3871                    panic!(
 3872                        "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
 3873                        title, worktree_name
 3874                    );
 3875                }
 3876            }
 3877        }
 3878
 3879        assert!(
 3880            saw_expected_thread,
 3881            "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
 3882        );
 3883    };
 3884
 3885    sidebar
 3886        .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
 3887        .detach();
 3888
 3889    let window = cx.windows()[0];
 3890    cx.update_window(window, |_, window, cx| {
 3891        window.dispatch_action(Confirm.boxed_clone(), cx);
 3892    })
 3893    .unwrap();
 3894
 3895    cx.run_until_parked();
 3896
 3897    sidebar.update(cx, assert_sidebar_state);
 3898}
 3899
 3900#[gpui::test]
 3901async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
 3902    cx: &mut TestAppContext,
 3903) {
 3904    init_test(cx);
 3905    let fs = FakeFs::new(cx.executor());
 3906
 3907    fs.insert_tree(
 3908        "/project",
 3909        serde_json::json!({
 3910            ".git": {},
 3911            "src": {},
 3912        }),
 3913    )
 3914    .await;
 3915
 3916    fs.add_linked_worktree_for_repo(
 3917        Path::new("/project/.git"),
 3918        false,
 3919        git::repository::Worktree {
 3920            path: std::path::PathBuf::from("/wt-feature-a"),
 3921            ref_name: Some("refs/heads/feature-a".into()),
 3922            sha: "aaa".into(),
 3923            is_main: false,
 3924            is_bare: false,
 3925        },
 3926    )
 3927    .await;
 3928
 3929    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3930
 3931    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3932    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3933
 3934    main_project
 3935        .update(cx, |p, cx| p.git_scans_complete(cx))
 3936        .await;
 3937    worktree_project
 3938        .update(cx, |p, cx| p.git_scans_complete(cx))
 3939        .await;
 3940
 3941    let (multi_workspace, cx) =
 3942        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3943
 3944    let sidebar = setup_sidebar(&multi_workspace, cx);
 3945
 3946    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3947        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3948    });
 3949
 3950    // Activate the main workspace before setting up the sidebar.
 3951    let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3952        let workspace = mw.workspaces().next().unwrap().clone();
 3953        mw.activate(workspace.clone(), None, window, cx);
 3954        workspace
 3955    });
 3956
 3957    save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
 3958    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 3959
 3960    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3961    cx.run_until_parked();
 3962
 3963    // The worktree workspace should be absorbed under the main repo.
 3964    let entries = visible_entries_as_strings(&sidebar, cx);
 3965    assert_eq!(entries.len(), 3);
 3966    assert_eq!(entries[0], "v [project]");
 3967    assert!(entries.contains(&"  Main Thread".to_string()));
 3968    assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
 3969
 3970    let wt_thread_index = entries
 3971        .iter()
 3972        .position(|e| e.contains("WT Thread"))
 3973        .expect("should find the worktree thread entry");
 3974
 3975    assert_eq!(
 3976        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 3977        main_workspace,
 3978        "main workspace should be active initially"
 3979    );
 3980
 3981    // Focus the sidebar and select the absorbed worktree thread.
 3982    focus_sidebar(&sidebar, cx);
 3983    sidebar.update_in(cx, |sidebar, _window, _cx| {
 3984        sidebar.selection = Some(wt_thread_index);
 3985    });
 3986
 3987    // Confirm to activate the worktree thread.
 3988    cx.dispatch_action(Confirm);
 3989    cx.run_until_parked();
 3990
 3991    // The worktree workspace should now be active, not the main one.
 3992    let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 3993    assert_eq!(
 3994        active_workspace, worktree_workspace,
 3995        "clicking an absorbed worktree thread should activate the worktree workspace"
 3996    );
 3997}
 3998
 3999// Reproduces the core of the user-reported bug: a thread belonging to
 4000// a multi-root workspace that mixes a standalone project and a linked
 4001// git worktree can become invisible in the sidebar when its stored
 4002// `main_worktree_paths` don't match the workspace's project group
 4003// key. The metadata still exists and Thread History still shows it,
 4004// but the sidebar rebuild's lookups all miss.
 4005//
 4006// Real-world setup: a single multi-root workspace whose roots are
 4007// `[/cloud, /worktrees/zed/wt_a/zed]`, where:
 4008//   - `/cloud` is a standalone git repo (main == folder).
 4009//   - `/worktrees/zed/wt_a/zed` is a linked worktree of `/zed`.
 4010//
 4011// Once git scans complete the project group key is
 4012// `[/cloud, /zed]` — the main paths of the two roots. A thread
 4013// created in this workspace is written with
 4014// `main=[/cloud, /zed], folder=[/cloud, /worktrees/zed/wt_a/zed]`
 4015// and the sidebar finds it via `entries_for_main_worktree_path`.
 4016//
 4017// If some other code path (stale data on reload, a path-less archive
 4018// restored via the project picker, a legacy write …) persists the
 4019// thread with `main == folder` instead, the stored
 4020// `main_worktree_paths` is
 4021// `[/cloud, /worktrees/zed/wt_a/zed]` ≠ `[/cloud, /zed]`. The three
 4022// lookups in `rebuild_contents` all miss:
 4023//
 4024//   1. `entries_for_main_worktree_path([/cloud, /zed])` — the
 4025//      thread's stored main doesn't equal the group key.
 4026//   2. `entries_for_path([/cloud, /zed])` — the thread's folder paths
 4027//      don't equal the group key either.
 4028//   3. The linked-worktree fallback iterates the group's workspaces'
 4029//      `linked_worktrees()` snapshots. Those yield *sibling* linked
 4030//      worktrees of the repo, not the workspace's own roots, so the
 4031//      thread's folder `/worktrees/zed/wt_a/zed` doesn't match.
 4032//
 4033// The row falls out of the sidebar entirely — matching the user's
 4034// symptom of a thread visible in the agent panel but missing from
 4035// the sidebar. It only reappears once something re-writes the
 4036// thread's metadata in the good shape (e.g. `handle_conversation_event`
 4037// firing after the user sends a message).
 4038//
 4039// We directly persist the bad shape via `store.save(...)` rather
 4040// than trying to reproduce the original writer. The bug is
 4041// ultimately about the sidebar's tolerance for any stale row whose
 4042// folder paths correspond to an open workspace's roots, regardless
 4043// of how that row came to be in the store.
 4044#[gpui::test]
 4045async fn test_sidebar_keeps_multi_root_thread_with_stale_main_paths(cx: &mut TestAppContext) {
 4046    agent_ui::test_support::init_test(cx);
 4047    cx.update(|cx| {
 4048        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 4049        ThreadStore::init_global(cx);
 4050        ThreadMetadataStore::init_global(cx);
 4051        language_model::LanguageModelRegistry::test(cx);
 4052        prompt_store::init(cx);
 4053    });
 4054
 4055    let fs = FakeFs::new(cx.executor());
 4056
 4057    // Standalone repo — one of the workspace's two roots, main
 4058    // worktree of its own .git.
 4059    fs.insert_tree(
 4060        "/cloud",
 4061        serde_json::json!({
 4062            ".git": {},
 4063            "src": {},
 4064        }),
 4065    )
 4066    .await;
 4067
 4068    // Separate /zed repo whose linked worktree will form the second
 4069    // workspace root. /zed itself is NOT opened as a workspace root.
 4070    fs.insert_tree(
 4071        "/zed",
 4072        serde_json::json!({
 4073            ".git": {},
 4074            "src": {},
 4075        }),
 4076    )
 4077    .await;
 4078    fs.insert_tree(
 4079        "/worktrees/zed/wt_a/zed",
 4080        serde_json::json!({
 4081            ".git": "gitdir: /zed/.git/worktrees/wt_a",
 4082            "src": {},
 4083        }),
 4084    )
 4085    .await;
 4086    fs.add_linked_worktree_for_repo(
 4087        Path::new("/zed/.git"),
 4088        false,
 4089        git::repository::Worktree {
 4090            path: std::path::PathBuf::from("/worktrees/zed/wt_a/zed"),
 4091            ref_name: Some("refs/heads/wt_a".into()),
 4092            sha: "aaa".into(),
 4093            is_main: false,
 4094            is_bare: false,
 4095        },
 4096    )
 4097    .await;
 4098
 4099    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4100
 4101    // Single multi-root project with both /cloud and the linked
 4102    // worktree of /zed.
 4103    let project = project::Project::test(
 4104        fs.clone(),
 4105        ["/cloud".as_ref(), "/worktrees/zed/wt_a/zed".as_ref()],
 4106        cx,
 4107    )
 4108    .await;
 4109    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 4110
 4111    let (multi_workspace, cx) =
 4112        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 4113    let sidebar = setup_sidebar(&multi_workspace, cx);
 4114    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4115    let _panel = add_agent_panel(&workspace, cx);
 4116    cx.run_until_parked();
 4117
 4118    // Sanity-check the shapes the rest of the test depends on.
 4119    let group_key = workspace.read_with(cx, |ws, cx| ws.project_group_key(cx));
 4120    let expected_main_paths = PathList::new(&[PathBuf::from("/cloud"), PathBuf::from("/zed")]);
 4121    assert_eq!(
 4122        group_key.path_list(),
 4123        &expected_main_paths,
 4124        "expected the multi-root workspace's project group key to normalize to \
 4125         [/cloud, /zed] (main of the standalone repo + main of the linked worktree)"
 4126    );
 4127
 4128    let folder_paths = PathList::new(&[
 4129        PathBuf::from("/cloud"),
 4130        PathBuf::from("/worktrees/zed/wt_a/zed"),
 4131    ]);
 4132    let workspace_root_paths = workspace.read_with(cx, |ws, cx| PathList::new(&ws.root_paths(cx)));
 4133    assert_eq!(
 4134        workspace_root_paths, folder_paths,
 4135        "expected the workspace's root paths to equal [/cloud, /worktrees/zed/wt_a/zed]"
 4136    );
 4137
 4138    let session_id = acp::SessionId::new(Arc::from("multi-root-stale-paths"));
 4139    let thread_id = ThreadId::new();
 4140
 4141    // Persist the thread in the "bad" shape that the bug manifests as:
 4142    // main == folder for every root. Any stale row where
 4143    // `main_worktree_paths` no longer equals the group key produces
 4144    // the same user-visible symptom; this is the concrete shape
 4145    // produced by `WorktreePaths::from_folder_paths` on the workspace
 4146    // roots.
 4147    cx.update(|_, cx| {
 4148        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 4149            store.save(
 4150                ThreadMetadata {
 4151                    thread_id,
 4152                    session_id: Some(session_id.clone()),
 4153                    agent_id: agent::ZED_AGENT_ID.clone(),
 4154                    title: Some("Stale Multi-Root Thread".into()),
 4155                    updated_at: Utc::now(),
 4156                    created_at: None,
 4157                    interacted_at: None,
 4158                    worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
 4159                    archived: false,
 4160                    remote_connection: None,
 4161                },
 4162                cx,
 4163            )
 4164        });
 4165    });
 4166    cx.run_until_parked();
 4167
 4168    let entries = visible_entries_as_strings(&sidebar, cx);
 4169    let visible = sidebar.read_with(cx, |sidebar, _cx| has_thread_entry(sidebar, &session_id));
 4170
 4171    // If this assert fails, we've reproduced the bug: the sidebar's
 4172    // rebuild queries can't locate the thread under the current
 4173    // project group, even though the metadata is intact and the
 4174    // thread's folder paths exactly equal the open workspace's roots.
 4175    assert!(
 4176        visible,
 4177        "thread disappeared from the sidebar when its main_worktree_paths \
 4178         ({folder_paths:?}) diverged from the project group key ({expected_main_paths:?}); \
 4179         sidebar entries: {entries:?}"
 4180    );
 4181}
 4182
 4183#[gpui::test]
 4184async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
 4185    cx: &mut TestAppContext,
 4186) {
 4187    // Thread has saved metadata in ThreadStore. A matching workspace is
 4188    // already open. Expected: activates the matching workspace.
 4189    init_test(cx);
 4190    let fs = FakeFs::new(cx.executor());
 4191    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4192        .await;
 4193    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4194        .await;
 4195    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4196
 4197    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4198    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4199
 4200    let (multi_workspace, cx) =
 4201        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 4202
 4203    let sidebar = setup_sidebar(&multi_workspace, cx);
 4204
 4205    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4206        mw.test_add_workspace(project_b.clone(), window, cx)
 4207    });
 4208    let workspace_a =
 4209        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4210
 4211    // Save a thread with path_list pointing to project-b.
 4212    let session_id = acp::SessionId::new(Arc::from("archived-1"));
 4213    save_test_thread_metadata(&session_id, &project_b, cx).await;
 4214
 4215    // Ensure workspace A is active.
 4216    multi_workspace.update_in(cx, |mw, window, cx| {
 4217        let workspace = mw.workspaces().next().unwrap().clone();
 4218        mw.activate(workspace, None, window, cx);
 4219    });
 4220    cx.run_until_parked();
 4221    assert_eq!(
 4222        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4223        workspace_a
 4224    );
 4225
 4226    // Call activate_archived_thread – should resolve saved paths and
 4227    // switch to the workspace for project-b.
 4228    sidebar.update_in(cx, |sidebar, window, cx| {
 4229        sidebar.open_thread_from_archive(
 4230            ThreadMetadata {
 4231                thread_id: ThreadId::new(),
 4232                session_id: Some(session_id.clone()),
 4233                agent_id: agent::ZED_AGENT_ID.clone(),
 4234                title: Some("Archived Thread".into()),
 4235                updated_at: Utc::now(),
 4236                created_at: None,
 4237                interacted_at: None,
 4238                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4239                    "/project-b",
 4240                )])),
 4241                archived: false,
 4242                remote_connection: None,
 4243            },
 4244            window,
 4245            cx,
 4246        );
 4247    });
 4248    cx.run_until_parked();
 4249
 4250    assert_eq!(
 4251        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4252        workspace_b,
 4253        "should have switched to the workspace matching the saved paths"
 4254    );
 4255}
 4256
 4257#[gpui::test]
 4258async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
 4259    cx: &mut TestAppContext,
 4260) {
 4261    // Thread has no saved metadata but session_info has cwd. A matching
 4262    // workspace is open. Expected: uses cwd to find and activate it.
 4263    init_test(cx);
 4264    let fs = FakeFs::new(cx.executor());
 4265    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4266        .await;
 4267    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4268        .await;
 4269    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4270
 4271    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4272    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4273
 4274    let (multi_workspace, cx) =
 4275        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4276
 4277    let sidebar = setup_sidebar(&multi_workspace, cx);
 4278
 4279    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4280        mw.test_add_workspace(project_b, window, cx)
 4281    });
 4282    let workspace_a =
 4283        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4284
 4285    // Start with workspace A active.
 4286    multi_workspace.update_in(cx, |mw, window, cx| {
 4287        let workspace = mw.workspaces().next().unwrap().clone();
 4288        mw.activate(workspace, None, window, cx);
 4289    });
 4290    cx.run_until_parked();
 4291    assert_eq!(
 4292        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4293        workspace_a
 4294    );
 4295
 4296    // No thread saved to the store – cwd is the only path hint.
 4297    sidebar.update_in(cx, |sidebar, window, cx| {
 4298        sidebar.open_thread_from_archive(
 4299            ThreadMetadata {
 4300                thread_id: ThreadId::new(),
 4301                session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
 4302                agent_id: agent::ZED_AGENT_ID.clone(),
 4303                title: Some("CWD Thread".into()),
 4304                updated_at: Utc::now(),
 4305                created_at: None,
 4306                interacted_at: None,
 4307                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 4308                    std::path::PathBuf::from("/project-b"),
 4309                ])),
 4310                archived: false,
 4311                remote_connection: None,
 4312            },
 4313            window,
 4314            cx,
 4315        );
 4316    });
 4317    cx.run_until_parked();
 4318
 4319    assert_eq!(
 4320        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4321        workspace_b,
 4322        "should have activated the workspace matching the cwd"
 4323    );
 4324}
 4325
 4326#[gpui::test]
 4327async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
 4328    cx: &mut TestAppContext,
 4329) {
 4330    // Thread has no saved metadata and no cwd. Expected: falls back to
 4331    // the currently active workspace.
 4332    init_test(cx);
 4333    let fs = FakeFs::new(cx.executor());
 4334    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4335        .await;
 4336    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4337        .await;
 4338    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4339
 4340    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4341    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4342
 4343    let (multi_workspace, cx) =
 4344        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4345
 4346    let sidebar = setup_sidebar(&multi_workspace, cx);
 4347
 4348    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4349        mw.test_add_workspace(project_b, window, cx)
 4350    });
 4351
 4352    // Activate workspace B (index 1) to make it the active one.
 4353    multi_workspace.update_in(cx, |mw, window, cx| {
 4354        let workspace = mw.workspaces().nth(1).unwrap().clone();
 4355        mw.activate(workspace, None, window, cx);
 4356    });
 4357    cx.run_until_parked();
 4358    assert_eq!(
 4359        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4360        workspace_b
 4361    );
 4362
 4363    // No saved thread, no cwd – should fall back to the active workspace.
 4364    sidebar.update_in(cx, |sidebar, window, cx| {
 4365        sidebar.open_thread_from_archive(
 4366            ThreadMetadata {
 4367                thread_id: ThreadId::new(),
 4368                session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
 4369                agent_id: agent::ZED_AGENT_ID.clone(),
 4370                title: Some("Contextless Thread".into()),
 4371                updated_at: Utc::now(),
 4372                created_at: None,
 4373                interacted_at: None,
 4374                worktree_paths: WorktreePaths::default(),
 4375                archived: false,
 4376                remote_connection: None,
 4377            },
 4378            window,
 4379            cx,
 4380        );
 4381    });
 4382    cx.run_until_parked();
 4383
 4384    assert_eq!(
 4385        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4386        workspace_b,
 4387        "should have stayed on the active workspace when no path info is available"
 4388    );
 4389}
 4390
 4391#[gpui::test]
 4392async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
 4393    // Thread has saved metadata pointing to a path with no open workspace.
 4394    // Expected: opens a new workspace for that path.
 4395    init_test(cx);
 4396    let fs = FakeFs::new(cx.executor());
 4397    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4398        .await;
 4399    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4400        .await;
 4401    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4402
 4403    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4404
 4405    let (multi_workspace, cx) =
 4406        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4407
 4408    let sidebar = setup_sidebar(&multi_workspace, cx);
 4409
 4410    // Save a thread with path_list pointing to project-b – which has no
 4411    // open workspace.
 4412    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 4413    let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
 4414
 4415    assert_eq!(
 4416        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4417        1,
 4418        "should start with one workspace"
 4419    );
 4420
 4421    sidebar.update_in(cx, |sidebar, window, cx| {
 4422        sidebar.open_thread_from_archive(
 4423            ThreadMetadata {
 4424                thread_id: ThreadId::new(),
 4425                session_id: Some(session_id.clone()),
 4426                agent_id: agent::ZED_AGENT_ID.clone(),
 4427                title: Some("New WS Thread".into()),
 4428                updated_at: Utc::now(),
 4429                created_at: None,
 4430                interacted_at: None,
 4431                worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 4432                archived: false,
 4433                remote_connection: None,
 4434            },
 4435            window,
 4436            cx,
 4437        );
 4438    });
 4439    cx.run_until_parked();
 4440
 4441    assert_eq!(
 4442        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4443        2,
 4444        "should have opened a second workspace for the archived thread's saved paths"
 4445    );
 4446}
 4447
 4448#[gpui::test]
 4449async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
 4450    init_test(cx);
 4451    let fs = FakeFs::new(cx.executor());
 4452    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4453        .await;
 4454    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4455        .await;
 4456    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4457
 4458    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4459    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4460
 4461    let multi_workspace_a =
 4462        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4463    let multi_workspace_b =
 4464        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4465
 4466    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4467    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4468
 4469    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4470    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4471
 4472    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4473    let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4474
 4475    let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
 4476
 4477    sidebar.update_in(cx_a, |sidebar, window, cx| {
 4478        sidebar.open_thread_from_archive(
 4479            ThreadMetadata {
 4480                thread_id: ThreadId::new(),
 4481                session_id: Some(session_id.clone()),
 4482                agent_id: agent::ZED_AGENT_ID.clone(),
 4483                title: Some("Cross Window Thread".into()),
 4484                updated_at: Utc::now(),
 4485                created_at: None,
 4486                interacted_at: None,
 4487                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4488                    "/project-b",
 4489                )])),
 4490                archived: false,
 4491                remote_connection: None,
 4492            },
 4493            window,
 4494            cx,
 4495        );
 4496    });
 4497    cx_a.run_until_parked();
 4498
 4499    assert_eq!(
 4500        multi_workspace_a
 4501            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4502            .unwrap(),
 4503        1,
 4504        "should not add the other window's workspace into the current window"
 4505    );
 4506    assert_eq!(
 4507        multi_workspace_b
 4508            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4509            .unwrap(),
 4510        1,
 4511        "should reuse the existing workspace in the other window"
 4512    );
 4513    assert!(
 4514        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4515        "should activate the window that already owns the matching workspace"
 4516    );
 4517    sidebar.read_with(cx_a, |sidebar, _| {
 4518            assert!(
 4519                !is_active_session(&sidebar, &session_id),
 4520                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4521            );
 4522        });
 4523}
 4524
 4525#[gpui::test]
 4526async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
 4527    cx: &mut TestAppContext,
 4528) {
 4529    init_test(cx);
 4530    let fs = FakeFs::new(cx.executor());
 4531    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4532        .await;
 4533    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4534        .await;
 4535    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4536
 4537    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4538    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4539
 4540    let multi_workspace_a =
 4541        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4542    let multi_workspace_b =
 4543        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
 4544
 4545    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4546    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4547
 4548    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4549    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4550
 4551    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4552    let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4553    let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
 4554    let _panel_b = add_agent_panel(&workspace_b, cx_b);
 4555
 4556    let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
 4557
 4558    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4559        sidebar.open_thread_from_archive(
 4560            ThreadMetadata {
 4561                thread_id: ThreadId::new(),
 4562                session_id: Some(session_id.clone()),
 4563                agent_id: agent::ZED_AGENT_ID.clone(),
 4564                title: Some("Cross Window Thread".into()),
 4565                updated_at: Utc::now(),
 4566                created_at: None,
 4567                interacted_at: None,
 4568                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4569                    "/project-b",
 4570                )])),
 4571                archived: false,
 4572                remote_connection: None,
 4573            },
 4574            window,
 4575            cx,
 4576        );
 4577    });
 4578    cx_a.run_until_parked();
 4579
 4580    assert_eq!(
 4581        multi_workspace_a
 4582            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4583            .unwrap(),
 4584        1,
 4585        "should not add the other window's workspace into the current window"
 4586    );
 4587    assert_eq!(
 4588        multi_workspace_b
 4589            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4590            .unwrap(),
 4591        1,
 4592        "should reuse the existing workspace in the other window"
 4593    );
 4594    assert!(
 4595        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4596        "should activate the window that already owns the matching workspace"
 4597    );
 4598    sidebar_a.read_with(cx_a, |sidebar, _| {
 4599            assert!(
 4600                !is_active_session(&sidebar, &session_id),
 4601                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4602            );
 4603        });
 4604    sidebar_b.read_with(cx_b, |sidebar, _| {
 4605        assert_active_thread(
 4606            sidebar,
 4607            &session_id,
 4608            "target window's sidebar should eagerly focus the activated archived thread",
 4609        );
 4610    });
 4611}
 4612
 4613#[gpui::test]
 4614async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
 4615    cx: &mut TestAppContext,
 4616) {
 4617    init_test(cx);
 4618    let fs = FakeFs::new(cx.executor());
 4619    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4620        .await;
 4621    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4622
 4623    let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4624    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4625
 4626    let multi_workspace_b =
 4627        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4628    let multi_workspace_a =
 4629        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4630
 4631    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4632    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4633
 4634    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4635    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4636
 4637    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4638    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4639
 4640    let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
 4641
 4642    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4643        sidebar.open_thread_from_archive(
 4644            ThreadMetadata {
 4645                thread_id: ThreadId::new(),
 4646                session_id: Some(session_id.clone()),
 4647                agent_id: agent::ZED_AGENT_ID.clone(),
 4648                title: Some("Current Window Thread".into()),
 4649                updated_at: Utc::now(),
 4650                created_at: None,
 4651                interacted_at: None,
 4652                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4653                    "/project-a",
 4654                )])),
 4655                archived: false,
 4656                remote_connection: None,
 4657            },
 4658            window,
 4659            cx,
 4660        );
 4661    });
 4662    cx_a.run_until_parked();
 4663
 4664    assert!(
 4665        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
 4666        "should keep activation in the current window when it already has a matching workspace"
 4667    );
 4668    sidebar_a.read_with(cx_a, |sidebar, _| {
 4669        assert_active_thread(
 4670            sidebar,
 4671            &session_id,
 4672            "current window's sidebar should eagerly focus the activated archived thread",
 4673        );
 4674    });
 4675    assert_eq!(
 4676        multi_workspace_a
 4677            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4678            .unwrap(),
 4679        1,
 4680        "current window should continue reusing its existing workspace"
 4681    );
 4682    assert_eq!(
 4683        multi_workspace_b
 4684            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4685            .unwrap(),
 4686        1,
 4687        "other windows should not be activated just because they also match the saved paths"
 4688    );
 4689}
 4690
 4691#[gpui::test]
 4692async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
 4693    // Regression test: archive_thread previously always loaded the next thread
 4694    // through group_workspace (the main workspace's ProjectHeader), even when
 4695    // the next thread belonged to an absorbed linked-worktree workspace. That
 4696    // caused the worktree thread to be loaded in the main panel, which bound it
 4697    // to the main project and corrupted its stored folder_paths.
 4698    //
 4699    // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
 4700    // falling back to group_workspace only for Closed workspaces.
 4701    agent_ui::test_support::init_test(cx);
 4702    cx.update(|cx| {
 4703        ThreadStore::init_global(cx);
 4704        ThreadMetadataStore::init_global(cx);
 4705        language_model::LanguageModelRegistry::test(cx);
 4706        prompt_store::init(cx);
 4707    });
 4708
 4709    let fs = FakeFs::new(cx.executor());
 4710
 4711    fs.insert_tree(
 4712        "/project",
 4713        serde_json::json!({
 4714            ".git": {},
 4715            "src": {},
 4716        }),
 4717    )
 4718    .await;
 4719
 4720    fs.add_linked_worktree_for_repo(
 4721        Path::new("/project/.git"),
 4722        false,
 4723        git::repository::Worktree {
 4724            path: std::path::PathBuf::from("/wt-feature-a"),
 4725            ref_name: Some("refs/heads/feature-a".into()),
 4726            sha: "aaa".into(),
 4727            is_main: false,
 4728            is_bare: false,
 4729        },
 4730    )
 4731    .await;
 4732
 4733    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4734
 4735    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4736    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4737
 4738    main_project
 4739        .update(cx, |p, cx| p.git_scans_complete(cx))
 4740        .await;
 4741    worktree_project
 4742        .update(cx, |p, cx| p.git_scans_complete(cx))
 4743        .await;
 4744
 4745    let (multi_workspace, cx) =
 4746        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4747
 4748    let sidebar = setup_sidebar(&multi_workspace, cx);
 4749
 4750    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4751        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4752    });
 4753
 4754    // Activate main workspace so the sidebar tracks the main panel.
 4755    multi_workspace.update_in(cx, |mw, window, cx| {
 4756        let workspace = mw.workspaces().next().unwrap().clone();
 4757        mw.activate(workspace, None, window, cx);
 4758    });
 4759
 4760    let main_workspace =
 4761        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4762    let main_panel = add_agent_panel(&main_workspace, cx);
 4763    let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
 4764
 4765    // Open Thread 2 in the main panel and keep it running.
 4766    let connection = StubAgentConnection::new();
 4767    open_thread_with_connection(&main_panel, connection.clone(), cx);
 4768    send_message(&main_panel, cx);
 4769
 4770    let thread2_session_id = active_session_id(&main_panel, cx);
 4771
 4772    cx.update(|_, cx| {
 4773        connection.send_update(
 4774            thread2_session_id.clone(),
 4775            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 4776            cx,
 4777        );
 4778    });
 4779
 4780    // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
 4781    save_thread_metadata(
 4782        thread2_session_id.clone(),
 4783        Some("Thread 2".into()),
 4784        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 4785        None,
 4786        None,
 4787        &main_project,
 4788        cx,
 4789    );
 4790
 4791    // Save thread 1's metadata with the worktree path and an older timestamp so
 4792    // it sorts below thread 2. archive_thread will find it as the "next" candidate.
 4793    let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
 4794    save_thread_metadata(
 4795        thread1_session_id,
 4796        Some("Thread 1".into()),
 4797        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 4798        None,
 4799        None,
 4800        &worktree_project,
 4801        cx,
 4802    );
 4803
 4804    cx.run_until_parked();
 4805
 4806    // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
 4807    let entries_before = visible_entries_as_strings(&sidebar, cx);
 4808    assert!(
 4809        entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
 4810        "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
 4811        entries_before
 4812    );
 4813
 4814    // The sidebar should track T2 as the focused thread (derived from the
 4815    // main panel's active view).
 4816    sidebar.read_with(cx, |s, _| {
 4817        assert_active_thread(
 4818            s,
 4819            &thread2_session_id,
 4820            "focused thread should be Thread 2 before archiving",
 4821        );
 4822    });
 4823
 4824    // Archive thread 2.
 4825    sidebar.update_in(cx, |sidebar, window, cx| {
 4826        sidebar.archive_thread(&thread2_session_id, window, cx);
 4827    });
 4828
 4829    cx.run_until_parked();
 4830
 4831    // The main panel's active thread must still be thread 2.
 4832    let main_active = main_panel.read_with(cx, |panel, cx| {
 4833        panel
 4834            .active_agent_thread(cx)
 4835            .map(|t| t.read(cx).session_id().clone())
 4836    });
 4837    assert_eq!(
 4838        main_active,
 4839        Some(thread2_session_id.clone()),
 4840        "main panel should not have been taken over by loading the linked-worktree thread T1; \
 4841             before the fix, archive_thread used group_workspace instead of next.workspace, \
 4842             causing T1 to be loaded in the wrong panel"
 4843    );
 4844
 4845    // Thread 1 should still appear in the sidebar with its worktree chip
 4846    // (Thread 2 was archived so it is gone from the list).
 4847    let entries_after = visible_entries_as_strings(&sidebar, cx);
 4848    assert!(
 4849        entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
 4850        "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
 4851        entries_after
 4852    );
 4853}
 4854
 4855#[gpui::test]
 4856async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
 4857    // When the last non-archived thread for a linked worktree is archived,
 4858    // the linked worktree workspace should be removed from the multi-workspace.
 4859    // The main worktree workspace should remain (it's always reachable via
 4860    // the project header).
 4861    init_test(cx);
 4862    let fs = FakeFs::new(cx.executor());
 4863
 4864    fs.insert_tree(
 4865        "/project",
 4866        serde_json::json!({
 4867            ".git": {
 4868                "worktrees": {
 4869                    "feature-a": {
 4870                        "commondir": "../../",
 4871                        "HEAD": "ref: refs/heads/feature-a",
 4872                    },
 4873                },
 4874            },
 4875            "src": {},
 4876        }),
 4877    )
 4878    .await;
 4879
 4880    fs.insert_tree(
 4881        "/worktrees/project/feature-a/project",
 4882        serde_json::json!({
 4883            ".git": "gitdir: /project/.git/worktrees/feature-a",
 4884            "src": {},
 4885        }),
 4886    )
 4887    .await;
 4888
 4889    fs.add_linked_worktree_for_repo(
 4890        Path::new("/project/.git"),
 4891        false,
 4892        git::repository::Worktree {
 4893            path: PathBuf::from("/worktrees/project/feature-a/project"),
 4894            ref_name: Some("refs/heads/feature-a".into()),
 4895            sha: "abc".into(),
 4896            is_main: false,
 4897            is_bare: false,
 4898        },
 4899    )
 4900    .await;
 4901
 4902    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4903
 4904    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4905    let worktree_project = project::Project::test(
 4906        fs.clone(),
 4907        ["/worktrees/project/feature-a/project".as_ref()],
 4908        cx,
 4909    )
 4910    .await;
 4911
 4912    main_project
 4913        .update(cx, |p, cx| p.git_scans_complete(cx))
 4914        .await;
 4915    worktree_project
 4916        .update(cx, |p, cx| p.git_scans_complete(cx))
 4917        .await;
 4918
 4919    let (multi_workspace, cx) =
 4920        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4921    let sidebar = setup_sidebar(&multi_workspace, cx);
 4922
 4923    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4924        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4925    });
 4926
 4927    // Save a thread for the main project.
 4928    save_thread_metadata(
 4929        acp::SessionId::new(Arc::from("main-thread")),
 4930        Some("Main Thread".into()),
 4931        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 4932        None,
 4933        None,
 4934        &main_project,
 4935        cx,
 4936    );
 4937
 4938    // Save a thread for the linked worktree.
 4939    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 4940    save_thread_metadata(
 4941        wt_thread_id.clone(),
 4942        Some("Worktree Thread".into()),
 4943        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 4944        None,
 4945        None,
 4946        &worktree_project,
 4947        cx,
 4948    );
 4949    cx.run_until_parked();
 4950
 4951    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4952    cx.run_until_parked();
 4953
 4954    // Should have 2 workspaces.
 4955    assert_eq!(
 4956        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4957        2,
 4958        "should start with 2 workspaces (main + linked worktree)"
 4959    );
 4960
 4961    // Archive the worktree thread (the only thread for /wt-feature-a).
 4962    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 4963        sidebar.archive_thread(&wt_thread_id, window, cx);
 4964    });
 4965
 4966    // archive_thread spawns a multi-layered chain of tasks (workspace
 4967    // removal → git persist → disk removal), each of which may spawn
 4968    // further background work. Each run_until_parked() call drives one
 4969    // layer of pending work.
 4970
 4971    cx.run_until_parked();
 4972
 4973    // The linked worktree workspace should have been removed.
 4974    assert_eq!(
 4975        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4976        1,
 4977        "linked worktree workspace should be removed after archiving its last thread"
 4978    );
 4979
 4980    // The linked worktree checkout directory should also be removed from disk.
 4981    assert!(
 4982        !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
 4983            .await,
 4984        "linked worktree directory should be removed from disk after archiving its last thread"
 4985    );
 4986
 4987    // The main thread should still be visible.
 4988    let entries = visible_entries_as_strings(&sidebar, cx);
 4989    assert!(
 4990        entries.iter().any(|e| e.contains("Main Thread")),
 4991        "main thread should still be visible: {entries:?}"
 4992    );
 4993    assert!(
 4994        !entries.iter().any(|e| e.contains("Worktree Thread")),
 4995        "archived worktree thread should not be visible: {entries:?}"
 4996    );
 4997
 4998    // The archived thread must retain its folder_paths so it can be
 4999    // restored to the correct workspace later.
 5000    let wt_thread_id = cx.update(|_window, cx| {
 5001        ThreadMetadataStore::global(cx)
 5002            .read(cx)
 5003            .entry_by_session(&wt_thread_id)
 5004            .unwrap()
 5005            .thread_id
 5006    });
 5007    let archived_paths = cx.update(|_window, cx| {
 5008        ThreadMetadataStore::global(cx)
 5009            .read(cx)
 5010            .entry(wt_thread_id)
 5011            .unwrap()
 5012            .folder_paths()
 5013            .clone()
 5014    });
 5015    assert_eq!(
 5016        archived_paths.paths(),
 5017        &[PathBuf::from("/worktrees/project/feature-a/project")],
 5018        "archived thread must retain its folder_paths for restore"
 5019    );
 5020}
 5021
 5022#[gpui::test]
 5023async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
 5024    // restore_worktree_via_git should succeed when the branch has moved
 5025    // to a different SHA since archival. The worktree stays in detached
 5026    // HEAD and the moved branch is left untouched.
 5027    init_test(cx);
 5028    let fs = FakeFs::new(cx.executor());
 5029
 5030    fs.insert_tree(
 5031        "/project",
 5032        serde_json::json!({
 5033            ".git": {
 5034                "worktrees": {
 5035                    "feature-a": {
 5036                        "commondir": "../../",
 5037                        "HEAD": "ref: refs/heads/feature-a",
 5038                    },
 5039                },
 5040            },
 5041            "src": {},
 5042        }),
 5043    )
 5044    .await;
 5045    fs.insert_tree(
 5046        "/wt-feature-a",
 5047        serde_json::json!({
 5048            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5049            "src": {},
 5050        }),
 5051    )
 5052    .await;
 5053    fs.add_linked_worktree_for_repo(
 5054        Path::new("/project/.git"),
 5055        false,
 5056        git::repository::Worktree {
 5057            path: PathBuf::from("/wt-feature-a"),
 5058            ref_name: Some("refs/heads/feature-a".into()),
 5059            sha: "original-sha".into(),
 5060            is_main: false,
 5061            is_bare: false,
 5062        },
 5063    )
 5064    .await;
 5065    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5066
 5067    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5068    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5069    main_project
 5070        .update(cx, |p, cx| p.git_scans_complete(cx))
 5071        .await;
 5072    worktree_project
 5073        .update(cx, |p, cx| p.git_scans_complete(cx))
 5074        .await;
 5075
 5076    let (multi_workspace, _cx) =
 5077        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5078    multi_workspace.update_in(_cx, |mw, window, cx| {
 5079        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5080    });
 5081
 5082    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5083        project.repositories(cx).values().next().unwrap().clone()
 5084    });
 5085    let (staged_hash, unstaged_hash) = cx
 5086        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5087        .await
 5088        .unwrap()
 5089        .unwrap();
 5090
 5091    // Move the branch to a different SHA.
 5092    fs.with_git_state(Path::new("/project/.git"), false, |state| {
 5093        state
 5094            .refs
 5095            .insert("refs/heads/feature-a".into(), "moved-sha".into());
 5096    })
 5097    .unwrap();
 5098
 5099    let result = cx
 5100        .spawn(|mut cx| async move {
 5101            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5102                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5103                    id: 1,
 5104                    worktree_path: PathBuf::from("/wt-feature-a"),
 5105                    main_repo_path: PathBuf::from("/project"),
 5106                    branch_name: Some("feature-a".to_string()),
 5107                    staged_commit_hash: staged_hash,
 5108                    unstaged_commit_hash: unstaged_hash,
 5109                    original_commit_hash: "original-sha".to_string(),
 5110                },
 5111                None,
 5112                &mut cx,
 5113            )
 5114            .await
 5115        })
 5116        .await;
 5117
 5118    assert!(
 5119        result.is_ok(),
 5120        "restore should succeed even when branch has moved: {:?}",
 5121        result.err()
 5122    );
 5123
 5124    // The moved branch ref should be completely untouched.
 5125    let branch_sha = fs
 5126        .with_git_state(Path::new("/project/.git"), false, |state| {
 5127            state.refs.get("refs/heads/feature-a").cloned()
 5128        })
 5129        .unwrap();
 5130    assert_eq!(
 5131        branch_sha.as_deref(),
 5132        Some("moved-sha"),
 5133        "the moved branch ref should not be modified by the restore"
 5134    );
 5135}
 5136
 5137#[gpui::test]
 5138async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
 5139    // restore_worktree_via_git should succeed when the branch still
 5140    // points at the same SHA as at archive time.
 5141    init_test(cx);
 5142    let fs = FakeFs::new(cx.executor());
 5143
 5144    fs.insert_tree(
 5145        "/project",
 5146        serde_json::json!({
 5147            ".git": {
 5148                "worktrees": {
 5149                    "feature-b": {
 5150                        "commondir": "../../",
 5151                        "HEAD": "ref: refs/heads/feature-b",
 5152                    },
 5153                },
 5154            },
 5155            "src": {},
 5156        }),
 5157    )
 5158    .await;
 5159    fs.insert_tree(
 5160        "/wt-feature-b",
 5161        serde_json::json!({
 5162            ".git": "gitdir: /project/.git/worktrees/feature-b",
 5163            "src": {},
 5164        }),
 5165    )
 5166    .await;
 5167    fs.add_linked_worktree_for_repo(
 5168        Path::new("/project/.git"),
 5169        false,
 5170        git::repository::Worktree {
 5171            path: PathBuf::from("/wt-feature-b"),
 5172            ref_name: Some("refs/heads/feature-b".into()),
 5173            sha: "original-sha".into(),
 5174            is_main: false,
 5175            is_bare: false,
 5176        },
 5177    )
 5178    .await;
 5179    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5180
 5181    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5182    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 5183    main_project
 5184        .update(cx, |p, cx| p.git_scans_complete(cx))
 5185        .await;
 5186    worktree_project
 5187        .update(cx, |p, cx| p.git_scans_complete(cx))
 5188        .await;
 5189
 5190    let (multi_workspace, _cx) =
 5191        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5192    multi_workspace.update_in(_cx, |mw, window, cx| {
 5193        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5194    });
 5195
 5196    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5197        project.repositories(cx).values().next().unwrap().clone()
 5198    });
 5199    let (staged_hash, unstaged_hash) = cx
 5200        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5201        .await
 5202        .unwrap()
 5203        .unwrap();
 5204
 5205    // refs/heads/feature-b already points at "original-sha" (set by
 5206    // add_linked_worktree_for_repo), matching original_commit_hash.
 5207
 5208    let result = cx
 5209        .spawn(|mut cx| async move {
 5210            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5211                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5212                    id: 1,
 5213                    worktree_path: PathBuf::from("/wt-feature-b"),
 5214                    main_repo_path: PathBuf::from("/project"),
 5215                    branch_name: Some("feature-b".to_string()),
 5216                    staged_commit_hash: staged_hash,
 5217                    unstaged_commit_hash: unstaged_hash,
 5218                    original_commit_hash: "original-sha".to_string(),
 5219                },
 5220                None,
 5221                &mut cx,
 5222            )
 5223            .await
 5224        })
 5225        .await;
 5226
 5227    assert!(
 5228        result.is_ok(),
 5229        "restore should succeed when branch has not moved: {:?}",
 5230        result.err()
 5231    );
 5232}
 5233
 5234#[gpui::test]
 5235async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
 5236    // restore_worktree_via_git should succeed when the branch no longer
 5237    // exists (e.g. it was deleted while the thread was archived). The
 5238    // code should attempt to recreate the branch.
 5239    init_test(cx);
 5240    let fs = FakeFs::new(cx.executor());
 5241
 5242    fs.insert_tree(
 5243        "/project",
 5244        serde_json::json!({
 5245            ".git": {
 5246                "worktrees": {
 5247                    "feature-d": {
 5248                        "commondir": "../../",
 5249                        "HEAD": "ref: refs/heads/feature-d",
 5250                    },
 5251                },
 5252            },
 5253            "src": {},
 5254        }),
 5255    )
 5256    .await;
 5257    fs.insert_tree(
 5258        "/wt-feature-d",
 5259        serde_json::json!({
 5260            ".git": "gitdir: /project/.git/worktrees/feature-d",
 5261            "src": {},
 5262        }),
 5263    )
 5264    .await;
 5265    fs.add_linked_worktree_for_repo(
 5266        Path::new("/project/.git"),
 5267        false,
 5268        git::repository::Worktree {
 5269            path: PathBuf::from("/wt-feature-d"),
 5270            ref_name: Some("refs/heads/feature-d".into()),
 5271            sha: "original-sha".into(),
 5272            is_main: false,
 5273            is_bare: false,
 5274        },
 5275    )
 5276    .await;
 5277    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5278
 5279    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5280    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
 5281    main_project
 5282        .update(cx, |p, cx| p.git_scans_complete(cx))
 5283        .await;
 5284    worktree_project
 5285        .update(cx, |p, cx| p.git_scans_complete(cx))
 5286        .await;
 5287
 5288    let (multi_workspace, _cx) =
 5289        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5290    multi_workspace.update_in(_cx, |mw, window, cx| {
 5291        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5292    });
 5293
 5294    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5295        project.repositories(cx).values().next().unwrap().clone()
 5296    });
 5297    let (staged_hash, unstaged_hash) = cx
 5298        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5299        .await
 5300        .unwrap()
 5301        .unwrap();
 5302
 5303    // Remove the branch ref so change_branch will fail.
 5304    fs.with_git_state(Path::new("/project/.git"), false, |state| {
 5305        state.refs.remove("refs/heads/feature-d");
 5306    })
 5307    .unwrap();
 5308
 5309    let result = cx
 5310        .spawn(|mut cx| async move {
 5311            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5312                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5313                    id: 1,
 5314                    worktree_path: PathBuf::from("/wt-feature-d"),
 5315                    main_repo_path: PathBuf::from("/project"),
 5316                    branch_name: Some("feature-d".to_string()),
 5317                    staged_commit_hash: staged_hash,
 5318                    unstaged_commit_hash: unstaged_hash,
 5319                    original_commit_hash: "original-sha".to_string(),
 5320                },
 5321                None,
 5322                &mut cx,
 5323            )
 5324            .await
 5325        })
 5326        .await;
 5327
 5328    assert!(
 5329        result.is_ok(),
 5330        "restore should succeed when branch does not exist: {:?}",
 5331        result.err()
 5332    );
 5333}
 5334
 5335#[gpui::test]
 5336async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
 5337    // Activating an archived linked worktree thread whose directory has
 5338    // been deleted should reuse the existing main repo workspace, not
 5339    // create a new one. The provisional ProjectGroupKey must be derived
 5340    // from main_worktree_paths so that find_or_create_local_workspace
 5341    // matches the main repo workspace when the worktree path is absent.
 5342    init_test(cx);
 5343    let fs = FakeFs::new(cx.executor());
 5344
 5345    fs.insert_tree(
 5346        "/project",
 5347        serde_json::json!({
 5348            ".git": {
 5349                "worktrees": {
 5350                    "feature-c": {
 5351                        "commondir": "../../",
 5352                        "HEAD": "ref: refs/heads/feature-c",
 5353                    },
 5354                },
 5355            },
 5356            "src": {},
 5357        }),
 5358    )
 5359    .await;
 5360
 5361    fs.insert_tree(
 5362        "/wt-feature-c",
 5363        serde_json::json!({
 5364            ".git": "gitdir: /project/.git/worktrees/feature-c",
 5365            "src": {},
 5366        }),
 5367    )
 5368    .await;
 5369
 5370    fs.add_linked_worktree_for_repo(
 5371        Path::new("/project/.git"),
 5372        false,
 5373        git::repository::Worktree {
 5374            path: PathBuf::from("/wt-feature-c"),
 5375            ref_name: Some("refs/heads/feature-c".into()),
 5376            sha: "original-sha".into(),
 5377            is_main: false,
 5378            is_bare: false,
 5379        },
 5380    )
 5381    .await;
 5382
 5383    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5384
 5385    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5386    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
 5387
 5388    main_project
 5389        .update(cx, |p, cx| p.git_scans_complete(cx))
 5390        .await;
 5391    worktree_project
 5392        .update(cx, |p, cx| p.git_scans_complete(cx))
 5393        .await;
 5394
 5395    let (multi_workspace, cx) =
 5396        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5397    let sidebar = setup_sidebar(&multi_workspace, cx);
 5398
 5399    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5400        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5401    });
 5402
 5403    // Save thread metadata for the linked worktree.
 5404    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
 5405    save_thread_metadata(
 5406        wt_session_id.clone(),
 5407        Some("Worktree Thread C".into()),
 5408        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5409        None,
 5410        None,
 5411        &worktree_project,
 5412        cx,
 5413    );
 5414    cx.run_until_parked();
 5415
 5416    let thread_id = cx.update(|_window, cx| {
 5417        ThreadMetadataStore::global(cx)
 5418            .read(cx)
 5419            .entry_by_session(&wt_session_id)
 5420            .unwrap()
 5421            .thread_id
 5422    });
 5423
 5424    // Archive the thread without creating ArchivedGitWorktree records.
 5425    let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
 5426    cx.update(|_window, cx| {
 5427        store.update(cx, |store, cx| store.archive(thread_id, None, cx));
 5428    });
 5429    cx.run_until_parked();
 5430
 5431    // Remove the worktree workspace and delete the worktree from disk.
 5432    let main_workspace =
 5433        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 5434    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 5435        mw.remove(
 5436            vec![worktree_workspace],
 5437            move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
 5438            window,
 5439            cx,
 5440        )
 5441    });
 5442    remove_task.await.ok();
 5443    cx.run_until_parked();
 5444    cx.run_until_parked();
 5445    fs.remove_dir(
 5446        Path::new("/wt-feature-c"),
 5447        fs::RemoveOptions {
 5448            recursive: true,
 5449            ignore_if_not_exists: true,
 5450        },
 5451    )
 5452    .await
 5453    .unwrap();
 5454
 5455    let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 5456    assert_eq!(
 5457        workspace_count_before, 1,
 5458        "should have only the main workspace"
 5459    );
 5460
 5461    // Activate the archived thread. The worktree path is missing from
 5462    // disk, so find_or_create_local_workspace falls back to the
 5463    // provisional ProjectGroupKey to find a matching workspace.
 5464    let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
 5465    sidebar.update_in(cx, |sidebar, window, cx| {
 5466        sidebar.open_thread_from_archive(metadata, window, cx);
 5467    });
 5468    cx.run_until_parked();
 5469
 5470    // The provisional key should use [/project] (the main repo),
 5471    // which matches the existing main workspace. If it incorrectly
 5472    // used [/wt-feature-c] (the linked worktree path), no workspace
 5473    // would match and a spurious new one would be created.
 5474    let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 5475    assert_eq!(
 5476        workspace_count_after, 1,
 5477        "restoring a linked worktree thread should reuse the main repo workspace, \
 5478         not create a new one (workspace count went from {workspace_count_before} to \
 5479         {workspace_count_after})"
 5480    );
 5481}
 5482
 5483#[gpui::test]
 5484async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
 5485    cx: &mut TestAppContext,
 5486) {
 5487    // A remote thread at the same path as a local linked worktree thread
 5488    // should not prevent the local workspace from being removed when the
 5489    // local thread is archived (the last local thread for that worktree).
 5490    init_test(cx);
 5491    let fs = FakeFs::new(cx.executor());
 5492
 5493    fs.insert_tree(
 5494        "/project",
 5495        serde_json::json!({
 5496            ".git": {
 5497                "worktrees": {
 5498                    "feature-a": {
 5499                        "commondir": "../../",
 5500                        "HEAD": "ref: refs/heads/feature-a",
 5501                    },
 5502                },
 5503            },
 5504            "src": {},
 5505        }),
 5506    )
 5507    .await;
 5508
 5509    fs.insert_tree(
 5510        "/wt-feature-a",
 5511        serde_json::json!({
 5512            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5513            "src": {},
 5514        }),
 5515    )
 5516    .await;
 5517
 5518    fs.add_linked_worktree_for_repo(
 5519        Path::new("/project/.git"),
 5520        false,
 5521        git::repository::Worktree {
 5522            path: PathBuf::from("/wt-feature-a"),
 5523            ref_name: Some("refs/heads/feature-a".into()),
 5524            sha: "abc".into(),
 5525            is_main: false,
 5526            is_bare: false,
 5527        },
 5528    )
 5529    .await;
 5530
 5531    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5532
 5533    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5534    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5535
 5536    main_project
 5537        .update(cx, |p, cx| p.git_scans_complete(cx))
 5538        .await;
 5539    worktree_project
 5540        .update(cx, |p, cx| p.git_scans_complete(cx))
 5541        .await;
 5542
 5543    let (multi_workspace, cx) =
 5544        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5545    let sidebar = setup_sidebar(&multi_workspace, cx);
 5546
 5547    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5548        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5549    });
 5550
 5551    // Save a thread for the main project.
 5552    save_thread_metadata(
 5553        acp::SessionId::new(Arc::from("main-thread")),
 5554        Some("Main Thread".into()),
 5555        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5556        None,
 5557        None,
 5558        &main_project,
 5559        cx,
 5560    );
 5561
 5562    // Save a local thread for the linked worktree.
 5563    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 5564    save_thread_metadata(
 5565        wt_thread_id.clone(),
 5566        Some("Local Worktree Thread".into()),
 5567        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5568        None,
 5569        None,
 5570        &worktree_project,
 5571        cx,
 5572    );
 5573
 5574    // Save a remote thread at the same /wt-feature-a path but on a
 5575    // different host. This should NOT count as a remaining thread for
 5576    // the local linked worktree workspace.
 5577    let remote_host =
 5578        remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
 5579    cx.update(|_window, cx| {
 5580        let metadata = ThreadMetadata {
 5581            thread_id: ThreadId::new(),
 5582            session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
 5583            agent_id: agent::ZED_AGENT_ID.clone(),
 5584            title: Some("Remote Worktree Thread".into()),
 5585            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5586            created_at: None,
 5587            interacted_at: None,
 5588            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 5589                "/wt-feature-a",
 5590            )])),
 5591            archived: false,
 5592            remote_connection: Some(remote_host),
 5593        };
 5594        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 5595            store.save(metadata, cx);
 5596        });
 5597    });
 5598    cx.run_until_parked();
 5599
 5600    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5601    cx.run_until_parked();
 5602
 5603    assert_eq!(
 5604        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5605        2,
 5606        "should start with 2 workspaces (main + linked worktree)"
 5607    );
 5608
 5609    // The remote thread should NOT appear in the sidebar (it belongs
 5610    // to a different host and no matching remote project group exists).
 5611    let entries_before = visible_entries_as_strings(&sidebar, cx);
 5612    assert!(
 5613        !entries_before
 5614            .iter()
 5615            .any(|e| e.contains("Remote Worktree Thread")),
 5616        "remote thread should not appear in local sidebar: {entries_before:?}"
 5617    );
 5618
 5619    // Archive the local worktree thread.
 5620    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 5621        sidebar.archive_thread(&wt_thread_id, window, cx);
 5622    });
 5623
 5624    cx.run_until_parked();
 5625
 5626    // The linked worktree workspace should be removed because the
 5627    // only *local* thread for it was archived. The remote thread at
 5628    // the same path should not have prevented removal.
 5629    assert_eq!(
 5630        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5631        1,
 5632        "linked worktree workspace should be removed; the remote thread at the same path \
 5633         should not count as a remaining local thread"
 5634    );
 5635
 5636    let entries = visible_entries_as_strings(&sidebar, cx);
 5637    assert!(
 5638        entries.iter().any(|e| e.contains("Main Thread")),
 5639        "main thread should still be visible: {entries:?}"
 5640    );
 5641    assert!(
 5642        !entries.iter().any(|e| e.contains("Local Worktree Thread")),
 5643        "archived local worktree thread should not be visible: {entries:?}"
 5644    );
 5645    assert!(
 5646        !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
 5647        "remote thread should still not appear in local sidebar: {entries:?}"
 5648    );
 5649}
 5650
 5651#[gpui::test]
 5652async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
 5653    // When a multi-root workspace (e.g. [/other, /project]) shares a
 5654    // repo with a single-root workspace (e.g. [/project]), linked
 5655    // worktree threads from the shared repo should only appear under
 5656    // the dedicated group [project], not under [other, project].
 5657    agent_ui::test_support::init_test(cx);
 5658    cx.update(|cx| {
 5659        ThreadStore::init_global(cx);
 5660        ThreadMetadataStore::init_global(cx);
 5661        language_model::LanguageModelRegistry::test(cx);
 5662        prompt_store::init(cx);
 5663    });
 5664    let fs = FakeFs::new(cx.executor());
 5665
 5666    // Two independent repos, each with their own git history.
 5667    fs.insert_tree(
 5668        "/project",
 5669        serde_json::json!({
 5670            ".git": {},
 5671            "src": {},
 5672        }),
 5673    )
 5674    .await;
 5675    fs.insert_tree(
 5676        "/other",
 5677        serde_json::json!({
 5678            ".git": {},
 5679            "src": {},
 5680        }),
 5681    )
 5682    .await;
 5683
 5684    // Register the linked worktree in the main repo.
 5685    fs.add_linked_worktree_for_repo(
 5686        Path::new("/project/.git"),
 5687        false,
 5688        git::repository::Worktree {
 5689            path: std::path::PathBuf::from("/wt-feature-a"),
 5690            ref_name: Some("refs/heads/feature-a".into()),
 5691            sha: "aaa".into(),
 5692            is_main: false,
 5693            is_bare: false,
 5694        },
 5695    )
 5696    .await;
 5697
 5698    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5699
 5700    // Workspace 1: just /project.
 5701    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5702    project_only
 5703        .update(cx, |p, cx| p.git_scans_complete(cx))
 5704        .await;
 5705
 5706    // Workspace 2: /other and /project together (multi-root).
 5707    let multi_root =
 5708        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
 5709    multi_root
 5710        .update(cx, |p, cx| p.git_scans_complete(cx))
 5711        .await;
 5712
 5713    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5714    worktree_project
 5715        .update(cx, |p, cx| p.git_scans_complete(cx))
 5716        .await;
 5717
 5718    // Save a thread under the linked worktree path BEFORE setting up
 5719    // the sidebar and panels, so that reconciliation sees the [project]
 5720    // group as non-empty and doesn't create a spurious draft there.
 5721    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
 5722    save_thread_metadata(
 5723        wt_session_id,
 5724        Some("Worktree Thread".into()),
 5725        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5726        None,
 5727        None,
 5728        &worktree_project,
 5729        cx,
 5730    );
 5731
 5732    let (multi_workspace, cx) =
 5733        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
 5734    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5735    let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5736        mw.test_add_workspace(multi_root.clone(), window, cx)
 5737    });
 5738    add_agent_panel(&multi_root_workspace, cx);
 5739    cx.run_until_parked();
 5740
 5741    // The thread should appear only under [project] (the dedicated
 5742    // group for the /project repo), not under [other, project].
 5743    assert_eq!(
 5744        visible_entries_as_strings(&sidebar, cx),
 5745        vec![
 5746            //
 5747            "v [other, project]",
 5748            "v [project]",
 5749            "  Worktree Thread {wt-feature-a}",
 5750        ]
 5751    );
 5752}
 5753
 5754fn thread_id_for(session_id: &acp::SessionId, cx: &mut TestAppContext) -> ThreadId {
 5755    cx.read(|cx| {
 5756        ThreadMetadataStore::global(cx)
 5757            .read(cx)
 5758            .entry_by_session(session_id)
 5759            .map(|m| m.thread_id)
 5760            .expect("thread metadata should exist")
 5761    })
 5762}
 5763
 5764#[gpui::test]
 5765async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
 5766    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 5767    let (multi_workspace, cx) =
 5768        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 5769    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5770
 5771    let switcher_ids =
 5772        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<ThreadId> {
 5773            sidebar.read_with(cx, |sidebar, cx| {
 5774                let switcher = sidebar
 5775                    .thread_switcher
 5776                    .as_ref()
 5777                    .expect("switcher should be open");
 5778                switcher
 5779                    .read(cx)
 5780                    .entries()
 5781                    .iter()
 5782                    .map(|e| e.metadata.thread_id)
 5783                    .collect()
 5784            })
 5785        };
 5786
 5787    let switcher_selected_id =
 5788        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> ThreadId {
 5789            sidebar.read_with(cx, |sidebar, cx| {
 5790                let switcher = sidebar
 5791                    .thread_switcher
 5792                    .as_ref()
 5793                    .expect("switcher should be open");
 5794                let s = switcher.read(cx);
 5795                s.selected_entry()
 5796                    .expect("should have selection")
 5797                    .metadata
 5798                    .thread_id
 5799            })
 5800        };
 5801
 5802    // ── Setup: create three threads with distinct created_at times ──────
 5803    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
 5804    // We send messages in each so they also get last_message_sent_or_queued timestamps.
 5805    let connection_c = StubAgentConnection::new();
 5806    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5807        acp::ContentChunk::new("Done C".into()),
 5808    )]);
 5809    open_thread_with_connection(&panel, connection_c, cx);
 5810    send_message(&panel, cx);
 5811    let session_id_c = active_session_id(&panel, cx);
 5812    let thread_id_c = active_thread_id(&panel, cx);
 5813    save_thread_metadata(
 5814        session_id_c.clone(),
 5815        Some("Thread C".into()),
 5816        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5817        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
 5818        None,
 5819        &project,
 5820        cx,
 5821    );
 5822
 5823    let connection_b = StubAgentConnection::new();
 5824    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5825        acp::ContentChunk::new("Done B".into()),
 5826    )]);
 5827    open_thread_with_connection(&panel, connection_b, cx);
 5828    send_message(&panel, cx);
 5829    let session_id_b = active_session_id(&panel, cx);
 5830    let thread_id_b = active_thread_id(&panel, cx);
 5831    save_thread_metadata(
 5832        session_id_b.clone(),
 5833        Some("Thread B".into()),
 5834        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5835        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
 5836        None,
 5837        &project,
 5838        cx,
 5839    );
 5840
 5841    let connection_a = StubAgentConnection::new();
 5842    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5843        acp::ContentChunk::new("Done A".into()),
 5844    )]);
 5845    open_thread_with_connection(&panel, connection_a, cx);
 5846    send_message(&panel, cx);
 5847    let session_id_a = active_session_id(&panel, cx);
 5848    let thread_id_a = active_thread_id(&panel, cx);
 5849    save_thread_metadata(
 5850        session_id_a.clone(),
 5851        Some("Thread A".into()),
 5852        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
 5853        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
 5854        None,
 5855        &project,
 5856        cx,
 5857    );
 5858
 5859    // All three threads are now live. Thread A was opened last, so it's
 5860    // the one being viewed. Opening each thread called record_thread_access,
 5861    // so all three have last_accessed_at set.
 5862    // Access order is: A (most recent), B, C (oldest).
 5863
 5864    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
 5865    focus_sidebar(&sidebar, cx);
 5866    sidebar.update_in(cx, |sidebar, window, cx| {
 5867        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5868    });
 5869    cx.run_until_parked();
 5870
 5871    // All three have last_accessed_at, so they sort by access time.
 5872    // A was accessed most recently (it's the currently viewed thread),
 5873    // then B, then C.
 5874    assert_eq!(
 5875        switcher_ids(&sidebar, cx),
 5876        vec![thread_id_a, thread_id_b, thread_id_c,],
 5877    );
 5878    // First ctrl-tab selects the second entry (B).
 5879    assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_b);
 5880
 5881    // Dismiss the switcher without confirming.
 5882    sidebar.update_in(cx, |sidebar, _window, cx| {
 5883        sidebar.dismiss_thread_switcher(cx);
 5884    });
 5885    cx.run_until_parked();
 5886
 5887    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
 5888    sidebar.update_in(cx, |sidebar, window, cx| {
 5889        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5890    });
 5891    cx.run_until_parked();
 5892
 5893    // Cycle twice to land on Thread C (index 2).
 5894    sidebar.read_with(cx, |sidebar, cx| {
 5895        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5896        assert_eq!(switcher.read(cx).selected_index(), 1);
 5897    });
 5898    sidebar.update_in(cx, |sidebar, _window, cx| {
 5899        sidebar
 5900            .thread_switcher
 5901            .as_ref()
 5902            .unwrap()
 5903            .update(cx, |s, cx| s.cycle_selection(cx));
 5904    });
 5905    cx.run_until_parked();
 5906    assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_c);
 5907
 5908    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
 5909
 5910    // Confirm on Thread C.
 5911    sidebar.update_in(cx, |sidebar, window, cx| {
 5912        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5913        let focus = switcher.focus_handle(cx);
 5914        focus.dispatch_action(&menu::Confirm, window, cx);
 5915    });
 5916    cx.run_until_parked();
 5917
 5918    // Switcher should be dismissed after confirm.
 5919    sidebar.read_with(cx, |sidebar, _cx| {
 5920        assert!(
 5921            sidebar.thread_switcher.is_none(),
 5922            "switcher should be dismissed"
 5923        );
 5924    });
 5925
 5926    sidebar.update(cx, |sidebar, _cx| {
 5927        let last_accessed = sidebar
 5928            .thread_last_accessed
 5929            .keys()
 5930            .cloned()
 5931            .collect::<Vec<_>>();
 5932        assert_eq!(last_accessed.len(), 1);
 5933        assert!(last_accessed.contains(&thread_id_c));
 5934        assert!(
 5935            is_active_session(&sidebar, &session_id_c),
 5936            "active_entry should be Thread({session_id_c:?})"
 5937        );
 5938    });
 5939
 5940    sidebar.update_in(cx, |sidebar, window, cx| {
 5941        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5942    });
 5943    cx.run_until_parked();
 5944
 5945    assert_eq!(
 5946        switcher_ids(&sidebar, cx),
 5947        vec![thread_id_c, thread_id_a, thread_id_b],
 5948    );
 5949
 5950    // Confirm on Thread A.
 5951    sidebar.update_in(cx, |sidebar, window, cx| {
 5952        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5953        let focus = switcher.focus_handle(cx);
 5954        focus.dispatch_action(&menu::Confirm, window, cx);
 5955    });
 5956    cx.run_until_parked();
 5957
 5958    sidebar.update(cx, |sidebar, _cx| {
 5959        let last_accessed = sidebar
 5960            .thread_last_accessed
 5961            .keys()
 5962            .cloned()
 5963            .collect::<Vec<_>>();
 5964        assert_eq!(last_accessed.len(), 2);
 5965        assert!(last_accessed.contains(&thread_id_c));
 5966        assert!(last_accessed.contains(&thread_id_a));
 5967        assert!(
 5968            is_active_session(&sidebar, &session_id_a),
 5969            "active_entry should be Thread({session_id_a:?})"
 5970        );
 5971    });
 5972
 5973    sidebar.update_in(cx, |sidebar, window, cx| {
 5974        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5975    });
 5976    cx.run_until_parked();
 5977
 5978    assert_eq!(
 5979        switcher_ids(&sidebar, cx),
 5980        vec![thread_id_a, thread_id_c, thread_id_b,],
 5981    );
 5982
 5983    sidebar.update_in(cx, |sidebar, _window, cx| {
 5984        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5985        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
 5986    });
 5987    cx.run_until_parked();
 5988
 5989    // Confirm on Thread B.
 5990    sidebar.update_in(cx, |sidebar, window, cx| {
 5991        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5992        let focus = switcher.focus_handle(cx);
 5993        focus.dispatch_action(&menu::Confirm, window, cx);
 5994    });
 5995    cx.run_until_parked();
 5996
 5997    sidebar.update(cx, |sidebar, _cx| {
 5998        let last_accessed = sidebar
 5999            .thread_last_accessed
 6000            .keys()
 6001            .cloned()
 6002            .collect::<Vec<_>>();
 6003        assert_eq!(last_accessed.len(), 3);
 6004        assert!(last_accessed.contains(&thread_id_c));
 6005        assert!(last_accessed.contains(&thread_id_a));
 6006        assert!(last_accessed.contains(&thread_id_b));
 6007        assert!(
 6008            is_active_session(&sidebar, &session_id_b),
 6009            "active_entry should be Thread({session_id_b:?})"
 6010        );
 6011    });
 6012
 6013    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
 6014    // This thread was never opened in a panel — it only exists in metadata.
 6015    save_thread_metadata(
 6016        acp::SessionId::new(Arc::from("thread-historical")),
 6017        Some("Historical Thread".into()),
 6018        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 6019        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
 6020        None,
 6021        &project,
 6022        cx,
 6023    );
 6024
 6025    sidebar.update_in(cx, |sidebar, window, cx| {
 6026        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6027    });
 6028    cx.run_until_parked();
 6029
 6030    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
 6031    // so it falls to tier 3 (sorted by created_at). It should appear after all
 6032    // accessed threads, even though its created_at (June 2024) is much later
 6033    // than the others.
 6034    //
 6035    // But the live threads (A, B, C) each had send_message called which sets
 6036    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
 6037    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
 6038    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
 6039    let thread_id_hist = thread_id_for(&session_id_hist, cx);
 6040
 6041    let ids = switcher_ids(&sidebar, cx);
 6042    assert_eq!(
 6043        ids,
 6044        vec![thread_id_b, thread_id_a, thread_id_c, thread_id_hist],
 6045    );
 6046
 6047    sidebar.update_in(cx, |sidebar, _window, cx| {
 6048        sidebar.dismiss_thread_switcher(cx);
 6049    });
 6050    cx.run_until_parked();
 6051
 6052    // ── 4. Add another historical thread with older created_at ─────────
 6053    save_thread_metadata(
 6054        acp::SessionId::new(Arc::from("thread-old-historical")),
 6055        Some("Old Historical Thread".into()),
 6056        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
 6057        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
 6058        None,
 6059        &project,
 6060        cx,
 6061    );
 6062
 6063    sidebar.update_in(cx, |sidebar, window, cx| {
 6064        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6065    });
 6066    cx.run_until_parked();
 6067
 6068    // Both historical threads have no access or message times. They should
 6069    // appear after accessed threads, sorted by created_at (newest first).
 6070    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
 6071    let thread_id_old_hist = thread_id_for(&session_id_old_hist, cx);
 6072    let ids = switcher_ids(&sidebar, cx);
 6073    assert_eq!(
 6074        ids,
 6075        vec![
 6076            thread_id_b,
 6077            thread_id_a,
 6078            thread_id_c,
 6079            thread_id_hist,
 6080            thread_id_old_hist,
 6081        ],
 6082    );
 6083
 6084    sidebar.update_in(cx, |sidebar, _window, cx| {
 6085        sidebar.dismiss_thread_switcher(cx);
 6086    });
 6087    cx.run_until_parked();
 6088}
 6089
 6090#[gpui::test]
 6091async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
 6092    let project = init_test_project("/my-project", cx).await;
 6093    let (multi_workspace, cx) =
 6094        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6095    let sidebar = setup_sidebar(&multi_workspace, cx);
 6096
 6097    save_thread_metadata(
 6098        acp::SessionId::new(Arc::from("thread-to-archive")),
 6099        Some("Thread To Archive".into()),
 6100        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 6101        None,
 6102        None,
 6103        &project,
 6104        cx,
 6105    );
 6106    cx.run_until_parked();
 6107
 6108    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 6109    cx.run_until_parked();
 6110
 6111    let entries = visible_entries_as_strings(&sidebar, cx);
 6112    assert!(
 6113        entries.iter().any(|e| e.contains("Thread To Archive")),
 6114        "expected thread to be visible before archiving, got: {entries:?}"
 6115    );
 6116
 6117    sidebar.update_in(cx, |sidebar, window, cx| {
 6118        sidebar.archive_thread(
 6119            &acp::SessionId::new(Arc::from("thread-to-archive")),
 6120            window,
 6121            cx,
 6122        );
 6123    });
 6124    cx.run_until_parked();
 6125
 6126    let entries = visible_entries_as_strings(&sidebar, cx);
 6127    assert!(
 6128        !entries.iter().any(|e| e.contains("Thread To Archive")),
 6129        "expected thread to be hidden after archiving, got: {entries:?}"
 6130    );
 6131
 6132    cx.update(|_, cx| {
 6133        let store = ThreadMetadataStore::global(cx);
 6134        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 6135        assert_eq!(archived.len(), 1);
 6136        assert_eq!(
 6137            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 6138            "thread-to-archive"
 6139        );
 6140        assert!(archived[0].archived);
 6141    });
 6142}
 6143
 6144#[gpui::test]
 6145async fn test_archive_thread_drops_retained_conversation_view(cx: &mut TestAppContext) {
 6146    let project = init_test_project_with_agent_panel("/project-a", cx).await;
 6147    let (multi_workspace, cx) =
 6148        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6149    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6150    cx.run_until_parked();
 6151
 6152    let connection = acp_thread::StubAgentConnection::new();
 6153    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6154        acp::ContentChunk::new("Done".into()),
 6155    )]);
 6156    open_thread_with_connection(&panel, connection, cx);
 6157    send_message(&panel, cx);
 6158    let session_id = active_session_id(&panel, cx);
 6159    let thread_id = active_thread_id(&panel, cx);
 6160    cx.run_until_parked();
 6161
 6162    sidebar.read_with(cx, |sidebar, _| {
 6163        assert!(
 6164            is_active_session(sidebar, &session_id),
 6165            "expected the newly created thread to be active before archiving",
 6166        );
 6167    });
 6168
 6169    sidebar.update_in(cx, |sidebar, window, cx| {
 6170        sidebar.archive_thread(&session_id, window, cx);
 6171    });
 6172    cx.run_until_parked();
 6173
 6174    panel.read_with(cx, |panel, _| {
 6175        assert!(
 6176            !panel.is_retained_thread(&thread_id),
 6177            "archiving a thread must drop its ConversationView from retained_threads, \
 6178             but the archived thread id {thread_id:?} is still retained",
 6179        );
 6180    });
 6181}
 6182
 6183#[gpui::test]
 6184async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
 6185    // Tests two archive scenarios:
 6186    // 1. Archiving a thread in a non-active workspace leaves active_entry
 6187    //    as the current draft.
 6188    // 2. Archiving the thread the user is looking at falls back to a draft
 6189    //    on the same workspace.
 6190    agent_ui::test_support::init_test(cx);
 6191    cx.update(|cx| {
 6192        ThreadStore::init_global(cx);
 6193        ThreadMetadataStore::init_global(cx);
 6194        language_model::LanguageModelRegistry::test(cx);
 6195        prompt_store::init(cx);
 6196    });
 6197
 6198    let fs = FakeFs::new(cx.executor());
 6199    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6200        .await;
 6201    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6202        .await;
 6203    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6204
 6205    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6206    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6207
 6208    let (multi_workspace, cx) =
 6209        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6210    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6211
 6212    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6213        mw.test_add_workspace(project_b.clone(), window, cx)
 6214    });
 6215    let panel_b = add_agent_panel(&workspace_b, cx);
 6216    cx.run_until_parked();
 6217
 6218    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 6219    sidebar.update_in(cx, |sidebar, window, cx| {
 6220        sidebar.create_new_thread(&workspace_b, window, cx);
 6221    });
 6222    cx.run_until_parked();
 6223
 6224    // --- Scenario 1: archive a thread in the non-active workspace ---
 6225
 6226    // Create a thread in project-a (non-active — project-b is active).
 6227    let connection = acp_thread::StubAgentConnection::new();
 6228    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6229        acp::ContentChunk::new("Done".into()),
 6230    )]);
 6231    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 6232    agent_ui::test_support::send_message(&panel_a, cx);
 6233    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 6234    cx.run_until_parked();
 6235
 6236    sidebar.update_in(cx, |sidebar, window, cx| {
 6237        sidebar.archive_thread(&thread_a, window, cx);
 6238    });
 6239    cx.run_until_parked();
 6240
 6241    // active_entry should still be a draft on workspace_b (the active one).
 6242    sidebar.read_with(cx, |sidebar, _| {
 6243        assert!(
 6244            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
 6245            "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
 6246            sidebar.active_entry,
 6247        );
 6248    });
 6249
 6250    // --- Scenario 2: archive the thread the user is looking at ---
 6251
 6252    // Create a thread in project-b (the active workspace) and verify it
 6253    // becomes the active entry.
 6254    let connection = acp_thread::StubAgentConnection::new();
 6255    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6256        acp::ContentChunk::new("Done".into()),
 6257    )]);
 6258    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6259    agent_ui::test_support::send_message(&panel_b, cx);
 6260    let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
 6261    cx.run_until_parked();
 6262
 6263    sidebar.read_with(cx, |sidebar, _| {
 6264        assert!(
 6265            is_active_session(&sidebar, &thread_b),
 6266            "expected active_entry to be Thread({thread_b}), got: {:?}",
 6267            sidebar.active_entry,
 6268        );
 6269    });
 6270
 6271    sidebar.update_in(cx, |sidebar, window, cx| {
 6272        sidebar.archive_thread(&thread_b, window, cx);
 6273    });
 6274    cx.run_until_parked();
 6275
 6276    // Archiving the active thread activates a draft on the same workspace
 6277    // (via clear_base_view → activate_draft). The draft is not shown as a
 6278    // sidebar row but active_entry tracks it.
 6279    sidebar.read_with(cx, |sidebar, _| {
 6280        assert!(
 6281            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
 6282            "expected draft on workspace_b after archiving active thread, got: {:?}",
 6283            sidebar.active_entry,
 6284        );
 6285    });
 6286}
 6287
 6288#[gpui::test]
 6289async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
 6290    // Full flow: create a thread, archive it (removing the workspace),
 6291    // then unarchive. Only the restored thread should appear — no
 6292    // leftover drafts or previously-serialized threads.
 6293    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 6294    let (multi_workspace, cx) =
 6295        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6296    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6297    cx.run_until_parked();
 6298
 6299    // Create a thread and send a message so it's a real thread.
 6300    let connection = acp_thread::StubAgentConnection::new();
 6301    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6302        acp::ContentChunk::new("Hello".into()),
 6303    )]);
 6304    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6305    agent_ui::test_support::send_message(&panel, cx);
 6306    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6307    cx.run_until_parked();
 6308
 6309    // Archive it.
 6310    sidebar.update_in(cx, |sidebar, window, cx| {
 6311        sidebar.archive_thread(&session_id, window, cx);
 6312    });
 6313    cx.run_until_parked();
 6314
 6315    // Grab metadata for unarchive.
 6316    let thread_id = cx.update(|_, cx| {
 6317        ThreadMetadataStore::global(cx)
 6318            .read(cx)
 6319            .entries()
 6320            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6321            .map(|e| e.thread_id)
 6322            .expect("thread should exist")
 6323    });
 6324    let metadata = cx.update(|_, cx| {
 6325        ThreadMetadataStore::global(cx)
 6326            .read(cx)
 6327            .entry(thread_id)
 6328            .cloned()
 6329            .expect("metadata should exist")
 6330    });
 6331
 6332    // Unarchive it — the draft should be replaced by the restored thread.
 6333    sidebar.update_in(cx, |sidebar, window, cx| {
 6334        sidebar.open_thread_from_archive(metadata, window, cx);
 6335    });
 6336    cx.run_until_parked();
 6337
 6338    // Only the unarchived thread should be visible — no drafts, no other threads.
 6339    let entries = visible_entries_as_strings(&sidebar, cx);
 6340    let thread_count = entries
 6341        .iter()
 6342        .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
 6343        .count();
 6344    assert_eq!(
 6345        thread_count, 1,
 6346        "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
 6347    );
 6348    assert!(
 6349        !entries.iter().any(|e| e.contains("Draft")),
 6350        "expected no drafts after restoring, got entries: {entries:?}"
 6351    );
 6352}
 6353
 6354#[gpui::test]
 6355async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
 6356    cx: &mut TestAppContext,
 6357) {
 6358    // When a thread is unarchived into a project group that has no open
 6359    // workspace, the sidebar opens a new workspace and loads the thread.
 6360    // No spurious draft should appear alongside the unarchived thread.
 6361    agent_ui::test_support::init_test(cx);
 6362    cx.update(|cx| {
 6363        ThreadStore::init_global(cx);
 6364        ThreadMetadataStore::init_global(cx);
 6365        language_model::LanguageModelRegistry::test(cx);
 6366        prompt_store::init(cx);
 6367    });
 6368
 6369    let fs = FakeFs::new(cx.executor());
 6370    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6371        .await;
 6372    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6373        .await;
 6374    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6375
 6376    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6377    let (multi_workspace, cx) =
 6378        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6379    let sidebar = setup_sidebar(&multi_workspace, cx);
 6380    cx.run_until_parked();
 6381
 6382    // Save an archived thread whose folder_paths point to project-b,
 6383    // which has no open workspace.
 6384    let session_id = acp::SessionId::new(Arc::from("archived-thread"));
 6385    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 6386    let thread_id = ThreadId::new();
 6387    cx.update(|_, cx| {
 6388        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6389            store.save(
 6390                ThreadMetadata {
 6391                    thread_id,
 6392                    session_id: Some(session_id.clone()),
 6393                    agent_id: agent::ZED_AGENT_ID.clone(),
 6394                    title: Some("Unarchived Thread".into()),
 6395                    updated_at: Utc::now(),
 6396                    created_at: None,
 6397                    interacted_at: None,
 6398                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6399                    archived: true,
 6400                    remote_connection: None,
 6401                },
 6402                cx,
 6403            )
 6404        });
 6405    });
 6406    cx.run_until_parked();
 6407
 6408    // Verify no workspace for project-b exists yet.
 6409    assert_eq!(
 6410        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6411        1,
 6412        "should start with only the project-a workspace"
 6413    );
 6414
 6415    // Un-archive the thread — should open project-b workspace and load it.
 6416    let metadata = cx.update(|_, cx| {
 6417        ThreadMetadataStore::global(cx)
 6418            .read(cx)
 6419            .entry(thread_id)
 6420            .cloned()
 6421            .expect("metadata should exist")
 6422    });
 6423
 6424    sidebar.update_in(cx, |sidebar, window, cx| {
 6425        sidebar.open_thread_from_archive(metadata, window, cx);
 6426    });
 6427    cx.run_until_parked();
 6428
 6429    // A second workspace should have been created for project-b.
 6430    assert_eq!(
 6431        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6432        2,
 6433        "should have opened a workspace for the unarchived thread"
 6434    );
 6435
 6436    // The sidebar should show the unarchived thread without a spurious draft
 6437    // in the project-b group.
 6438    let entries = visible_entries_as_strings(&sidebar, cx);
 6439    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6440    // project-a gets a draft (it's the active workspace with no threads),
 6441    // but project-b should NOT have one — only the unarchived thread.
 6442    assert!(
 6443        draft_count <= 1,
 6444        "expected at most one draft (for project-a), got entries: {entries:?}"
 6445    );
 6446    assert!(
 6447        entries.iter().any(|e| e.contains("Unarchived Thread")),
 6448        "expected unarchived thread to appear, got entries: {entries:?}"
 6449    );
 6450}
 6451
 6452#[gpui::test]
 6453async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
 6454    cx: &mut TestAppContext,
 6455) {
 6456    agent_ui::test_support::init_test(cx);
 6457    cx.update(|cx| {
 6458        ThreadStore::init_global(cx);
 6459        ThreadMetadataStore::init_global(cx);
 6460        language_model::LanguageModelRegistry::test(cx);
 6461        prompt_store::init(cx);
 6462    });
 6463
 6464    let fs = FakeFs::new(cx.executor());
 6465    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6466        .await;
 6467    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6468        .await;
 6469    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6470
 6471    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6472    let (multi_workspace, cx) =
 6473        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6474    let sidebar = setup_sidebar(&multi_workspace, cx);
 6475    cx.run_until_parked();
 6476
 6477    let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
 6478    let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
 6479    let original_thread_id = ThreadId::new();
 6480    cx.update(|_, cx| {
 6481        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6482            store.save(
 6483                ThreadMetadata {
 6484                    thread_id: original_thread_id,
 6485                    session_id: Some(session_id.clone()),
 6486                    agent_id: agent::ZED_AGENT_ID.clone(),
 6487                    title: Some("Unarchived Thread".into()),
 6488                    updated_at: Utc::now(),
 6489                    created_at: None,
 6490                    interacted_at: None,
 6491                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6492                    archived: true,
 6493                    remote_connection: None,
 6494                },
 6495                cx,
 6496            )
 6497        });
 6498    });
 6499    cx.run_until_parked();
 6500
 6501    let metadata = cx.update(|_, cx| {
 6502        ThreadMetadataStore::global(cx)
 6503            .read(cx)
 6504            .entry(original_thread_id)
 6505            .cloned()
 6506            .expect("metadata should exist before unarchive")
 6507    });
 6508
 6509    sidebar.update_in(cx, |sidebar, window, cx| {
 6510        sidebar.open_thread_from_archive(metadata, window, cx);
 6511    });
 6512
 6513    cx.run_until_parked();
 6514
 6515    assert_eq!(
 6516        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6517        2,
 6518        "expected unarchive to open the target workspace"
 6519    );
 6520
 6521    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6522        mw.workspaces()
 6523            .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
 6524            .cloned()
 6525            .expect("expected restored workspace for unarchived thread")
 6526    });
 6527    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6528        workspace
 6529            .panel::<AgentPanel>(cx)
 6530            .expect("expected unarchive to install an agent panel in the new workspace")
 6531    });
 6532
 6533    let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6534    assert_eq!(
 6535        restored_thread_id,
 6536        Some(original_thread_id),
 6537        "expected the new workspace's agent panel to target the restored archived thread id"
 6538    );
 6539
 6540    let session_entries = cx.update(|_, cx| {
 6541        ThreadMetadataStore::global(cx)
 6542            .read(cx)
 6543            .entries()
 6544            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6545            .cloned()
 6546            .collect::<Vec<_>>()
 6547    });
 6548    assert_eq!(
 6549        session_entries.len(),
 6550        1,
 6551        "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
 6552    );
 6553    assert_eq!(
 6554        session_entries[0].thread_id, original_thread_id,
 6555        "expected restore into a new workspace to reuse the original thread id"
 6556    );
 6557    assert!(
 6558        !session_entries[0].archived,
 6559        "expected restored thread metadata to be unarchived, got: {:?}",
 6560        session_entries[0]
 6561    );
 6562
 6563    let mapped_thread_id = cx.update(|_, cx| {
 6564        ThreadMetadataStore::global(cx)
 6565            .read(cx)
 6566            .entries()
 6567            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6568            .map(|e| e.thread_id)
 6569    });
 6570    assert_eq!(
 6571        mapped_thread_id,
 6572        Some(original_thread_id),
 6573        "expected session mapping to remain stable after opening the new workspace"
 6574    );
 6575
 6576    let entries = visible_entries_as_strings(&sidebar, cx);
 6577    let real_thread_rows = entries
 6578        .iter()
 6579        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6580        .filter(|entry| !entry.contains("Draft"))
 6581        .count();
 6582    assert_eq!(
 6583        real_thread_rows, 1,
 6584        "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
 6585    );
 6586    assert!(
 6587        entries
 6588            .iter()
 6589            .any(|entry| entry.contains("Unarchived Thread")),
 6590        "expected restored thread row to be visible, got entries: {entries:?}"
 6591    );
 6592}
 6593
 6594#[gpui::test]
 6595async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
 6596    // When a workspace already exists with an empty draft and a thread
 6597    // is unarchived into it, the draft should be replaced — not kept
 6598    // alongside the loaded thread.
 6599    agent_ui::test_support::init_test(cx);
 6600    cx.update(|cx| {
 6601        ThreadStore::init_global(cx);
 6602        ThreadMetadataStore::init_global(cx);
 6603        language_model::LanguageModelRegistry::test(cx);
 6604        prompt_store::init(cx);
 6605    });
 6606
 6607    let fs = FakeFs::new(cx.executor());
 6608    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6609        .await;
 6610    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6611
 6612    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6613    let (multi_workspace, cx) =
 6614        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6615    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6616    cx.run_until_parked();
 6617
 6618    // Create a thread and send a message so it's no longer a draft.
 6619    let connection = acp_thread::StubAgentConnection::new();
 6620    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6621        acp::ContentChunk::new("Done".into()),
 6622    )]);
 6623    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6624    agent_ui::test_support::send_message(&panel, cx);
 6625    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6626    cx.run_until_parked();
 6627
 6628    // Archive the thread — the group is left empty (no draft created).
 6629    sidebar.update_in(cx, |sidebar, window, cx| {
 6630        sidebar.archive_thread(&session_id, window, cx);
 6631    });
 6632    cx.run_until_parked();
 6633
 6634    // Un-archive the thread.
 6635    let thread_id = cx.update(|_, cx| {
 6636        ThreadMetadataStore::global(cx)
 6637            .read(cx)
 6638            .entries()
 6639            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6640            .map(|e| e.thread_id)
 6641            .expect("thread should exist in store")
 6642    });
 6643    let metadata = cx.update(|_, cx| {
 6644        ThreadMetadataStore::global(cx)
 6645            .read(cx)
 6646            .entry(thread_id)
 6647            .cloned()
 6648            .expect("metadata should exist")
 6649    });
 6650
 6651    sidebar.update_in(cx, |sidebar, window, cx| {
 6652        sidebar.open_thread_from_archive(metadata, window, cx);
 6653    });
 6654    cx.run_until_parked();
 6655
 6656    // The draft should be gone — only the unarchived thread remains.
 6657    let entries = visible_entries_as_strings(&sidebar, cx);
 6658    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6659    assert_eq!(
 6660        draft_count, 0,
 6661        "expected no drafts after unarchiving, got entries: {entries:?}"
 6662    );
 6663}
 6664
 6665#[gpui::test]
 6666async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
 6667    cx: &mut TestAppContext,
 6668) {
 6669    agent_ui::test_support::init_test(cx);
 6670    cx.update(|cx| {
 6671        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6672        ThreadStore::init_global(cx);
 6673        ThreadMetadataStore::init_global(cx);
 6674        language_model::LanguageModelRegistry::test(cx);
 6675        prompt_store::init(cx);
 6676    });
 6677
 6678    let fs = FakeFs::new(cx.executor());
 6679    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6680        .await;
 6681    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6682        .await;
 6683    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6684
 6685    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6686    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6687
 6688    let (multi_workspace, cx) =
 6689        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6690    let sidebar = setup_sidebar(&multi_workspace, cx);
 6691
 6692    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 6693    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6694        mw.test_add_workspace(project_b.clone(), window, cx)
 6695    });
 6696    let _panel_b = add_agent_panel(&workspace_b, cx);
 6697    cx.run_until_parked();
 6698
 6699    multi_workspace.update_in(cx, |mw, window, cx| {
 6700        mw.activate(workspace_a.clone(), None, window, cx);
 6701    });
 6702    cx.run_until_parked();
 6703
 6704    let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
 6705    let thread_id = ThreadId::new();
 6706    cx.update(|_, cx| {
 6707        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6708            store.save(
 6709                ThreadMetadata {
 6710                    thread_id,
 6711                    session_id: Some(session_id.clone()),
 6712                    agent_id: agent::ZED_AGENT_ID.clone(),
 6713                    title: Some("Restored In Inactive Workspace".into()),
 6714                    updated_at: Utc::now(),
 6715                    created_at: None,
 6716                    interacted_at: None,
 6717                    worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 6718                        PathBuf::from("/project-b"),
 6719                    ])),
 6720                    archived: true,
 6721                    remote_connection: None,
 6722                },
 6723                cx,
 6724            )
 6725        });
 6726    });
 6727    cx.run_until_parked();
 6728
 6729    let metadata = cx.update(|_, cx| {
 6730        ThreadMetadataStore::global(cx)
 6731            .read(cx)
 6732            .entry(thread_id)
 6733            .cloned()
 6734            .expect("archived metadata should exist before restore")
 6735    });
 6736
 6737    sidebar.update_in(cx, |sidebar, window, cx| {
 6738        sidebar.open_thread_from_archive(metadata, window, cx);
 6739    });
 6740
 6741    let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
 6742        workspace.panel::<AgentPanel>(cx).expect(
 6743            "target workspace should still have an agent panel immediately after activation",
 6744        )
 6745    });
 6746    let immediate_active_thread_id =
 6747        panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6748
 6749    cx.run_until_parked();
 6750
 6751    sidebar.read_with(cx, |sidebar, _cx| {
 6752        assert_active_thread(
 6753            sidebar,
 6754            &session_id,
 6755            "unarchiving into an inactive existing workspace should end on the restored thread",
 6756        );
 6757    });
 6758
 6759    let panel_b = workspace_b.read_with(cx, |workspace, cx| {
 6760        workspace
 6761            .panel::<AgentPanel>(cx)
 6762            .expect("target workspace should still have an agent panel")
 6763    });
 6764    assert_eq!(
 6765        panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6766        Some(thread_id),
 6767        "expected target panel to activate the restored thread id"
 6768    );
 6769    assert!(
 6770        immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
 6771        "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
 6772    );
 6773
 6774    let entries = visible_entries_as_strings(&sidebar, cx);
 6775    let target_rows: Vec<_> = entries
 6776        .iter()
 6777        .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
 6778        .cloned()
 6779        .collect();
 6780    assert_eq!(
 6781        target_rows.len(),
 6782        1,
 6783        "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
 6784    );
 6785    assert!(
 6786        target_rows[0].contains("Restored In Inactive Workspace"),
 6787        "expected the remaining row to be the restored thread, got entries: {entries:?}"
 6788    );
 6789    assert!(
 6790        !target_rows[0].contains("Draft"),
 6791        "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
 6792    );
 6793}
 6794
 6795#[gpui::test]
 6796async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
 6797    cx: &mut TestAppContext,
 6798) {
 6799    agent_ui::test_support::init_test(cx);
 6800    cx.update(|cx| {
 6801        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6802        ThreadStore::init_global(cx);
 6803        ThreadMetadataStore::init_global(cx);
 6804        language_model::LanguageModelRegistry::test(cx);
 6805        prompt_store::init(cx);
 6806    });
 6807
 6808    let fs = FakeFs::new(cx.executor());
 6809    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6810        .await;
 6811    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6812        .await;
 6813    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6814
 6815    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6816    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6817
 6818    let (multi_workspace, cx) =
 6819        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6820    let sidebar = setup_sidebar(&multi_workspace, cx);
 6821
 6822    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6823        mw.test_add_workspace(project_b.clone(), window, cx)
 6824    });
 6825    let panel_b = add_agent_panel(&workspace_b, cx);
 6826    cx.run_until_parked();
 6827
 6828    let connection = acp_thread::StubAgentConnection::new();
 6829    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6830        acp::ContentChunk::new("Done".into()),
 6831    )]);
 6832    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6833    agent_ui::test_support::send_message(&panel_b, cx);
 6834    let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
 6835    save_test_thread_metadata(&session_id, &project_b, cx).await;
 6836    cx.run_until_parked();
 6837
 6838    sidebar.update_in(cx, |sidebar, window, cx| {
 6839        sidebar.archive_thread(&session_id, window, cx);
 6840    });
 6841
 6842    cx.run_until_parked();
 6843
 6844    let archived_metadata = cx.update(|_, cx| {
 6845        let store = ThreadMetadataStore::global(cx).read(cx);
 6846        let thread_id = store
 6847            .entries()
 6848            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6849            .map(|e| e.thread_id)
 6850            .expect("archived thread should still exist in metadata store");
 6851        let metadata = store
 6852            .entry(thread_id)
 6853            .cloned()
 6854            .expect("archived metadata should still exist after archive");
 6855        assert!(
 6856            metadata.archived,
 6857            "thread should be archived before project removal"
 6858        );
 6859        metadata
 6860    });
 6861
 6862    let group_key_b =
 6863        project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
 6864    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 6865        mw.remove_project_group(&group_key_b, window, cx)
 6866    });
 6867    remove_task
 6868        .await
 6869        .expect("remove project group task should complete");
 6870    cx.run_until_parked();
 6871
 6872    assert_eq!(
 6873        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6874        1,
 6875        "removing the archived thread's parent project group should remove its workspace"
 6876    );
 6877
 6878    sidebar.update_in(cx, |sidebar, window, cx| {
 6879        sidebar.open_thread_from_archive(archived_metadata.clone(), window, cx);
 6880    });
 6881    cx.run_until_parked();
 6882
 6883    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6884        mw.workspaces()
 6885            .find(|workspace| {
 6886                PathList::new(&workspace.read(cx).root_paths(cx))
 6887                    == PathList::new(&[PathBuf::from("/project-b")])
 6888            })
 6889            .cloned()
 6890            .expect("expected unarchive to recreate the removed project workspace")
 6891    });
 6892    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6893        workspace
 6894            .panel::<AgentPanel>(cx)
 6895            .expect("expected restored workspace to bootstrap an agent panel")
 6896    });
 6897
 6898    let restored_thread_id = cx.update(|_, cx| {
 6899        ThreadMetadataStore::global(cx)
 6900            .read(cx)
 6901            .entries()
 6902            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6903            .map(|e| e.thread_id)
 6904            .expect("session should still map to restored thread id")
 6905    });
 6906    assert_eq!(
 6907        restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6908        Some(restored_thread_id),
 6909        "expected unarchive after project removal to activate the restored real thread"
 6910    );
 6911
 6912    sidebar.read_with(cx, |sidebar, _cx| {
 6913        assert_active_thread(
 6914            sidebar,
 6915            &session_id,
 6916            "expected sidebar active entry to track the restored thread after project removal",
 6917        );
 6918    });
 6919
 6920    let entries = visible_entries_as_strings(&sidebar, cx);
 6921    let restored_title = archived_metadata.display_title().to_string();
 6922    let matching_rows: Vec<_> = entries
 6923        .iter()
 6924        .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
 6925        .cloned()
 6926        .collect();
 6927    assert_eq!(
 6928        matching_rows.len(),
 6929        1,
 6930        "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
 6931    );
 6932    assert!(
 6933        !matching_rows[0].contains("Draft"),
 6934        "expected no draft row after unarchive following project removal, got entries: {entries:?}"
 6935    );
 6936}
 6937
 6938#[gpui::test]
 6939async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
 6940    agent_ui::test_support::init_test(cx);
 6941    cx.update(|cx| {
 6942        ThreadStore::init_global(cx);
 6943        ThreadMetadataStore::init_global(cx);
 6944        language_model::LanguageModelRegistry::test(cx);
 6945        prompt_store::init(cx);
 6946    });
 6947
 6948    let fs = FakeFs::new(cx.executor());
 6949    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6950        .await;
 6951    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6952
 6953    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6954    let (multi_workspace, cx) =
 6955        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6956    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6957    cx.run_until_parked();
 6958
 6959    let connection = acp_thread::StubAgentConnection::new();
 6960    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6961        acp::ContentChunk::new("Done".into()),
 6962    )]);
 6963    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6964    agent_ui::test_support::send_message(&panel, cx);
 6965    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6966    cx.run_until_parked();
 6967
 6968    let original_thread_id = cx.update(|_, cx| {
 6969        ThreadMetadataStore::global(cx)
 6970            .read(cx)
 6971            .entries()
 6972            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6973            .map(|e| e.thread_id)
 6974            .expect("thread should exist in store before archiving")
 6975    });
 6976
 6977    sidebar.update_in(cx, |sidebar, window, cx| {
 6978        sidebar.archive_thread(&session_id, window, cx);
 6979    });
 6980    cx.run_until_parked();
 6981
 6982    let metadata = cx.update(|_, cx| {
 6983        ThreadMetadataStore::global(cx)
 6984            .read(cx)
 6985            .entry(original_thread_id)
 6986            .cloned()
 6987            .expect("metadata should exist after archiving")
 6988    });
 6989
 6990    sidebar.update_in(cx, |sidebar, window, cx| {
 6991        sidebar.open_thread_from_archive(metadata, window, cx);
 6992    });
 6993    cx.run_until_parked();
 6994
 6995    let session_entries = cx.update(|_, cx| {
 6996        ThreadMetadataStore::global(cx)
 6997            .read(cx)
 6998            .entries()
 6999            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7000            .cloned()
 7001            .collect::<Vec<_>>()
 7002    });
 7003
 7004    assert_eq!(
 7005        session_entries.len(),
 7006        1,
 7007        "expected exactly one metadata row for the restored session, got: {session_entries:?}"
 7008    );
 7009    assert_eq!(
 7010        session_entries[0].thread_id, original_thread_id,
 7011        "expected unarchive to reuse the original thread id instead of creating a duplicate row"
 7012    );
 7013    assert!(
 7014        session_entries[0].session_id.is_some(),
 7015        "expected restored metadata to be a real thread, got: {:?}",
 7016        session_entries[0]
 7017    );
 7018
 7019    let entries = visible_entries_as_strings(&sidebar, cx);
 7020    let real_thread_rows = entries
 7021        .iter()
 7022        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7023        .filter(|entry| !entry.contains("Draft"))
 7024        .count();
 7025    assert_eq!(
 7026        real_thread_rows, 1,
 7027        "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
 7028    );
 7029    assert!(
 7030        !entries.iter().any(|entry| entry.contains("Draft")),
 7031        "expected no draft rows after restoring, got entries: {entries:?}"
 7032    );
 7033}
 7034
 7035#[gpui::test]
 7036async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
 7037    cx: &mut TestAppContext,
 7038) {
 7039    // When a thread is archived while the user is in a different workspace,
 7040    // clear_base_view creates a draft on the archived workspace's panel.
 7041    // Switching back to that workspace shows the draft as active_entry.
 7042    agent_ui::test_support::init_test(cx);
 7043    cx.update(|cx| {
 7044        ThreadStore::init_global(cx);
 7045        ThreadMetadataStore::init_global(cx);
 7046        language_model::LanguageModelRegistry::test(cx);
 7047        prompt_store::init(cx);
 7048    });
 7049
 7050    let fs = FakeFs::new(cx.executor());
 7051    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 7052        .await;
 7053    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 7054        .await;
 7055    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7056
 7057    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 7058    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 7059
 7060    let (multi_workspace, cx) =
 7061        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 7062    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 7063
 7064    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 7065        mw.test_add_workspace(project_b.clone(), window, cx)
 7066    });
 7067    let _panel_b = add_agent_panel(&workspace_b, cx);
 7068    cx.run_until_parked();
 7069
 7070    // Create a thread in project-a's panel (currently non-active).
 7071    let connection = acp_thread::StubAgentConnection::new();
 7072    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 7073        acp::ContentChunk::new("Done".into()),
 7074    )]);
 7075    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 7076    agent_ui::test_support::send_message(&panel_a, cx);
 7077    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 7078    cx.run_until_parked();
 7079
 7080    // Archive it while project-b is active.
 7081    sidebar.update_in(cx, |sidebar, window, cx| {
 7082        sidebar.archive_thread(&thread_a, window, cx);
 7083    });
 7084    cx.run_until_parked();
 7085
 7086    // Switch back to project-a. Its panel was cleared during archiving
 7087    // (clear_base_view activated a draft), so active_entry should point
 7088    // to the draft on workspace_a.
 7089    let workspace_a =
 7090        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7091    multi_workspace.update_in(cx, |mw, window, cx| {
 7092        mw.activate(workspace_a.clone(), None, window, cx);
 7093    });
 7094    cx.run_until_parked();
 7095
 7096    sidebar.update_in(cx, |sidebar, _window, cx| {
 7097        sidebar.update_entries(cx);
 7098    });
 7099    cx.run_until_parked();
 7100
 7101    sidebar.read_with(cx, |sidebar, _| {
 7102        assert_active_draft(
 7103            sidebar,
 7104            &workspace_a,
 7105            "after switching to workspace with archived thread, active_entry should be the draft",
 7106        );
 7107    });
 7108}
 7109
 7110#[gpui::test]
 7111async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
 7112    let project = init_test_project("/my-project", cx).await;
 7113    let (multi_workspace, cx) =
 7114        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 7115    let sidebar = setup_sidebar(&multi_workspace, cx);
 7116
 7117    save_thread_metadata(
 7118        acp::SessionId::new(Arc::from("visible-thread")),
 7119        Some("Visible Thread".into()),
 7120        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7121        None,
 7122        None,
 7123        &project,
 7124        cx,
 7125    );
 7126
 7127    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
 7128    save_thread_metadata(
 7129        archived_thread_session_id.clone(),
 7130        Some("Archived Thread".into()),
 7131        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7132        None,
 7133        None,
 7134        &project,
 7135        cx,
 7136    );
 7137
 7138    cx.update(|_, cx| {
 7139        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7140            let thread_id = store
 7141                .entries()
 7142                .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
 7143                .map(|e| e.thread_id)
 7144                .unwrap();
 7145            store.archive(thread_id, None, cx)
 7146        })
 7147    });
 7148    cx.run_until_parked();
 7149
 7150    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7151    cx.run_until_parked();
 7152
 7153    let entries = visible_entries_as_strings(&sidebar, cx);
 7154    assert!(
 7155        entries.iter().any(|e| e.contains("Visible Thread")),
 7156        "expected visible thread in sidebar, got: {entries:?}"
 7157    );
 7158    assert!(
 7159        !entries.iter().any(|e| e.contains("Archived Thread")),
 7160        "expected archived thread to be hidden from sidebar, got: {entries:?}"
 7161    );
 7162
 7163    cx.update(|_, cx| {
 7164        let store = ThreadMetadataStore::global(cx);
 7165        let all: Vec<_> = store.read(cx).entries().collect();
 7166        assert_eq!(
 7167            all.len(),
 7168            2,
 7169            "expected 2 total entries in the store, got: {}",
 7170            all.len()
 7171        );
 7172
 7173        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 7174        assert_eq!(archived.len(), 1);
 7175        assert_eq!(
 7176            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 7177            "archived-thread"
 7178        );
 7179    });
 7180}
 7181
 7182#[gpui::test]
 7183async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
 7184    cx: &mut TestAppContext,
 7185) {
 7186    // When a linked worktree has a single thread and that thread is archived,
 7187    // the sidebar must NOT create a new thread on the same worktree (which
 7188    // would prevent the worktree from being cleaned up on disk). Instead,
 7189    // archive_thread switches to a sibling thread on the main workspace (or
 7190    // creates a draft there) before archiving the metadata.
 7191    agent_ui::test_support::init_test(cx);
 7192    cx.update(|cx| {
 7193        ThreadStore::init_global(cx);
 7194        ThreadMetadataStore::init_global(cx);
 7195        language_model::LanguageModelRegistry::test(cx);
 7196        prompt_store::init(cx);
 7197    });
 7198
 7199    let fs = FakeFs::new(cx.executor());
 7200
 7201    fs.insert_tree(
 7202        "/project",
 7203        serde_json::json!({
 7204            ".git": {},
 7205            "src": {},
 7206        }),
 7207    )
 7208    .await;
 7209
 7210    fs.add_linked_worktree_for_repo(
 7211        Path::new("/project/.git"),
 7212        false,
 7213        git::repository::Worktree {
 7214            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7215            ref_name: Some("refs/heads/ochre-drift".into()),
 7216            sha: "aaa".into(),
 7217            is_main: false,
 7218            is_bare: false,
 7219        },
 7220    )
 7221    .await;
 7222
 7223    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7224
 7225    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7226    let worktree_project =
 7227        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7228
 7229    main_project
 7230        .update(cx, |p, cx| p.git_scans_complete(cx))
 7231        .await;
 7232    worktree_project
 7233        .update(cx, |p, cx| p.git_scans_complete(cx))
 7234        .await;
 7235
 7236    let (multi_workspace, cx) =
 7237        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7238
 7239    let sidebar = setup_sidebar(&multi_workspace, cx);
 7240
 7241    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7242        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7243    });
 7244
 7245    // Set up both workspaces with agent panels.
 7246    let main_workspace =
 7247        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7248    let _main_panel = add_agent_panel(&main_workspace, cx);
 7249    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7250
 7251    // Activate the linked worktree workspace so the sidebar tracks it.
 7252    multi_workspace.update_in(cx, |mw, window, cx| {
 7253        mw.activate(worktree_workspace.clone(), None, window, cx);
 7254    });
 7255
 7256    // Open a thread in the linked worktree panel and send a message
 7257    // so it becomes the active thread.
 7258    let connection = StubAgentConnection::new();
 7259    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7260    send_message(&worktree_panel, cx);
 7261
 7262    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7263
 7264    // Give the thread a response chunk so it has content.
 7265    cx.update(|_, cx| {
 7266        connection.send_update(
 7267            worktree_thread_id.clone(),
 7268            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7269            cx,
 7270        );
 7271    });
 7272
 7273    // Save the worktree thread's metadata.
 7274    save_thread_metadata(
 7275        worktree_thread_id.clone(),
 7276        Some("Ochre Drift Thread".into()),
 7277        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7278        None,
 7279        None,
 7280        &worktree_project,
 7281        cx,
 7282    );
 7283
 7284    // Also save a thread on the main project so there's a sibling in the
 7285    // group that can be selected after archiving.
 7286    save_thread_metadata(
 7287        acp::SessionId::new(Arc::from("main-project-thread")),
 7288        Some("Main Project Thread".into()),
 7289        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7290        None,
 7291        None,
 7292        &main_project,
 7293        cx,
 7294    );
 7295
 7296    cx.run_until_parked();
 7297
 7298    // Verify the linked worktree thread appears with its chip.
 7299    // The live thread title comes from the message text ("Hello"), not
 7300    // the metadata title we saved.
 7301    let entries_before = visible_entries_as_strings(&sidebar, cx);
 7302    assert!(
 7303        entries_before
 7304            .iter()
 7305            .any(|s| s.contains("{wt-ochre-drift}")),
 7306        "expected worktree thread with chip before archiving, got: {entries_before:?}"
 7307    );
 7308    assert!(
 7309        entries_before
 7310            .iter()
 7311            .any(|s| s.contains("Main Project Thread")),
 7312        "expected main project thread before archiving, got: {entries_before:?}"
 7313    );
 7314
 7315    // Confirm the worktree thread is the active entry.
 7316    sidebar.read_with(cx, |s, _| {
 7317        assert_active_thread(
 7318            s,
 7319            &worktree_thread_id,
 7320            "worktree thread should be active before archiving",
 7321        );
 7322    });
 7323
 7324    // Archive the worktree thread — it's the only thread using ochre-drift.
 7325    sidebar.update_in(cx, |sidebar, window, cx| {
 7326        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7327    });
 7328
 7329    cx.run_until_parked();
 7330
 7331    // The archived thread should no longer appear in the sidebar.
 7332    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7333    assert!(
 7334        !entries_after
 7335            .iter()
 7336            .any(|s| s.contains("Ochre Drift Thread")),
 7337        "archived thread should be hidden, got: {entries_after:?}"
 7338    );
 7339
 7340    // No "+ New Thread" entry should appear with the ochre-drift worktree
 7341    // chip — that would keep the worktree alive and prevent cleanup.
 7342    assert!(
 7343        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7344        "no entry should reference the archived worktree, got: {entries_after:?}"
 7345    );
 7346
 7347    // The main project thread should still be visible.
 7348    assert!(
 7349        entries_after
 7350            .iter()
 7351            .any(|s| s.contains("Main Project Thread")),
 7352        "main project thread should still be visible, got: {entries_after:?}"
 7353    );
 7354}
 7355
 7356#[gpui::test]
 7357async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
 7358    cx: &mut TestAppContext,
 7359) {
 7360    // When a linked worktree thread is the ONLY thread in the project group
 7361    // (no threads on the main repo either), archiving it should leave the
 7362    // group empty with no active entry.
 7363    agent_ui::test_support::init_test(cx);
 7364    cx.update(|cx| {
 7365        ThreadStore::init_global(cx);
 7366        ThreadMetadataStore::init_global(cx);
 7367        language_model::LanguageModelRegistry::test(cx);
 7368        prompt_store::init(cx);
 7369    });
 7370
 7371    let fs = FakeFs::new(cx.executor());
 7372
 7373    fs.insert_tree(
 7374        "/project",
 7375        serde_json::json!({
 7376            ".git": {},
 7377            "src": {},
 7378        }),
 7379    )
 7380    .await;
 7381
 7382    fs.add_linked_worktree_for_repo(
 7383        Path::new("/project/.git"),
 7384        false,
 7385        git::repository::Worktree {
 7386            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7387            ref_name: Some("refs/heads/ochre-drift".into()),
 7388            sha: "aaa".into(),
 7389            is_main: false,
 7390            is_bare: false,
 7391        },
 7392    )
 7393    .await;
 7394
 7395    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7396
 7397    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7398    let worktree_project =
 7399        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7400
 7401    main_project
 7402        .update(cx, |p, cx| p.git_scans_complete(cx))
 7403        .await;
 7404    worktree_project
 7405        .update(cx, |p, cx| p.git_scans_complete(cx))
 7406        .await;
 7407
 7408    let (multi_workspace, cx) =
 7409        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7410
 7411    let sidebar = setup_sidebar(&multi_workspace, cx);
 7412
 7413    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7414        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7415    });
 7416
 7417    let main_workspace =
 7418        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7419    let _main_panel = add_agent_panel(&main_workspace, cx);
 7420    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7421
 7422    // Activate the linked worktree workspace.
 7423    multi_workspace.update_in(cx, |mw, window, cx| {
 7424        mw.activate(worktree_workspace.clone(), None, window, cx);
 7425    });
 7426
 7427    // Open a thread on the linked worktree — this is the ONLY thread.
 7428    let connection = StubAgentConnection::new();
 7429    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7430    send_message(&worktree_panel, cx);
 7431
 7432    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7433
 7434    cx.update(|_, cx| {
 7435        connection.send_update(
 7436            worktree_thread_id.clone(),
 7437            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7438            cx,
 7439        );
 7440    });
 7441
 7442    save_thread_metadata(
 7443        worktree_thread_id.clone(),
 7444        Some("Ochre Drift Thread".into()),
 7445        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7446        None,
 7447        None,
 7448        &worktree_project,
 7449        cx,
 7450    );
 7451
 7452    cx.run_until_parked();
 7453
 7454    // Archive it — there are no other threads in the group.
 7455    sidebar.update_in(cx, |sidebar, window, cx| {
 7456        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7457    });
 7458
 7459    cx.run_until_parked();
 7460
 7461    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7462
 7463    // No entry should reference the linked worktree.
 7464    assert!(
 7465        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7466        "no entry should reference the archived worktree, got: {entries_after:?}"
 7467    );
 7468
 7469    // The active entry should be None — no draft is created.
 7470    sidebar.read_with(cx, |s, _| {
 7471        assert!(
 7472            s.active_entry.is_none(),
 7473            "expected no active entry after archiving the last thread, got: {:?}",
 7474            s.active_entry,
 7475        );
 7476    });
 7477}
 7478
 7479#[gpui::test]
 7480async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
 7481    cx: &mut TestAppContext,
 7482) {
 7483    // When an archived thread belongs to a linked worktree whose main repo is
 7484    // already open, unarchiving should reopen the linked workspace into the
 7485    // same project group and show only the restored real thread row.
 7486    agent_ui::test_support::init_test(cx);
 7487    cx.update(|cx| {
 7488        ThreadStore::init_global(cx);
 7489        ThreadMetadataStore::init_global(cx);
 7490        language_model::LanguageModelRegistry::test(cx);
 7491        prompt_store::init(cx);
 7492    });
 7493
 7494    let fs = FakeFs::new(cx.executor());
 7495
 7496    fs.insert_tree(
 7497        "/project",
 7498        serde_json::json!({
 7499            ".git": {},
 7500            "src": {},
 7501        }),
 7502    )
 7503    .await;
 7504
 7505    fs.insert_tree(
 7506        "/wt-ochre-drift",
 7507        serde_json::json!({
 7508            ".git": "gitdir: /project/.git/worktrees/ochre-drift",
 7509            "src": {},
 7510        }),
 7511    )
 7512    .await;
 7513
 7514    fs.add_linked_worktree_for_repo(
 7515        Path::new("/project/.git"),
 7516        false,
 7517        git::repository::Worktree {
 7518            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7519            ref_name: Some("refs/heads/ochre-drift".into()),
 7520            sha: "aaa".into(),
 7521            is_main: false,
 7522            is_bare: false,
 7523        },
 7524    )
 7525    .await;
 7526
 7527    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7528
 7529    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7530    let worktree_project =
 7531        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7532
 7533    main_project
 7534        .update(cx, |p, cx| p.git_scans_complete(cx))
 7535        .await;
 7536    worktree_project
 7537        .update(cx, |p, cx| p.git_scans_complete(cx))
 7538        .await;
 7539
 7540    let (multi_workspace, cx) =
 7541        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7542
 7543    let sidebar = setup_sidebar(&multi_workspace, cx);
 7544    let main_workspace =
 7545        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7546    let _main_panel = add_agent_panel(&main_workspace, cx);
 7547    cx.run_until_parked();
 7548
 7549    let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
 7550    let original_thread_id = ThreadId::new();
 7551    let main_paths = PathList::new(&[PathBuf::from("/project")]);
 7552    let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
 7553
 7554    cx.update(|_, cx| {
 7555        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7556            store.save(
 7557                ThreadMetadata {
 7558                    thread_id: original_thread_id,
 7559                    session_id: Some(session_id.clone()),
 7560                    agent_id: agent::ZED_AGENT_ID.clone(),
 7561                    title: Some("Unarchived Linked Thread".into()),
 7562                    updated_at: Utc::now(),
 7563                    created_at: None,
 7564                    interacted_at: None,
 7565                    worktree_paths: WorktreePaths::from_path_lists(
 7566                        main_paths.clone(),
 7567                        folder_paths.clone(),
 7568                    )
 7569                    .expect("main and folder paths should be well-formed"),
 7570                    archived: true,
 7571                    remote_connection: None,
 7572                },
 7573                cx,
 7574            )
 7575        });
 7576    });
 7577    cx.run_until_parked();
 7578
 7579    let metadata = cx.update(|_, cx| {
 7580        ThreadMetadataStore::global(cx)
 7581            .read(cx)
 7582            .entry(original_thread_id)
 7583            .cloned()
 7584            .expect("archived linked-worktree metadata should exist before restore")
 7585    });
 7586
 7587    sidebar.update_in(cx, |sidebar, window, cx| {
 7588        sidebar.open_thread_from_archive(metadata, window, cx);
 7589    });
 7590    cx.run_until_parked();
 7591
 7592    assert_eq!(
 7593        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7594        2,
 7595        "expected unarchive to open the linked worktree workspace into the project group"
 7596    );
 7597
 7598    let session_entries = cx.update(|_, cx| {
 7599        ThreadMetadataStore::global(cx)
 7600            .read(cx)
 7601            .entries()
 7602            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7603            .cloned()
 7604            .collect::<Vec<_>>()
 7605    });
 7606    assert_eq!(
 7607        session_entries.len(),
 7608        1,
 7609        "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
 7610    );
 7611    assert_eq!(
 7612        session_entries[0].thread_id, original_thread_id,
 7613        "expected unarchive to reuse the original linked worktree thread id"
 7614    );
 7615    assert!(
 7616        !session_entries[0].archived,
 7617        "expected restored linked worktree metadata to be unarchived, got: {:?}",
 7618        session_entries[0]
 7619    );
 7620
 7621    let assert_no_extra_rows = |entries: &[String]| {
 7622        let real_thread_rows = entries
 7623            .iter()
 7624            .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7625            .filter(|entry| !entry.contains("Draft"))
 7626            .count();
 7627        assert_eq!(
 7628            real_thread_rows, 1,
 7629            "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
 7630        );
 7631        assert!(
 7632            !entries.iter().any(|entry| entry.contains("Draft")),
 7633            "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
 7634        );
 7635        assert!(
 7636            !entries
 7637                .iter()
 7638                .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
 7639            "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
 7640        );
 7641        assert!(
 7642            entries
 7643                .iter()
 7644                .any(|entry| entry.contains("Unarchived Linked Thread")),
 7645            "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
 7646        );
 7647    };
 7648
 7649    let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
 7650    assert_no_extra_rows(&entries_after_restore);
 7651
 7652    // The reported bug may only appear after an extra scheduling turn.
 7653    cx.run_until_parked();
 7654
 7655    let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
 7656    assert_no_extra_rows(&entries_after_extra_turns);
 7657}
 7658
 7659#[gpui::test]
 7660async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
 7661    // When a linked worktree thread is archived but the group has other
 7662    // threads (e.g. on the main project), archive_thread should select
 7663    // the nearest sibling.
 7664    agent_ui::test_support::init_test(cx);
 7665    cx.update(|cx| {
 7666        ThreadStore::init_global(cx);
 7667        ThreadMetadataStore::init_global(cx);
 7668        language_model::LanguageModelRegistry::test(cx);
 7669        prompt_store::init(cx);
 7670    });
 7671
 7672    let fs = FakeFs::new(cx.executor());
 7673
 7674    fs.insert_tree(
 7675        "/project",
 7676        serde_json::json!({
 7677            ".git": {},
 7678            "src": {},
 7679        }),
 7680    )
 7681    .await;
 7682
 7683    fs.add_linked_worktree_for_repo(
 7684        Path::new("/project/.git"),
 7685        false,
 7686        git::repository::Worktree {
 7687            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7688            ref_name: Some("refs/heads/ochre-drift".into()),
 7689            sha: "aaa".into(),
 7690            is_main: false,
 7691            is_bare: false,
 7692        },
 7693    )
 7694    .await;
 7695
 7696    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7697
 7698    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7699    let worktree_project =
 7700        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7701
 7702    main_project
 7703        .update(cx, |p, cx| p.git_scans_complete(cx))
 7704        .await;
 7705    worktree_project
 7706        .update(cx, |p, cx| p.git_scans_complete(cx))
 7707        .await;
 7708
 7709    let (multi_workspace, cx) =
 7710        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7711
 7712    let sidebar = setup_sidebar(&multi_workspace, cx);
 7713
 7714    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7715        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7716    });
 7717
 7718    let main_workspace =
 7719        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7720    let _main_panel = add_agent_panel(&main_workspace, cx);
 7721    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7722
 7723    // Activate the linked worktree workspace.
 7724    multi_workspace.update_in(cx, |mw, window, cx| {
 7725        mw.activate(worktree_workspace.clone(), None, window, cx);
 7726    });
 7727
 7728    // Open a thread on the linked worktree.
 7729    let connection = StubAgentConnection::new();
 7730    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7731    send_message(&worktree_panel, cx);
 7732
 7733    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7734
 7735    cx.update(|_, cx| {
 7736        connection.send_update(
 7737            worktree_thread_id.clone(),
 7738            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7739            cx,
 7740        );
 7741    });
 7742
 7743    save_thread_metadata(
 7744        worktree_thread_id.clone(),
 7745        Some("Ochre Drift Thread".into()),
 7746        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7747        None,
 7748        None,
 7749        &worktree_project,
 7750        cx,
 7751    );
 7752
 7753    // Save a sibling thread on the main project.
 7754    let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
 7755    save_thread_metadata(
 7756        main_thread_id,
 7757        Some("Main Project Thread".into()),
 7758        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7759        None,
 7760        None,
 7761        &main_project,
 7762        cx,
 7763    );
 7764
 7765    cx.run_until_parked();
 7766
 7767    // Confirm the worktree thread is active.
 7768    sidebar.read_with(cx, |s, _| {
 7769        assert_active_thread(
 7770            s,
 7771            &worktree_thread_id,
 7772            "worktree thread should be active before archiving",
 7773        );
 7774    });
 7775
 7776    // Archive the worktree thread.
 7777    sidebar.update_in(cx, |sidebar, window, cx| {
 7778        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7779    });
 7780
 7781    cx.run_until_parked();
 7782
 7783    // The worktree workspace was removed and a draft was created on the
 7784    // main workspace. No entry should reference the linked worktree.
 7785    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7786    assert!(
 7787        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7788        "no entry should reference the archived worktree, got: {entries_after:?}"
 7789    );
 7790
 7791    // The main project thread should still be visible.
 7792    assert!(
 7793        entries_after
 7794            .iter()
 7795            .any(|s| s.contains("Main Project Thread")),
 7796        "main project thread should still be visible, got: {entries_after:?}"
 7797    );
 7798}
 7799
 7800// TODO: Restore this test once linked worktree draft entries are re-implemented.
 7801// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
 7802#[gpui::test]
 7803#[ignore = "linked worktree draft entries not yet implemented"]
 7804async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
 7805    init_test(cx);
 7806    let fs = FakeFs::new(cx.executor());
 7807
 7808    fs.insert_tree(
 7809        "/project",
 7810        serde_json::json!({
 7811            ".git": {
 7812                "worktrees": {
 7813                    "feature-a": {
 7814                        "commondir": "../../",
 7815                        "HEAD": "ref: refs/heads/feature-a",
 7816                    },
 7817                },
 7818            },
 7819            "src": {},
 7820        }),
 7821    )
 7822    .await;
 7823
 7824    fs.insert_tree(
 7825        "/wt-feature-a",
 7826        serde_json::json!({
 7827            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7828            "src": {},
 7829        }),
 7830    )
 7831    .await;
 7832
 7833    fs.add_linked_worktree_for_repo(
 7834        Path::new("/project/.git"),
 7835        false,
 7836        git::repository::Worktree {
 7837            path: PathBuf::from("/wt-feature-a"),
 7838            ref_name: Some("refs/heads/feature-a".into()),
 7839            sha: "aaa".into(),
 7840            is_main: false,
 7841            is_bare: false,
 7842        },
 7843    )
 7844    .await;
 7845
 7846    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7847
 7848    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7849    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7850
 7851    main_project
 7852        .update(cx, |p, cx| p.git_scans_complete(cx))
 7853        .await;
 7854    worktree_project
 7855        .update(cx, |p, cx| p.git_scans_complete(cx))
 7856        .await;
 7857
 7858    let (multi_workspace, cx) =
 7859        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7860    let sidebar = setup_sidebar(&multi_workspace, cx);
 7861
 7862    // Open the linked worktree as a separate workspace (simulates cmd-o).
 7863    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7864        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7865    });
 7866    add_agent_panel(&worktree_workspace, cx);
 7867    cx.run_until_parked();
 7868
 7869    // Explicitly create a draft thread from the linked worktree workspace.
 7870    // Auto-created drafts use the group's first workspace (the main one),
 7871    // so a user-created draft is needed to make the linked worktree reachable.
 7872    sidebar.update_in(cx, |sidebar, window, cx| {
 7873        sidebar.create_new_thread(&worktree_workspace, window, cx);
 7874    });
 7875    cx.run_until_parked();
 7876
 7877    // Switch back to the main workspace.
 7878    multi_workspace.update_in(cx, |mw, window, cx| {
 7879        let main_ws = mw.workspaces().next().unwrap().clone();
 7880        mw.activate(main_ws, None, window, cx);
 7881    });
 7882    cx.run_until_parked();
 7883
 7884    sidebar.update_in(cx, |sidebar, _window, cx| {
 7885        sidebar.update_entries(cx);
 7886    });
 7887    cx.run_until_parked();
 7888
 7889    // The linked worktree workspace must be reachable from some sidebar entry.
 7890    let worktree_ws_id = worktree_workspace.entity_id();
 7891    let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
 7892        let mw = multi_workspace.read(cx);
 7893        sidebar
 7894            .contents
 7895            .entries
 7896            .iter()
 7897            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 7898            .map(|ws| ws.entity_id())
 7899            .collect()
 7900    });
 7901    assert!(
 7902        reachable.contains(&worktree_ws_id),
 7903        "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
 7904    );
 7905
 7906    // Find the draft Thread entry whose workspace is the linked worktree.
 7907    let _ = (worktree_ws_id, sidebar, multi_workspace);
 7908    // todo("re-implement once linked worktree draft entries exist");
 7909}
 7910
 7911#[gpui::test]
 7912async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
 7913    // When only a linked worktree workspace is open (not the main repo),
 7914    // threads saved against the main repo should still appear in the sidebar.
 7915    init_test(cx);
 7916    let fs = FakeFs::new(cx.executor());
 7917
 7918    // Create the main repo with a linked worktree.
 7919    fs.insert_tree(
 7920        "/project",
 7921        serde_json::json!({
 7922            ".git": {
 7923                "worktrees": {
 7924                    "feature-a": {
 7925                        "commondir": "../../",
 7926                        "HEAD": "ref: refs/heads/feature-a",
 7927                    },
 7928                },
 7929            },
 7930            "src": {},
 7931        }),
 7932    )
 7933    .await;
 7934
 7935    fs.insert_tree(
 7936        "/wt-feature-a",
 7937        serde_json::json!({
 7938            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7939            "src": {},
 7940        }),
 7941    )
 7942    .await;
 7943
 7944    fs.add_linked_worktree_for_repo(
 7945        std::path::Path::new("/project/.git"),
 7946        false,
 7947        git::repository::Worktree {
 7948            path: std::path::PathBuf::from("/wt-feature-a"),
 7949            ref_name: Some("refs/heads/feature-a".into()),
 7950            sha: "abc".into(),
 7951            is_main: false,
 7952            is_bare: false,
 7953        },
 7954    )
 7955    .await;
 7956
 7957    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7958
 7959    // Only open the linked worktree as a workspace — NOT the main repo.
 7960    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7961    worktree_project
 7962        .update(cx, |p, cx| p.git_scans_complete(cx))
 7963        .await;
 7964
 7965    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7966    main_project
 7967        .update(cx, |p, cx| p.git_scans_complete(cx))
 7968        .await;
 7969
 7970    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 7971        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 7972    });
 7973    let sidebar = setup_sidebar(&multi_workspace, cx);
 7974
 7975    // Save a thread against the MAIN repo path.
 7976    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
 7977
 7978    // Save a thread against the linked worktree path.
 7979    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
 7980
 7981    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7982    cx.run_until_parked();
 7983
 7984    // Both threads should be visible: the worktree thread by direct lookup,
 7985    // and the main repo thread because the workspace is a linked worktree
 7986    // and we also query the main repo path.
 7987    let entries = visible_entries_as_strings(&sidebar, cx);
 7988    assert!(
 7989        entries.iter().any(|e| e.contains("Main Repo Thread")),
 7990        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
 7991    );
 7992    assert!(
 7993        entries.iter().any(|e| e.contains("Worktree Thread")),
 7994        "expected worktree thread to be visible, got: {entries:?}"
 7995    );
 7996}
 7997
 7998async fn init_multi_project_test(
 7999    paths: &[&str],
 8000    cx: &mut TestAppContext,
 8001) -> (Arc<FakeFs>, Entity<project::Project>) {
 8002    agent_ui::test_support::init_test(cx);
 8003    cx.update(|cx| {
 8004        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 8005        ThreadStore::init_global(cx);
 8006        ThreadMetadataStore::init_global(cx);
 8007        language_model::LanguageModelRegistry::test(cx);
 8008        prompt_store::init(cx);
 8009    });
 8010    let fs = FakeFs::new(cx.executor());
 8011    for path in paths {
 8012        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
 8013            .await;
 8014    }
 8015    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8016    let project =
 8017        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
 8018    (fs, project)
 8019}
 8020
 8021async fn add_test_project(
 8022    path: &str,
 8023    fs: &Arc<FakeFs>,
 8024    multi_workspace: &Entity<MultiWorkspace>,
 8025    cx: &mut gpui::VisualTestContext,
 8026) -> Entity<Workspace> {
 8027    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
 8028    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8029        mw.test_add_workspace(project, window, cx)
 8030    });
 8031    cx.run_until_parked();
 8032    workspace
 8033}
 8034
 8035#[gpui::test]
 8036async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
 8037    let (fs, project_a) =
 8038        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8039    let (multi_workspace, cx) =
 8040        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8041    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
 8042
 8043    // Sidebar starts closed. Initial workspace A is transient.
 8044    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8045    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 8046    assert_eq!(
 8047        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8048        1
 8049    );
 8050    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
 8051
 8052    // Add B — replaces A as the transient workspace.
 8053    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8054    assert_eq!(
 8055        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8056        1
 8057    );
 8058    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8059
 8060    // Add C — replaces B as the transient workspace.
 8061    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8062    assert_eq!(
 8063        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8064        1
 8065    );
 8066    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8067}
 8068
 8069#[gpui::test]
 8070async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
 8071    let (fs, project_a) = init_multi_project_test(
 8072        &["/project-a", "/project-b", "/project-c", "/project-d"],
 8073        cx,
 8074    )
 8075    .await;
 8076    let (multi_workspace, cx) =
 8077        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8078    let _sidebar = setup_sidebar(&multi_workspace, cx);
 8079    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 8080
 8081    // Add B — retained since sidebar is open.
 8082    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8083    assert_eq!(
 8084        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8085        2
 8086    );
 8087
 8088    // Switch to A — B survives. (Switching from one internal workspace, to another)
 8089    multi_workspace.update_in(cx, |mw, window, cx| {
 8090        mw.activate(workspace_a, None, window, cx)
 8091    });
 8092    cx.run_until_parked();
 8093    assert_eq!(
 8094        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8095        2
 8096    );
 8097
 8098    // Close sidebar — both A and B remain retained.
 8099    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
 8100    cx.run_until_parked();
 8101    assert_eq!(
 8102        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8103        2
 8104    );
 8105
 8106    // Add C — added as new transient workspace. (switching from retained, to transient)
 8107    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8108    assert_eq!(
 8109        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8110        3
 8111    );
 8112    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8113
 8114    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
 8115    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
 8116    assert_eq!(
 8117        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8118        3
 8119    );
 8120    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
 8121}
 8122
 8123#[gpui::test]
 8124async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
 8125    let (fs, project_a) =
 8126        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8127    let (multi_workspace, cx) =
 8128        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8129    setup_sidebar_closed(&multi_workspace, cx);
 8130
 8131    // Add B — replaces A as the transient workspace (A is discarded).
 8132    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8133    assert_eq!(
 8134        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8135        1
 8136    );
 8137    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8138
 8139    // Open sidebar — promotes the transient B to retained.
 8140    multi_workspace.update_in(cx, |mw, window, cx| {
 8141        mw.toggle_sidebar(window, cx);
 8142    });
 8143    cx.run_until_parked();
 8144    assert_eq!(
 8145        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8146        1
 8147    );
 8148    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
 8149
 8150    // Close sidebar — the retained B remains.
 8151    multi_workspace.update_in(cx, |mw, window, cx| {
 8152        mw.toggle_sidebar(window, cx);
 8153    });
 8154
 8155    // Add C — added as new transient workspace.
 8156    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8157    assert_eq!(
 8158        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8159        2
 8160    );
 8161    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8162}
 8163
 8164#[gpui::test]
 8165async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
 8166    init_test(cx);
 8167    let fs = FakeFs::new(cx.executor());
 8168
 8169    fs.insert_tree(
 8170        "/project",
 8171        serde_json::json!({
 8172            ".git": {
 8173                "worktrees": {
 8174                    "feature-a": {
 8175                        "commondir": "../../",
 8176                        "HEAD": "ref: refs/heads/feature-a",
 8177                    },
 8178                },
 8179            },
 8180            "src": {},
 8181        }),
 8182    )
 8183    .await;
 8184
 8185    fs.insert_tree(
 8186        "/wt-feature-a",
 8187        serde_json::json!({
 8188            ".git": "gitdir: /project/.git/worktrees/feature-a",
 8189            "src": {},
 8190        }),
 8191    )
 8192    .await;
 8193
 8194    fs.add_linked_worktree_for_repo(
 8195        Path::new("/project/.git"),
 8196        false,
 8197        git::repository::Worktree {
 8198            path: PathBuf::from("/wt-feature-a"),
 8199            ref_name: Some("refs/heads/feature-a".into()),
 8200            sha: "abc".into(),
 8201            is_main: false,
 8202            is_bare: false,
 8203        },
 8204    )
 8205    .await;
 8206
 8207    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8208
 8209    // Only a linked worktree workspace is open — no workspace for /project.
 8210    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8211    worktree_project
 8212        .update(cx, |p, cx| p.git_scans_complete(cx))
 8213        .await;
 8214
 8215    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 8216        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 8217    });
 8218    let sidebar = setup_sidebar(&multi_workspace, cx);
 8219
 8220    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
 8221    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
 8222    cx.update(|_, cx| {
 8223        let metadata = ThreadMetadata {
 8224            thread_id: ThreadId::new(),
 8225            session_id: Some(legacy_session.clone()),
 8226            agent_id: agent::ZED_AGENT_ID.clone(),
 8227            title: Some("Legacy Main Thread".into()),
 8228            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8229            created_at: None,
 8230            interacted_at: None,
 8231            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 8232                "/project",
 8233            )])),
 8234            archived: false,
 8235            remote_connection: None,
 8236        };
 8237        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
 8238    });
 8239    cx.run_until_parked();
 8240
 8241    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 8242    cx.run_until_parked();
 8243
 8244    // The legacy thread should appear in the sidebar under the project group.
 8245    let entries = visible_entries_as_strings(&sidebar, cx);
 8246    assert!(
 8247        entries.iter().any(|e| e.contains("Legacy Main Thread")),
 8248        "legacy thread should be visible: {entries:?}",
 8249    );
 8250
 8251    // Verify only 1 workspace before clicking.
 8252    assert_eq!(
 8253        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8254        1,
 8255    );
 8256
 8257    // Focus and select the legacy thread, then confirm.
 8258    focus_sidebar(&sidebar, cx);
 8259    let thread_index = sidebar.read_with(cx, |sidebar, _| {
 8260        sidebar
 8261            .contents
 8262            .entries
 8263            .iter()
 8264            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
 8265            .expect("legacy thread should be in entries")
 8266    });
 8267    sidebar.update_in(cx, |sidebar, _window, _cx| {
 8268        sidebar.selection = Some(thread_index);
 8269    });
 8270    cx.dispatch_action(Confirm);
 8271    cx.run_until_parked();
 8272
 8273    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8274    let new_path_list =
 8275        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 8276    assert_eq!(
 8277        new_path_list,
 8278        PathList::new(&[PathBuf::from("/project")]),
 8279        "the new workspace should be for the main repo, not the linked worktree",
 8280    );
 8281}
 8282
 8283#[gpui::test]
 8284async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
 8285    cx: &mut TestAppContext,
 8286) {
 8287    // Regression test for a property-test finding:
 8288    //   AddLinkedWorktree { project_group_index: 0 }
 8289    //   AddProject { use_worktree: true }
 8290    //   AddProject { use_worktree: false }
 8291    // After these three steps, the linked-worktree workspace was not
 8292    // reachable from any sidebar entry.
 8293    agent_ui::test_support::init_test(cx);
 8294    cx.update(|cx| {
 8295        ThreadStore::init_global(cx);
 8296        ThreadMetadataStore::init_global(cx);
 8297        language_model::LanguageModelRegistry::test(cx);
 8298        prompt_store::init(cx);
 8299
 8300        cx.observe_new(
 8301            |workspace: &mut Workspace,
 8302             window: Option<&mut Window>,
 8303             cx: &mut gpui::Context<Workspace>| {
 8304                if let Some(window) = window {
 8305                    let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 8306                    workspace.add_panel(panel, window, cx);
 8307                }
 8308            },
 8309        )
 8310        .detach();
 8311    });
 8312
 8313    let fs = FakeFs::new(cx.executor());
 8314    fs.insert_tree(
 8315        "/my-project",
 8316        serde_json::json!({
 8317            ".git": {},
 8318            "src": {},
 8319        }),
 8320    )
 8321    .await;
 8322    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8323    let project =
 8324        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
 8325    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8326
 8327    let (multi_workspace, cx) =
 8328        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8329    let sidebar = setup_sidebar(&multi_workspace, cx);
 8330
 8331    // Step 1: Create a linked worktree for the main project.
 8332    let worktree_name = "wt-0";
 8333    let worktree_path = "/worktrees/wt-0";
 8334
 8335    fs.insert_tree(
 8336        worktree_path,
 8337        serde_json::json!({
 8338            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8339            "src": {},
 8340        }),
 8341    )
 8342    .await;
 8343    fs.insert_tree(
 8344        "/my-project/.git/worktrees/wt-0",
 8345        serde_json::json!({
 8346            "commondir": "../../",
 8347            "HEAD": "ref: refs/heads/wt-0",
 8348        }),
 8349    )
 8350    .await;
 8351    fs.add_linked_worktree_for_repo(
 8352        Path::new("/my-project/.git"),
 8353        false,
 8354        git::repository::Worktree {
 8355            path: PathBuf::from(worktree_path),
 8356            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 8357            sha: "aaa".into(),
 8358            is_main: false,
 8359            is_bare: false,
 8360        },
 8361    )
 8362    .await;
 8363
 8364    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8365    let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 8366    main_project
 8367        .update(cx, |p, cx| p.git_scans_complete(cx))
 8368        .await;
 8369    cx.run_until_parked();
 8370
 8371    // Step 2: Open the linked worktree as its own workspace.
 8372    let worktree_project =
 8373        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
 8374    worktree_project
 8375        .update(cx, |p, cx| p.git_scans_complete(cx))
 8376        .await;
 8377    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8378        mw.test_add_workspace(worktree_project.clone(), window, cx)
 8379    });
 8380    cx.run_until_parked();
 8381
 8382    // Step 3: Add an unrelated project.
 8383    fs.insert_tree(
 8384        "/other-project",
 8385        serde_json::json!({
 8386            ".git": {},
 8387            "src": {},
 8388        }),
 8389    )
 8390    .await;
 8391    let other_project = project::Project::test(
 8392        fs.clone() as Arc<dyn fs::Fs>,
 8393        ["/other-project".as_ref()],
 8394        cx,
 8395    )
 8396    .await;
 8397    other_project
 8398        .update(cx, |p, cx| p.git_scans_complete(cx))
 8399        .await;
 8400    multi_workspace.update_in(cx, |mw, window, cx| {
 8401        mw.test_add_workspace(other_project.clone(), window, cx);
 8402    });
 8403    cx.run_until_parked();
 8404
 8405    // Force a full sidebar rebuild with all groups expanded.
 8406    sidebar.update_in(cx, |sidebar, _window, cx| {
 8407        if let Some(mw) = sidebar.multi_workspace.upgrade() {
 8408            mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 8409        }
 8410        sidebar.update_entries(cx);
 8411    });
 8412    cx.run_until_parked();
 8413
 8414    // The linked-worktree workspace must be reachable from at least one
 8415    // sidebar entry — otherwise the user has no way to navigate to it.
 8416    let worktree_ws_id = worktree_workspace.entity_id();
 8417    let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
 8418        let mw = multi_workspace.read(cx);
 8419
 8420        let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
 8421        let reachable: HashSet<gpui::EntityId> = sidebar
 8422            .contents
 8423            .entries
 8424            .iter()
 8425            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 8426            .map(|ws| ws.entity_id())
 8427            .collect();
 8428        (all, reachable)
 8429    });
 8430
 8431    let unreachable = &all_ids - &reachable_ids;
 8432    eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
 8433
 8434    assert!(
 8435        unreachable.is_empty(),
 8436        "workspaces not reachable from any sidebar entry: {:?}\n\
 8437         (linked-worktree workspace id: {:?})",
 8438        unreachable,
 8439        worktree_ws_id,
 8440    );
 8441}
 8442
 8443#[gpui::test]
 8444async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
 8445    // Empty project groups no longer auto-create drafts via reconciliation.
 8446    // A fresh startup with no restorable thread should show only the header.
 8447    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8448    let (multi_workspace, cx) =
 8449        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8450    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8451
 8452    let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8453
 8454    let entries = visible_entries_as_strings(&sidebar, cx);
 8455    assert_eq!(
 8456        entries,
 8457        vec!["v [my-project]"],
 8458        "empty group should show only the header, no auto-created draft"
 8459    );
 8460}
 8461
 8462#[gpui::test]
 8463async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
 8464    // Rule 5: When the app starts and the AgentPanel successfully loads
 8465    // a thread, no spurious draft should appear.
 8466    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8467    let (multi_workspace, cx) =
 8468        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8469    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8470
 8471    // Create and send a message to make a real thread.
 8472    let connection = StubAgentConnection::new();
 8473    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8474        acp::ContentChunk::new("Done".into()),
 8475    )]);
 8476    open_thread_with_connection(&panel, connection, cx);
 8477    send_message(&panel, cx);
 8478    let session_id = active_session_id(&panel, cx);
 8479    save_test_thread_metadata(&session_id, &project, cx).await;
 8480    cx.run_until_parked();
 8481
 8482    // Should show the thread, NOT a spurious draft.
 8483    let entries = visible_entries_as_strings(&sidebar, cx);
 8484    assert_eq!(entries, vec!["v [my-project]", "  Hello *"]);
 8485
 8486    // active_entry should be Thread, not Draft.
 8487    sidebar.read_with(cx, |sidebar, _| {
 8488        assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
 8489    });
 8490}
 8491
 8492#[gpui::test]
 8493async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
 8494    // Rule 9: Clicking a project header should restore whatever the
 8495    // user was last looking at in that group, not create new drafts
 8496    // or jump to the first entry.
 8497    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 8498    let (multi_workspace, cx) =
 8499        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8500    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8501
 8502    // Create two threads in project-a.
 8503    let conn1 = StubAgentConnection::new();
 8504    conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8505        acp::ContentChunk::new("Done".into()),
 8506    )]);
 8507    open_thread_with_connection(&panel_a, conn1, cx);
 8508    send_message(&panel_a, cx);
 8509    let thread_a1 = active_session_id(&panel_a, cx);
 8510    save_test_thread_metadata(&thread_a1, &project_a, cx).await;
 8511
 8512    let conn2 = StubAgentConnection::new();
 8513    conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8514        acp::ContentChunk::new("Done".into()),
 8515    )]);
 8516    open_thread_with_connection(&panel_a, conn2, cx);
 8517    send_message(&panel_a, cx);
 8518    let thread_a2 = active_session_id(&panel_a, cx);
 8519    save_test_thread_metadata(&thread_a2, &project_a, cx).await;
 8520    cx.run_until_parked();
 8521
 8522    // The user is now looking at thread_a2.
 8523    sidebar.read_with(cx, |sidebar, _| {
 8524        assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
 8525    });
 8526
 8527    // Add project-b and switch to it.
 8528    let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
 8529    fs.as_fake()
 8530        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 8531        .await;
 8532    let project_b =
 8533        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8534    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8535        mw.test_add_workspace(project_b.clone(), window, cx)
 8536    });
 8537    let _panel_b = add_agent_panel(&workspace_b, cx);
 8538    cx.run_until_parked();
 8539
 8540    // Now switch BACK to project-a by activating its workspace.
 8541    let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
 8542        mw.workspaces()
 8543            .find(|ws| {
 8544                ws.read(cx)
 8545                    .project()
 8546                    .read(cx)
 8547                    .visible_worktrees(cx)
 8548                    .any(|wt| {
 8549                        wt.read(cx)
 8550                            .abs_path()
 8551                            .to_string_lossy()
 8552                            .contains("project-a")
 8553                    })
 8554            })
 8555            .unwrap()
 8556            .clone()
 8557    });
 8558    multi_workspace.update_in(cx, |mw, window, cx| {
 8559        mw.activate(workspace_a.clone(), None, window, cx);
 8560    });
 8561    cx.run_until_parked();
 8562
 8563    // The panel should still show thread_a2 (the last thing the user
 8564    // was viewing in project-a), not a draft or thread_a1.
 8565    sidebar.read_with(cx, |sidebar, _| {
 8566        assert_active_thread(
 8567            sidebar,
 8568            &thread_a2,
 8569            "switching back to project-a should restore thread_a2",
 8570        );
 8571    });
 8572
 8573    // No spurious draft entries should have been created in
 8574    // project-a's group (project-b may have a placeholder).
 8575    let entries = visible_entries_as_strings(&sidebar, cx);
 8576    // Find project-a's section and check it has no drafts.
 8577    let project_a_start = entries
 8578        .iter()
 8579        .position(|e| e.contains("project-a"))
 8580        .unwrap();
 8581    let project_a_end = entries[project_a_start + 1..]
 8582        .iter()
 8583        .position(|e| e.starts_with("v "))
 8584        .map(|i| i + project_a_start + 1)
 8585        .unwrap_or(entries.len());
 8586    let project_a_drafts = entries[project_a_start..project_a_end]
 8587        .iter()
 8588        .filter(|e| e.contains("Draft"))
 8589        .count();
 8590    assert_eq!(
 8591        project_a_drafts, 0,
 8592        "switching back to project-a should not create drafts in its group"
 8593    );
 8594}
 8595
 8596#[gpui::test]
 8597async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
 8598    // When a workspace has a draft (from the panel's load fallback)
 8599    // and the user activates it (e.g. by clicking the placeholder or
 8600    // the project header), no extra drafts should be created.
 8601    init_test(cx);
 8602    let fs = FakeFs::new(cx.executor());
 8603    fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
 8604        .await;
 8605    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8606        .await;
 8607    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8608
 8609    let project_a =
 8610        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
 8611    let (multi_workspace, cx) =
 8612        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8613    let sidebar = setup_sidebar(&multi_workspace, cx);
 8614    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8615    let _panel_a = add_agent_panel(&workspace_a, cx);
 8616    cx.run_until_parked();
 8617
 8618    // Add project-b with its own workspace and agent panel.
 8619    let project_b =
 8620        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8621    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8622        mw.test_add_workspace(project_b.clone(), window, cx)
 8623    });
 8624    let _panel_b = add_agent_panel(&workspace_b, cx);
 8625    cx.run_until_parked();
 8626
 8627    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 8628    sidebar.update_in(cx, |sidebar, window, cx| {
 8629        sidebar.create_new_thread(&workspace_b, window, cx);
 8630    });
 8631    cx.run_until_parked();
 8632
 8633    // Count project-b's drafts.
 8634    let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
 8635        let entries = visible_entries_as_strings(&sidebar, cx);
 8636        entries
 8637            .iter()
 8638            .skip_while(|e| !e.contains("project-b"))
 8639            .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
 8640            .filter(|e| e.contains("Draft"))
 8641            .count()
 8642    };
 8643    let drafts_before = count_b_drafts(cx);
 8644
 8645    // Switch away from project-b, then back.
 8646    multi_workspace.update_in(cx, |mw, window, cx| {
 8647        mw.activate(workspace_a.clone(), None, window, cx);
 8648    });
 8649    cx.run_until_parked();
 8650    multi_workspace.update_in(cx, |mw, window, cx| {
 8651        mw.activate(workspace_b.clone(), None, window, cx);
 8652    });
 8653    cx.run_until_parked();
 8654
 8655    let drafts_after = count_b_drafts(cx);
 8656    assert_eq!(
 8657        drafts_before, drafts_after,
 8658        "activating workspace should not create extra drafts"
 8659    );
 8660
 8661    // The draft should be highlighted as active after switching back.
 8662    sidebar.read_with(cx, |sidebar, _| {
 8663        assert_active_draft(
 8664            sidebar,
 8665            &workspace_b,
 8666            "draft should be active after switching back to its workspace",
 8667        );
 8668    });
 8669}
 8670
 8671#[gpui::test]
 8672async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
 8673    // Historical threads (not open in any agent panel) should have their
 8674    // worktree paths updated when a folder is added to or removed from the
 8675    // project.
 8676    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
 8677    let (multi_workspace, cx) =
 8678        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8679    let sidebar = setup_sidebar(&multi_workspace, cx);
 8680
 8681    // Save two threads directly into the metadata store (not via the agent
 8682    // panel), so they are purely historical — no open views hold them.
 8683    // Use different timestamps so sort order is deterministic.
 8684    save_thread_metadata(
 8685        acp::SessionId::new(Arc::from("hist-1")),
 8686        Some("Historical 1".into()),
 8687        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8688        None,
 8689        None,
 8690        &project,
 8691        cx,
 8692    );
 8693    save_thread_metadata(
 8694        acp::SessionId::new(Arc::from("hist-2")),
 8695        Some("Historical 2".into()),
 8696        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 8697        None,
 8698        None,
 8699        &project,
 8700        cx,
 8701    );
 8702    cx.run_until_parked();
 8703    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8704    cx.run_until_parked();
 8705
 8706    // Sanity-check: both threads exist under the initial key [/project-a].
 8707    let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
 8708    cx.update(|_window, cx| {
 8709        let store = ThreadMetadataStore::global(cx).read(cx);
 8710        assert_eq!(
 8711            store
 8712                .entries_for_main_worktree_path(&old_key_paths, None)
 8713                .count(),
 8714            2,
 8715            "should have 2 historical threads under old key before worktree add"
 8716        );
 8717    });
 8718
 8719    // Add a second worktree to the project.
 8720    project
 8721        .update(cx, |project, cx| {
 8722            project.find_or_create_worktree("/project-b", true, cx)
 8723        })
 8724        .await
 8725        .expect("should add worktree");
 8726    cx.run_until_parked();
 8727
 8728    // The historical threads should now be indexed under the new combined
 8729    // key [/project-a, /project-b].
 8730    let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
 8731    cx.update(|_window, cx| {
 8732        let store = ThreadMetadataStore::global(cx).read(cx);
 8733        assert_eq!(
 8734            store
 8735                .entries_for_main_worktree_path(&old_key_paths, None)
 8736                .count(),
 8737            0,
 8738            "should have 0 historical threads under old key after worktree add"
 8739        );
 8740        assert_eq!(
 8741            store
 8742                .entries_for_main_worktree_path(&new_key_paths, None)
 8743                .count(),
 8744            2,
 8745            "should have 2 historical threads under new key after worktree add"
 8746        );
 8747    });
 8748
 8749    // Sidebar should show threads under the new header.
 8750    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8751    cx.run_until_parked();
 8752    assert_eq!(
 8753        visible_entries_as_strings(&sidebar, cx),
 8754        vec![
 8755            "v [project-a, project-b]",
 8756            "  Historical 2",
 8757            "  Historical 1",
 8758        ]
 8759    );
 8760
 8761    // Now remove the second worktree.
 8762    let worktree_id = project.read_with(cx, |project, cx| {
 8763        project
 8764            .visible_worktrees(cx)
 8765            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
 8766            .map(|wt| wt.read(cx).id())
 8767            .expect("should find project-b worktree")
 8768    });
 8769    project.update(cx, |project, cx| {
 8770        project.remove_worktree(worktree_id, cx);
 8771    });
 8772    cx.run_until_parked();
 8773
 8774    // Historical threads should migrate back to the original key.
 8775    cx.update(|_window, cx| {
 8776        let store = ThreadMetadataStore::global(cx).read(cx);
 8777        assert_eq!(
 8778            store
 8779                .entries_for_main_worktree_path(&new_key_paths, None)
 8780                .count(),
 8781            0,
 8782            "should have 0 historical threads under new key after worktree remove"
 8783        );
 8784        assert_eq!(
 8785            store
 8786                .entries_for_main_worktree_path(&old_key_paths, None)
 8787                .count(),
 8788            2,
 8789            "should have 2 historical threads under old key after worktree remove"
 8790        );
 8791    });
 8792
 8793    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8794    cx.run_until_parked();
 8795    assert_eq!(
 8796        visible_entries_as_strings(&sidebar, cx),
 8797        vec!["v [project-a]", "  Historical 2", "  Historical 1",]
 8798    );
 8799}
 8800
 8801#[gpui::test]
 8802async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
 8803    // When two workspaces share the same project group (same main path)
 8804    // but have different folder paths (main repo vs linked worktree),
 8805    // adding a worktree to the main workspace should regroup only that
 8806    // workspace and its threads into the new project group. Threads for the
 8807    // linked worktree workspace should remain under the original group.
 8808    agent_ui::test_support::init_test(cx);
 8809    cx.update(|cx| {
 8810        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 8811        ThreadStore::init_global(cx);
 8812        ThreadMetadataStore::init_global(cx);
 8813        language_model::LanguageModelRegistry::test(cx);
 8814        prompt_store::init(cx);
 8815    });
 8816
 8817    let fs = FakeFs::new(cx.executor());
 8818    fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
 8819        .await;
 8820    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8821        .await;
 8822    fs.add_linked_worktree_for_repo(
 8823        Path::new("/project/.git"),
 8824        false,
 8825        git::repository::Worktree {
 8826            path: std::path::PathBuf::from("/wt-feature"),
 8827            ref_name: Some("refs/heads/feature".into()),
 8828            sha: "aaa".into(),
 8829            is_main: false,
 8830            is_bare: false,
 8831        },
 8832    )
 8833    .await;
 8834    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8835
 8836    // Workspace A: main repo at /project.
 8837    let main_project =
 8838        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
 8839    // Workspace B: linked worktree of the same repo (same group, different folder).
 8840    let worktree_project =
 8841        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
 8842
 8843    main_project
 8844        .update(cx, |p, cx| p.git_scans_complete(cx))
 8845        .await;
 8846    worktree_project
 8847        .update(cx, |p, cx| p.git_scans_complete(cx))
 8848        .await;
 8849
 8850    let (multi_workspace, cx) =
 8851        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 8852    let sidebar = setup_sidebar(&multi_workspace, cx);
 8853    multi_workspace.update_in(cx, |mw, window, cx| {
 8854        mw.test_add_workspace(worktree_project.clone(), window, cx);
 8855    });
 8856    cx.run_until_parked();
 8857
 8858    // Save a thread for each workspace's folder paths.
 8859    let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
 8860    let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
 8861    save_thread_metadata(
 8862        acp::SessionId::new(Arc::from("thread-main")),
 8863        Some("Main Thread".into()),
 8864        time_main,
 8865        Some(time_main),
 8866        None,
 8867        &main_project,
 8868        cx,
 8869    );
 8870    save_thread_metadata(
 8871        acp::SessionId::new(Arc::from("thread-wt")),
 8872        Some("Worktree Thread".into()),
 8873        time_wt,
 8874        Some(time_wt),
 8875        None,
 8876        &worktree_project,
 8877        cx,
 8878    );
 8879    cx.run_until_parked();
 8880
 8881    let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
 8882    let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
 8883
 8884    // Sanity-check: each thread is indexed under its own folder paths, but
 8885    // both appear under the shared sidebar group keyed by the main worktree.
 8886    cx.update(|_window, cx| {
 8887        let store = ThreadMetadataStore::global(cx).read(cx);
 8888        assert_eq!(
 8889            store.entries_for_path(&folder_paths_main, None).count(),
 8890            1,
 8891            "one thread under [/project]"
 8892        );
 8893        assert_eq!(
 8894            store.entries_for_path(&folder_paths_wt, None).count(),
 8895            1,
 8896            "one thread under [/wt-feature]"
 8897        );
 8898    });
 8899    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8900    cx.run_until_parked();
 8901    assert_eq!(
 8902        visible_entries_as_strings(&sidebar, cx),
 8903        vec![
 8904            "v [project]",
 8905            "  Worktree Thread {wt-feature}",
 8906            "  Main Thread",
 8907        ]
 8908    );
 8909
 8910    // Add /project-b to the main project only.
 8911    main_project
 8912        .update(cx, |project, cx| {
 8913            project.find_or_create_worktree("/project-b", true, cx)
 8914        })
 8915        .await
 8916        .expect("should add worktree");
 8917    cx.run_until_parked();
 8918
 8919    // Main Thread (folder paths [/project]) should be regrouped to
 8920    // [/project, /project-b]. Worktree Thread should remain under the
 8921    // original [/project] group.
 8922    let folder_paths_main_b =
 8923        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
 8924    cx.update(|_window, cx| {
 8925        let store = ThreadMetadataStore::global(cx).read(cx);
 8926        assert_eq!(
 8927            store.entries_for_path(&folder_paths_main, None).count(),
 8928            0,
 8929            "main thread should no longer be under old folder paths [/project]"
 8930        );
 8931        assert_eq!(
 8932            store.entries_for_path(&folder_paths_main_b, None).count(),
 8933            1,
 8934            "main thread should now be under [/project, /project-b]"
 8935        );
 8936        assert_eq!(
 8937            store.entries_for_path(&folder_paths_wt, None).count(),
 8938            1,
 8939            "worktree thread should remain unchanged under [/wt-feature]"
 8940        );
 8941    });
 8942
 8943    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8944    cx.run_until_parked();
 8945    assert_eq!(
 8946        visible_entries_as_strings(&sidebar, cx),
 8947        vec![
 8948            "v [project]",
 8949            "  Worktree Thread {wt-feature}",
 8950            "v [project, project-b]",
 8951            "  Main Thread",
 8952        ]
 8953    );
 8954}
 8955
 8956#[gpui::test]
 8957async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
 8958    cx: &mut TestAppContext,
 8959) {
 8960    // When a linked worktree is opened as its own workspace and then a new
 8961    // folder is added to the main project group, the linked worktree
 8962    // workspace must still be reachable from some sidebar entry.
 8963    let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
 8964    let fs = _fs.clone();
 8965
 8966    // Set up git worktree infrastructure.
 8967    fs.insert_tree(
 8968        "/my-project/.git/worktrees/wt-0",
 8969        serde_json::json!({
 8970            "commondir": "../../",
 8971            "HEAD": "ref: refs/heads/wt-0",
 8972        }),
 8973    )
 8974    .await;
 8975    fs.insert_tree(
 8976        "/worktrees/wt-0",
 8977        serde_json::json!({
 8978            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8979            "src": {},
 8980        }),
 8981    )
 8982    .await;
 8983    fs.add_linked_worktree_for_repo(
 8984        Path::new("/my-project/.git"),
 8985        false,
 8986        git::repository::Worktree {
 8987            path: PathBuf::from("/worktrees/wt-0"),
 8988            ref_name: Some("refs/heads/wt-0".into()),
 8989            sha: "aaa".into(),
 8990            is_main: false,
 8991            is_bare: false,
 8992        },
 8993    )
 8994    .await;
 8995
 8996    // Re-scan so the main project discovers the linked worktree.
 8997    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8998
 8999    let (multi_workspace, cx) =
 9000        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9001    let sidebar = setup_sidebar(&multi_workspace, cx);
 9002
 9003    // Open the linked worktree as its own workspace.
 9004    let worktree_project = project::Project::test(
 9005        fs.clone() as Arc<dyn fs::Fs>,
 9006        ["/worktrees/wt-0".as_ref()],
 9007        cx,
 9008    )
 9009    .await;
 9010    worktree_project
 9011        .update(cx, |p, cx| p.git_scans_complete(cx))
 9012        .await;
 9013    multi_workspace.update_in(cx, |mw, window, cx| {
 9014        mw.test_add_workspace(worktree_project.clone(), window, cx);
 9015    });
 9016    cx.run_until_parked();
 9017
 9018    // Both workspaces should be reachable.
 9019    let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 9020    assert_eq!(workspace_count, 2, "should have 2 workspaces");
 9021
 9022    // Add a new folder to the main project, changing the project group key.
 9023    fs.insert_tree(
 9024        "/other-project",
 9025        serde_json::json!({ ".git": {}, "src": {} }),
 9026    )
 9027    .await;
 9028    project
 9029        .update(cx, |project, cx| {
 9030            project.find_or_create_worktree("/other-project", true, cx)
 9031        })
 9032        .await
 9033        .expect("should add worktree");
 9034    cx.run_until_parked();
 9035
 9036    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9037    cx.run_until_parked();
 9038
 9039    // The linked worktree workspace must still be reachable.
 9040    let entries = visible_entries_as_strings(&sidebar, cx);
 9041    let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
 9042        mw.workspaces().map(|ws| ws.entity_id()).collect()
 9043    });
 9044    sidebar.read_with(cx, |sidebar, cx| {
 9045        let multi_workspace = multi_workspace.read(cx);
 9046        let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
 9047            .contents
 9048            .entries
 9049            .iter()
 9050            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 9051            .map(|ws| ws.entity_id())
 9052            .collect();
 9053        let all: std::collections::HashSet<gpui::EntityId> =
 9054            mw_workspaces.iter().copied().collect();
 9055        let unreachable = &all - &reachable;
 9056        assert!(
 9057            unreachable.is_empty(),
 9058            "all workspaces should be reachable after adding folder; \
 9059             unreachable: {:?}, entries: {:?}",
 9060            unreachable,
 9061            entries,
 9062        );
 9063    });
 9064}
 9065
 9066mod property_test {
 9067    use super::*;
 9068    use gpui::proptest::prelude::*;
 9069
 9070    struct UnopenedWorktree {
 9071        path: String,
 9072        main_workspace_path: String,
 9073    }
 9074
 9075    struct TestState {
 9076        fs: Arc<FakeFs>,
 9077        thread_counter: u32,
 9078        workspace_counter: u32,
 9079        worktree_counter: u32,
 9080        saved_thread_ids: Vec<acp::SessionId>,
 9081        unopened_worktrees: Vec<UnopenedWorktree>,
 9082    }
 9083
 9084    impl TestState {
 9085        fn new(fs: Arc<FakeFs>) -> Self {
 9086            Self {
 9087                fs,
 9088                thread_counter: 0,
 9089                workspace_counter: 1,
 9090                worktree_counter: 0,
 9091                saved_thread_ids: Vec::new(),
 9092                unopened_worktrees: Vec::new(),
 9093            }
 9094        }
 9095
 9096        fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
 9097            let id = self.thread_counter;
 9098            self.thread_counter += 1;
 9099            acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
 9100        }
 9101
 9102        fn next_workspace_path(&mut self) -> String {
 9103            let id = self.workspace_counter;
 9104            self.workspace_counter += 1;
 9105            format!("/prop-project-{id}")
 9106        }
 9107
 9108        fn next_worktree_name(&mut self) -> String {
 9109            let id = self.worktree_counter;
 9110            self.worktree_counter += 1;
 9111            format!("wt-{id}")
 9112        }
 9113    }
 9114
 9115    #[derive(Debug)]
 9116    enum Operation {
 9117        SaveThread { project_group_index: usize },
 9118        SaveWorktreeThread { worktree_index: usize },
 9119        ToggleAgentPanel,
 9120        CreateDraftThread,
 9121        AddProject { use_worktree: bool },
 9122        ArchiveThread { index: usize },
 9123        SwitchToThread { index: usize },
 9124        SwitchToProjectGroup { index: usize },
 9125        AddLinkedWorktree { project_group_index: usize },
 9126        AddWorktreeToProject { project_group_index: usize },
 9127        RemoveWorktreeFromProject { project_group_index: usize },
 9128    }
 9129
 9130    // Distribution (out of 24 slots):
 9131    //   SaveThread:                5 slots (~21%)
 9132    //   SaveWorktreeThread:        2 slots (~8%)
 9133    //   ToggleAgentPanel:          1 slot  (~4%)
 9134    //   CreateDraftThread:         1 slot  (~4%)
 9135    //   AddProject:                1 slot  (~4%)
 9136    //   ArchiveThread:             2 slots (~8%)
 9137    //   SwitchToThread:            2 slots (~8%)
 9138    //   SwitchToProjectGroup:      2 slots (~8%)
 9139    //   AddLinkedWorktree:         4 slots (~17%)
 9140    //   AddWorktreeToProject:      2 slots (~8%)
 9141    //   RemoveWorktreeFromProject: 2 slots (~8%)
 9142    const DISTRIBUTION_SLOTS: u32 = 24;
 9143
 9144    impl TestState {
 9145        fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
 9146            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
 9147
 9148            match raw % DISTRIBUTION_SLOTS {
 9149                0..=4 => Operation::SaveThread {
 9150                    project_group_index: extra % project_group_count,
 9151                },
 9152                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
 9153                    worktree_index: extra % self.unopened_worktrees.len(),
 9154                },
 9155                5..=6 => Operation::SaveThread {
 9156                    project_group_index: extra % project_group_count,
 9157                },
 9158                7 => Operation::ToggleAgentPanel,
 9159                8 => Operation::CreateDraftThread,
 9160                9 => Operation::AddProject {
 9161                    use_worktree: !self.unopened_worktrees.is_empty(),
 9162                },
 9163                10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
 9164                    index: extra % self.saved_thread_ids.len(),
 9165                },
 9166                10..=11 => Operation::AddProject {
 9167                    use_worktree: !self.unopened_worktrees.is_empty(),
 9168                },
 9169                12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
 9170                    index: extra % self.saved_thread_ids.len(),
 9171                },
 9172                12..=13 => Operation::SwitchToProjectGroup {
 9173                    index: extra % project_group_count,
 9174                },
 9175                14..=15 => Operation::SwitchToProjectGroup {
 9176                    index: extra % project_group_count,
 9177                },
 9178                16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
 9179                    project_group_index: extra % project_group_count,
 9180                },
 9181                16..=19 => Operation::SaveThread {
 9182                    project_group_index: extra % project_group_count,
 9183                },
 9184                20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
 9185                    project_group_index: extra % project_group_count,
 9186                },
 9187                20..=21 => Operation::SaveThread {
 9188                    project_group_index: extra % project_group_count,
 9189                },
 9190                22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
 9191                    project_group_index: extra % project_group_count,
 9192                },
 9193                22..=23 => Operation::SaveThread {
 9194                    project_group_index: extra % project_group_count,
 9195                },
 9196                _ => unreachable!(),
 9197            }
 9198        }
 9199    }
 9200
 9201    fn save_thread_to_path_with_main(
 9202        state: &mut TestState,
 9203        path_list: PathList,
 9204        main_worktree_paths: PathList,
 9205        cx: &mut gpui::VisualTestContext,
 9206    ) {
 9207        let session_id = state.next_metadata_only_thread_id();
 9208        let title: SharedString = format!("Thread {}", session_id).into();
 9209        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9210            .unwrap()
 9211            + chrono::Duration::seconds(state.thread_counter as i64);
 9212        let metadata = ThreadMetadata {
 9213            thread_id: ThreadId::new(),
 9214            session_id: Some(session_id),
 9215            agent_id: agent::ZED_AGENT_ID.clone(),
 9216            title: Some(title),
 9217            updated_at,
 9218            created_at: None,
 9219            interacted_at: None,
 9220            worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
 9221            archived: false,
 9222            remote_connection: None,
 9223        };
 9224        cx.update(|_, cx| {
 9225            ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
 9226        });
 9227        cx.run_until_parked();
 9228    }
 9229
 9230    async fn perform_operation(
 9231        operation: Operation,
 9232        state: &mut TestState,
 9233        multi_workspace: &Entity<MultiWorkspace>,
 9234        sidebar: &Entity<Sidebar>,
 9235        cx: &mut gpui::VisualTestContext,
 9236    ) {
 9237        match operation {
 9238            Operation::SaveThread {
 9239                project_group_index,
 9240            } => {
 9241                // Find a workspace for this project group and create a real
 9242                // thread via its agent panel.
 9243                let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
 9244                    let keys = mw.project_group_keys();
 9245                    let key = &keys[project_group_index];
 9246                    let ws = mw
 9247                        .workspaces_for_project_group(key, cx)
 9248                        .and_then(|ws| ws.first().cloned())
 9249                        .unwrap_or_else(|| mw.workspace().clone());
 9250                    let project = ws.read(cx).project().clone();
 9251                    (ws, project)
 9252                });
 9253
 9254                let panel =
 9255                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9256                if let Some(panel) = panel {
 9257                    let connection = StubAgentConnection::new();
 9258                    connection.set_next_prompt_updates(vec![
 9259                        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
 9260                            "Done".into(),
 9261                        )),
 9262                    ]);
 9263                    open_thread_with_connection(&panel, connection, cx);
 9264                    send_message(&panel, cx);
 9265                    let session_id = active_session_id(&panel, cx);
 9266                    state.saved_thread_ids.push(session_id.clone());
 9267
 9268                    let title: SharedString = format!("Thread {}", state.thread_counter).into();
 9269                    state.thread_counter += 1;
 9270                    let updated_at =
 9271                        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9272                            .unwrap()
 9273                            + chrono::Duration::seconds(state.thread_counter as i64);
 9274                    save_thread_metadata(
 9275                        session_id,
 9276                        Some(title),
 9277                        updated_at,
 9278                        None,
 9279                        None,
 9280                        &project,
 9281                        cx,
 9282                    );
 9283                }
 9284            }
 9285            Operation::SaveWorktreeThread { worktree_index } => {
 9286                let worktree = &state.unopened_worktrees[worktree_index];
 9287                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
 9288                let main_worktree_paths =
 9289                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
 9290                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
 9291            }
 9292
 9293            Operation::ToggleAgentPanel => {
 9294                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9295                let panel_open =
 9296                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
 9297                workspace.update_in(cx, |workspace, window, cx| {
 9298                    if panel_open {
 9299                        workspace.close_panel::<AgentPanel>(window, cx);
 9300                    } else {
 9301                        workspace.open_panel::<AgentPanel>(window, cx);
 9302                    }
 9303                });
 9304            }
 9305            Operation::CreateDraftThread => {
 9306                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9307                let panel =
 9308                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9309                if let Some(panel) = panel {
 9310                    panel.update_in(cx, |panel, window, cx| {
 9311                        panel.new_thread(&NewThread, window, cx);
 9312                    });
 9313                    cx.run_until_parked();
 9314                }
 9315                workspace.update_in(cx, |workspace, window, cx| {
 9316                    workspace.focus_panel::<AgentPanel>(window, cx);
 9317                });
 9318            }
 9319            Operation::AddProject { use_worktree } => {
 9320                let path = if use_worktree {
 9321                    // Open an existing linked worktree as a project (simulates Cmd+O
 9322                    // on a worktree directory).
 9323                    state.unopened_worktrees.remove(0).path
 9324                } else {
 9325                    // Create a brand new project.
 9326                    let path = state.next_workspace_path();
 9327                    state
 9328                        .fs
 9329                        .insert_tree(
 9330                            &path,
 9331                            serde_json::json!({
 9332                                ".git": {},
 9333                                "src": {},
 9334                            }),
 9335                        )
 9336                        .await;
 9337                    path
 9338                };
 9339                let project = project::Project::test(
 9340                    state.fs.clone() as Arc<dyn fs::Fs>,
 9341                    [path.as_ref()],
 9342                    cx,
 9343                )
 9344                .await;
 9345                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9346                multi_workspace.update_in(cx, |mw, window, cx| {
 9347                    mw.test_add_workspace(project.clone(), window, cx)
 9348                });
 9349            }
 9350
 9351            Operation::ArchiveThread { index } => {
 9352                let session_id = state.saved_thread_ids[index].clone();
 9353                sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 9354                    sidebar.archive_thread(&session_id, window, cx);
 9355                });
 9356                cx.run_until_parked();
 9357                state.saved_thread_ids.remove(index);
 9358            }
 9359            Operation::SwitchToThread { index } => {
 9360                let session_id = state.saved_thread_ids[index].clone();
 9361                // Find the thread's position in the sidebar entries and select it.
 9362                let thread_index = sidebar.read_with(cx, |sidebar, _| {
 9363                    sidebar.contents.entries.iter().position(|entry| {
 9364                        matches!(
 9365                            entry,
 9366                            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
 9367                        )
 9368                    })
 9369                });
 9370                if let Some(ix) = thread_index {
 9371                    sidebar.update_in(cx, |sidebar, window, cx| {
 9372                        sidebar.selection = Some(ix);
 9373                        sidebar.confirm(&Confirm, window, cx);
 9374                    });
 9375                    cx.run_until_parked();
 9376                }
 9377            }
 9378            Operation::SwitchToProjectGroup { index } => {
 9379                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9380                    let keys = mw.project_group_keys();
 9381                    let key = &keys[index];
 9382                    mw.workspaces_for_project_group(key, cx)
 9383                        .and_then(|ws| ws.first().cloned())
 9384                        .unwrap_or_else(|| mw.workspace().clone())
 9385                });
 9386                multi_workspace.update_in(cx, |mw, window, cx| {
 9387                    mw.activate(workspace, None, window, cx);
 9388                });
 9389            }
 9390            Operation::AddLinkedWorktree {
 9391                project_group_index,
 9392            } => {
 9393                // Get the main worktree path from the project group key.
 9394                let main_path = multi_workspace.read_with(cx, |mw, _| {
 9395                    let keys = mw.project_group_keys();
 9396                    let key = &keys[project_group_index];
 9397                    key.path_list()
 9398                        .paths()
 9399                        .first()
 9400                        .unwrap()
 9401                        .to_string_lossy()
 9402                        .to_string()
 9403                });
 9404                let dot_git = format!("{}/.git", main_path);
 9405                let worktree_name = state.next_worktree_name();
 9406                let worktree_path = format!("/worktrees/{}", worktree_name);
 9407
 9408                state.fs
 9409                    .insert_tree(
 9410                        &worktree_path,
 9411                        serde_json::json!({
 9412                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
 9413                            "src": {},
 9414                        }),
 9415                    )
 9416                    .await;
 9417
 9418                // Also create the worktree metadata dir inside the main repo's .git
 9419                state
 9420                    .fs
 9421                    .insert_tree(
 9422                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
 9423                        serde_json::json!({
 9424                            "commondir": "../../",
 9425                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
 9426                        }),
 9427                    )
 9428                    .await;
 9429
 9430                let dot_git_path = std::path::Path::new(&dot_git);
 9431                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
 9432                state
 9433                    .fs
 9434                    .add_linked_worktree_for_repo(
 9435                        dot_git_path,
 9436                        false,
 9437                        git::repository::Worktree {
 9438                            path: worktree_pathbuf,
 9439                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 9440                            sha: "aaa".into(),
 9441                            is_main: false,
 9442                            is_bare: false,
 9443                        },
 9444                    )
 9445                    .await;
 9446
 9447                // Re-scan the main workspace's project so it discovers the new worktree.
 9448                let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
 9449                    let keys = mw.project_group_keys();
 9450                    let key = &keys[project_group_index];
 9451                    mw.workspaces_for_project_group(key, cx)
 9452                        .and_then(|ws| ws.first().cloned())
 9453                        .unwrap()
 9454                });
 9455                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 9456                main_project
 9457                    .update(cx, |p, cx| p.git_scans_complete(cx))
 9458                    .await;
 9459
 9460                state.unopened_worktrees.push(UnopenedWorktree {
 9461                    path: worktree_path,
 9462                    main_workspace_path: main_path.clone(),
 9463                });
 9464            }
 9465            Operation::AddWorktreeToProject {
 9466                project_group_index,
 9467            } => {
 9468                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9469                    let keys = mw.project_group_keys();
 9470                    let key = &keys[project_group_index];
 9471                    mw.workspaces_for_project_group(key, cx)
 9472                        .and_then(|ws| ws.first().cloned())
 9473                });
 9474                let Some(workspace) = workspace else { return };
 9475                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9476
 9477                let new_path = state.next_workspace_path();
 9478                state
 9479                    .fs
 9480                    .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
 9481                    .await;
 9482
 9483                let result = project
 9484                    .update(cx, |project, cx| {
 9485                        project.find_or_create_worktree(&new_path, true, cx)
 9486                    })
 9487                    .await;
 9488                if result.is_err() {
 9489                    return;
 9490                }
 9491                cx.run_until_parked();
 9492            }
 9493            Operation::RemoveWorktreeFromProject {
 9494                project_group_index,
 9495            } => {
 9496                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9497                    let keys = mw.project_group_keys();
 9498                    let key = &keys[project_group_index];
 9499                    mw.workspaces_for_project_group(key, cx)
 9500                        .and_then(|ws| ws.first().cloned())
 9501                });
 9502                let Some(workspace) = workspace else { return };
 9503                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9504
 9505                let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
 9506                if worktree_count <= 1 {
 9507                    return;
 9508                }
 9509
 9510                let worktree_id = project.read_with(cx, |p, cx| {
 9511                    p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
 9512                });
 9513                if let Some(worktree_id) = worktree_id {
 9514                    project.update(cx, |project, cx| {
 9515                        project.remove_worktree(worktree_id, cx);
 9516                    });
 9517                    cx.run_until_parked();
 9518                }
 9519            }
 9520        }
 9521    }
 9522
 9523    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
 9524        sidebar.update_in(cx, |sidebar, _window, cx| {
 9525            if let Some(mw) = sidebar.multi_workspace.upgrade() {
 9526                mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 9527            }
 9528            sidebar.update_entries(cx);
 9529        });
 9530    }
 9531
 9532    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9533        verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
 9534        verify_no_duplicate_threads(sidebar)?;
 9535        verify_all_threads_are_shown(sidebar, cx)?;
 9536        verify_active_state_matches_current_workspace(sidebar, cx)?;
 9537        verify_all_workspaces_are_reachable(sidebar, cx)?;
 9538        verify_workspace_group_key_integrity(sidebar, cx)?;
 9539        Ok(())
 9540    }
 9541
 9542    fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
 9543        let mut seen: HashSet<acp::SessionId> = HashSet::default();
 9544        let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
 9545
 9546        for entry in &sidebar.contents.entries {
 9547            if let Some(session_id) = entry.session_id() {
 9548                if !seen.insert(session_id.clone()) {
 9549                    let title = match entry {
 9550                        ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
 9551                        _ => "<unknown>".to_string(),
 9552                    };
 9553                    duplicates.push((session_id.clone(), title));
 9554                }
 9555            }
 9556        }
 9557
 9558        anyhow::ensure!(
 9559            duplicates.is_empty(),
 9560            "threads appear more than once in sidebar: {:?}",
 9561            duplicates,
 9562        );
 9563        Ok(())
 9564    }
 9565
 9566    fn verify_every_group_in_multiworkspace_is_shown(
 9567        sidebar: &Sidebar,
 9568        cx: &App,
 9569    ) -> anyhow::Result<()> {
 9570        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9571            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9572        };
 9573
 9574        let mw = multi_workspace.read(cx);
 9575
 9576        // Every project group key in the multi-workspace that has a
 9577        // non-empty path list should appear as a ProjectHeader in the
 9578        // sidebar.
 9579        let all_keys = mw.project_group_keys();
 9580        let expected_keys: HashSet<&ProjectGroupKey> = all_keys
 9581            .iter()
 9582            .filter(|k| !k.path_list().paths().is_empty())
 9583            .collect();
 9584
 9585        let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
 9586            .contents
 9587            .entries
 9588            .iter()
 9589            .filter_map(|entry| match entry {
 9590                ListEntry::ProjectHeader { key, .. } => Some(key),
 9591                _ => None,
 9592            })
 9593            .collect();
 9594
 9595        let missing = &expected_keys - &sidebar_keys;
 9596        let stray = &sidebar_keys - &expected_keys;
 9597
 9598        anyhow::ensure!(
 9599            missing.is_empty() && stray.is_empty(),
 9600            "sidebar project groups don't match multi-workspace.\n\
 9601             Only in multi-workspace (missing): {:?}\n\
 9602             Only in sidebar (stray): {:?}",
 9603            missing,
 9604            stray,
 9605        );
 9606
 9607        Ok(())
 9608    }
 9609
 9610    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9611        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9612            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9613        };
 9614        let workspaces = multi_workspace
 9615            .read(cx)
 9616            .workspaces()
 9617            .cloned()
 9618            .collect::<Vec<_>>();
 9619        let thread_store = ThreadMetadataStore::global(cx);
 9620
 9621        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
 9622            .contents
 9623            .entries
 9624            .iter()
 9625            .filter_map(|entry| entry.session_id().cloned())
 9626            .collect();
 9627
 9628        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
 9629
 9630        // Query using the same approach as the sidebar: iterate project
 9631        // group keys, then do main + legacy queries per group.
 9632        let mw = multi_workspace.read(cx);
 9633        let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
 9634            HashMap::default();
 9635        for workspace in &workspaces {
 9636            let key = workspace.read(cx).project_group_key(cx);
 9637            workspaces_by_group
 9638                .entry(key)
 9639                .or_default()
 9640                .push(workspace.clone());
 9641        }
 9642
 9643        for group_key in mw.project_group_keys() {
 9644            let path_list = group_key.path_list().clone();
 9645            if path_list.paths().is_empty() {
 9646                continue;
 9647            }
 9648
 9649            let group_workspaces = workspaces_by_group
 9650                .get(&group_key)
 9651                .map(|ws| ws.as_slice())
 9652                .unwrap_or_default();
 9653
 9654            // Main code path queries (run for all groups, even without workspaces).
 9655            // Skip drafts (session_id: None) — they are not shown in the
 9656            // sidebar entries.
 9657            for metadata in thread_store
 9658                .read(cx)
 9659                .entries_for_main_worktree_path(&path_list, None)
 9660            {
 9661                if let Some(sid) = metadata.session_id.clone() {
 9662                    metadata_thread_ids.insert(sid);
 9663                }
 9664            }
 9665            for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
 9666                if let Some(sid) = metadata.session_id.clone() {
 9667                    metadata_thread_ids.insert(sid);
 9668                }
 9669            }
 9670
 9671            // Legacy: per-workspace queries for different root paths.
 9672            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
 9673                .iter()
 9674                .flat_map(|ws| {
 9675                    ws.read(cx)
 9676                        .root_paths(cx)
 9677                        .into_iter()
 9678                        .map(|p| p.to_path_buf())
 9679                })
 9680                .collect();
 9681
 9682            for workspace in group_workspaces {
 9683                let ws_path_list = workspace_path_list(workspace, cx);
 9684                if ws_path_list != path_list {
 9685                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
 9686                        if let Some(sid) = metadata.session_id.clone() {
 9687                            metadata_thread_ids.insert(sid);
 9688                        }
 9689                    }
 9690                }
 9691            }
 9692
 9693            for workspace in group_workspaces {
 9694                for snapshot in root_repository_snapshots(workspace, cx) {
 9695                    let Some(main_worktree_abs_path) = snapshot.main_worktree_abs_path() else {
 9696                        continue;
 9697                    };
 9698                    let repo_path_list = PathList::new(&[main_worktree_abs_path.to_path_buf()]);
 9699                    if repo_path_list != path_list {
 9700                        continue;
 9701                    }
 9702                    for linked_worktree in snapshot.linked_worktrees() {
 9703                        if covered_paths.contains(&*linked_worktree.path) {
 9704                            continue;
 9705                        }
 9706                        let worktree_path_list =
 9707                            PathList::new(std::slice::from_ref(&linked_worktree.path));
 9708                        for metadata in thread_store
 9709                            .read(cx)
 9710                            .entries_for_path(&worktree_path_list, None)
 9711                        {
 9712                            if let Some(sid) = metadata.session_id.clone() {
 9713                                metadata_thread_ids.insert(sid);
 9714                            }
 9715                        }
 9716                    }
 9717                }
 9718            }
 9719        }
 9720
 9721        anyhow::ensure!(
 9722            sidebar_thread_ids == metadata_thread_ids,
 9723            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
 9724            sidebar_thread_ids,
 9725            metadata_thread_ids,
 9726        );
 9727        Ok(())
 9728    }
 9729
 9730    fn verify_active_state_matches_current_workspace(
 9731        sidebar: &Sidebar,
 9732        cx: &App,
 9733    ) -> anyhow::Result<()> {
 9734        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9735            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9736        };
 9737
 9738        let active_workspace = multi_workspace.read(cx).workspace();
 9739
 9740        // 1. active_entry should be Some when the panel has content.
 9741        //    It may be None when the panel is uninitialized (no drafts,
 9742        //    no threads), which is fine.
 9743        //    It may also temporarily point at a different workspace
 9744        //    when the workspace just changed and the new panel has no
 9745        //    content yet.
 9746        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
 9747        let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
 9748            || panel.read(cx).active_conversation_view().is_some();
 9749
 9750        let Some(entry) = sidebar.active_entry.as_ref() else {
 9751            if panel_has_content {
 9752                anyhow::bail!("active_entry is None but panel has content (draft or thread)");
 9753            }
 9754            return Ok(());
 9755        };
 9756
 9757        // If the entry workspace doesn't match the active workspace
 9758        // and the panel has no content, this is a transient state that
 9759        // will resolve when the panel gets content.
 9760        if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
 9761            return Ok(());
 9762        }
 9763
 9764        // 2. The entry's workspace must agree with the multi-workspace's
 9765        //    active workspace.
 9766        anyhow::ensure!(
 9767            entry.workspace().entity_id() == active_workspace.entity_id(),
 9768            "active_entry workspace ({:?}) != active workspace ({:?})",
 9769            entry.workspace().entity_id(),
 9770            active_workspace.entity_id(),
 9771        );
 9772
 9773        // 3. The entry must match the agent panel's current state.
 9774        if panel.read(cx).active_thread_id(cx).is_some() {
 9775            anyhow::ensure!(
 9776                matches!(entry, ActiveEntry { .. }),
 9777                "panel shows a tracked draft but active_entry is {:?}",
 9778                entry,
 9779            );
 9780        } else if let Some(thread_id) = panel
 9781            .read(cx)
 9782            .active_conversation_view()
 9783            .map(|cv| cv.read(cx).parent_id())
 9784        {
 9785            anyhow::ensure!(
 9786                matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
 9787                "panel has thread {:?} but active_entry is {:?}",
 9788                thread_id,
 9789                entry,
 9790            );
 9791        }
 9792
 9793        // 4. Exactly one entry in sidebar contents must be uniquely
 9794        //    identified by the active_entry — unless the panel is showing
 9795        //    a draft, which is represented by the + button's active state
 9796        //    rather than a sidebar row.
 9797        // TODO: Make this check more complete
 9798        let is_draft = panel.read(cx).active_thread_is_draft(cx)
 9799            || panel.read(cx).active_conversation_view().is_none();
 9800        if is_draft {
 9801            return Ok(());
 9802        }
 9803        let matching_count = sidebar
 9804            .contents
 9805            .entries
 9806            .iter()
 9807            .filter(|e| entry.matches_entry(e))
 9808            .count();
 9809        if matching_count != 1 {
 9810            let thread_entries: Vec<_> = sidebar
 9811                .contents
 9812                .entries
 9813                .iter()
 9814                .filter_map(|e| match e {
 9815                    ListEntry::Thread(t) => Some(format!(
 9816                        "tid={:?} sid={:?}",
 9817                        t.metadata.thread_id, t.metadata.session_id
 9818                    )),
 9819                    _ => None,
 9820                })
 9821                .collect();
 9822            let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
 9823            let store_entries: Vec<_> = store
 9824                .entries()
 9825                .map(|m| {
 9826                    format!(
 9827                        "tid={:?} sid={:?} archived={} paths={:?}",
 9828                        m.thread_id,
 9829                        m.session_id,
 9830                        m.archived,
 9831                        m.folder_paths()
 9832                    )
 9833                })
 9834                .collect();
 9835            anyhow::bail!(
 9836                "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
 9837                entry,
 9838                matching_count,
 9839                thread_entries,
 9840                store_entries,
 9841            );
 9842        }
 9843
 9844        Ok(())
 9845    }
 9846
 9847    /// Every workspace in the multi-workspace should be "reachable" from
 9848    /// the sidebar — meaning there is at least one entry (thread, draft,
 9849    /// new-thread, or project header) that, when clicked, would activate
 9850    /// that workspace.
 9851    fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9852        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9853            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9854        };
 9855
 9856        let multi_workspace = multi_workspace.read(cx);
 9857
 9858        let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
 9859            .contents
 9860            .entries
 9861            .iter()
 9862            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 9863            .map(|ws| ws.entity_id())
 9864            .collect();
 9865
 9866        let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
 9867            .workspaces()
 9868            .map(|ws| ws.entity_id())
 9869            .collect();
 9870
 9871        let unreachable = &all_workspace_ids - &reachable_workspaces;
 9872
 9873        anyhow::ensure!(
 9874            unreachable.is_empty(),
 9875            "The following workspaces are not reachable from any sidebar entry: {:?}",
 9876            unreachable,
 9877        );
 9878
 9879        Ok(())
 9880    }
 9881
 9882    fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9883        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9884            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9885        };
 9886        multi_workspace
 9887            .read(cx)
 9888            .assert_project_group_key_integrity(cx)
 9889    }
 9890
 9891    #[gpui::property_test(config = ProptestConfig {
 9892        cases: 20,
 9893        ..Default::default()
 9894    })]
 9895    async fn test_sidebar_invariants(
 9896        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
 9897        raw_operations: Vec<u32>,
 9898        cx: &mut TestAppContext,
 9899    ) {
 9900        use std::sync::atomic::{AtomicUsize, Ordering};
 9901        static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
 9902
 9903        agent_ui::test_support::init_test(cx);
 9904        cx.update(|cx| {
 9905            cx.set_global(db::AppDatabase::test_new());
 9906            cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 9907            cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
 9908                format!(
 9909                    "PROPTEST_THREAD_METADATA_{}",
 9910                    NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
 9911                ),
 9912            ));
 9913
 9914            ThreadStore::init_global(cx);
 9915            ThreadMetadataStore::init_global(cx);
 9916            language_model::LanguageModelRegistry::test(cx);
 9917            prompt_store::init(cx);
 9918
 9919            // Auto-add an AgentPanel to every workspace so that implicitly
 9920            // created workspaces (e.g. from thread activation) also have one.
 9921            cx.observe_new(
 9922                |workspace: &mut Workspace,
 9923                 window: Option<&mut Window>,
 9924                 cx: &mut gpui::Context<Workspace>| {
 9925                    if let Some(window) = window {
 9926                        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 9927                        workspace.add_panel(panel, window, cx);
 9928                    }
 9929                },
 9930            )
 9931            .detach();
 9932        });
 9933
 9934        let fs = FakeFs::new(cx.executor());
 9935        fs.insert_tree(
 9936            "/my-project",
 9937            serde_json::json!({
 9938                ".git": {},
 9939                "src": {},
 9940            }),
 9941        )
 9942        .await;
 9943        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 9944        let project =
 9945            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
 9946                .await;
 9947        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9948
 9949        let (multi_workspace, cx) =
 9950            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9951        let sidebar = setup_sidebar(&multi_workspace, cx);
 9952
 9953        let mut state = TestState::new(fs);
 9954        let mut executed: Vec<String> = Vec::new();
 9955
 9956        for &raw_op in &raw_operations {
 9957            let project_group_count =
 9958                multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
 9959            let operation = state.generate_operation(raw_op, project_group_count);
 9960            executed.push(format!("{:?}", operation));
 9961            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
 9962            cx.run_until_parked();
 9963
 9964            update_sidebar(&sidebar, cx);
 9965            cx.run_until_parked();
 9966
 9967            let result =
 9968                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
 9969            if let Err(err) = result {
 9970                let log = executed.join("\n  ");
 9971                panic!(
 9972                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
 9973                    executed.len(),
 9974                );
 9975            }
 9976        }
 9977    }
 9978}
 9979
 9980#[gpui::test]
 9981async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
 9982    cx: &mut TestAppContext,
 9983    server_cx: &mut TestAppContext,
 9984) {
 9985    init_test(cx);
 9986
 9987    cx.update(|cx| {
 9988        release_channel::init(semver::Version::new(0, 0, 0), cx);
 9989    });
 9990
 9991    let app_state = cx.update(|cx| {
 9992        let app_state = workspace::AppState::test(cx);
 9993        workspace::init(app_state.clone(), cx);
 9994        app_state
 9995    });
 9996
 9997    // Set up the remote server side.
 9998    let server_fs = FakeFs::new(server_cx.executor());
 9999    server_fs
10000        .insert_tree(
10001            "/project",
10002            serde_json::json!({
10003                ".git": {},
10004                "src": { "main.rs": "fn main() {}" }
10005            }),
10006        )
10007        .await;
10008    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10009
10010    // Create the linked worktree checkout path on the remote server,
10011    // but do not yet register it as a git-linked worktree. The real
10012    // regrouping update in this test should happen only after the
10013    // sidebar opens the closed remote thread.
10014    server_fs
10015        .insert_tree(
10016            "/project-wt-1",
10017            serde_json::json!({
10018                "src": { "main.rs": "fn main() {}" }
10019            }),
10020        )
10021        .await;
10022
10023    server_cx.update(|cx| {
10024        release_channel::init(semver::Version::new(0, 0, 0), cx);
10025    });
10026
10027    let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
10028
10029    server_cx.update(remote_server::HeadlessProject::init);
10030    let server_executor = server_cx.executor();
10031    let _headless = server_cx.new(|cx| {
10032        remote_server::HeadlessProject::new(
10033            remote_server::HeadlessAppState {
10034                session: server_session,
10035                fs: server_fs.clone(),
10036                http_client: Arc::new(http_client::BlockedHttpClient),
10037                node_runtime: node_runtime::NodeRuntime::unavailable(),
10038                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10039                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10040                startup_time: std::time::Instant::now(),
10041            },
10042            false,
10043            cx,
10044        )
10045    });
10046
10047    // Connect the client side and build a remote project.
10048    let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
10049    let project = cx.update(|cx| {
10050        let project_client = client::Client::new(
10051            Arc::new(clock::FakeSystemClock::new()),
10052            http_client::FakeHttpClient::with_404_response(),
10053            cx,
10054        );
10055        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
10056        project::Project::remote(
10057            remote_client,
10058            project_client,
10059            node_runtime::NodeRuntime::unavailable(),
10060            user_store,
10061            app_state.languages.clone(),
10062            app_state.fs.clone(),
10063            false,
10064            cx,
10065        )
10066    });
10067
10068    // Open the remote worktree.
10069    project
10070        .update(cx, |project, cx| {
10071            project.find_or_create_worktree(Path::new("/project"), true, cx)
10072        })
10073        .await
10074        .expect("should open remote worktree");
10075    cx.run_until_parked();
10076
10077    // Verify the project is remote.
10078    project.read_with(cx, |project, cx| {
10079        assert!(!project.is_local(), "project should be remote");
10080        assert!(
10081            project.remote_connection_options(cx).is_some(),
10082            "project should have remote connection options"
10083        );
10084    });
10085
10086    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10087
10088    // Create MultiWorkspace with the remote project.
10089    let (multi_workspace, cx) =
10090        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10091    let sidebar = setup_sidebar(&multi_workspace, cx);
10092
10093    cx.run_until_parked();
10094
10095    // Save a thread for the main remote workspace (folder_paths match
10096    // the open workspace, so it will be classified as Open).
10097    let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
10098    save_thread_metadata(
10099        main_thread_id.clone(),
10100        Some("Main Thread".into()),
10101        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10102        None,
10103        None,
10104        &project,
10105        cx,
10106    );
10107    cx.run_until_parked();
10108
10109    // Save a thread whose folder_paths point to a linked worktree path
10110    // that doesn't have an open workspace ("/project-wt-1"), but whose
10111    // main_worktree_paths match the project group key so it appears
10112    // in the sidebar under the same remote group. This simulates a
10113    // linked worktree workspace that was closed.
10114    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10115    let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
10116        (
10117            p.project_group_key(cx).path_list().clone(),
10118            p.remote_connection_options(cx),
10119        )
10120    });
10121    cx.update(|_window, cx| {
10122        let metadata = ThreadMetadata {
10123            thread_id: ThreadId::new(),
10124            session_id: Some(remote_thread_id.clone()),
10125            agent_id: agent::ZED_AGENT_ID.clone(),
10126            title: Some("Worktree Thread".into()),
10127            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10128            created_at: None,
10129            interacted_at: None,
10130            worktree_paths: WorktreePaths::from_path_lists(
10131                main_worktree_paths,
10132                PathList::new(&[PathBuf::from("/project-wt-1")]),
10133            )
10134            .unwrap(),
10135            archived: false,
10136            remote_connection,
10137        };
10138        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10139    });
10140    cx.run_until_parked();
10141
10142    focus_sidebar(&sidebar, cx);
10143    sidebar.update_in(cx, |sidebar, _window, _cx| {
10144        sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10145            matches!(
10146                entry,
10147                ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10148            )
10149        });
10150    });
10151
10152    let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10153    let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10154
10155    sidebar
10156        .update(cx, |_, cx| {
10157            cx.observe_self(move |sidebar, _cx| {
10158                let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10159                    if let ListEntry::ProjectHeader { label, .. } = entry {
10160                        Some(label.as_ref())
10161                    } else {
10162                        None
10163                    }
10164                });
10165
10166                let Some(project_header) = project_headers.next() else {
10167                    saw_separate_project_header_for_observer
10168                        .store(true, std::sync::atomic::Ordering::SeqCst);
10169                    return;
10170                };
10171
10172                if project_header != "project" || project_headers.next().is_some() {
10173                    saw_separate_project_header_for_observer
10174                        .store(true, std::sync::atomic::Ordering::SeqCst);
10175                }
10176            })
10177        })
10178        .detach();
10179
10180    multi_workspace.update(cx, |multi_workspace, cx| {
10181        let workspace = multi_workspace.workspace().clone();
10182        workspace.update(cx, |workspace: &mut Workspace, cx| {
10183            let remote_client = workspace
10184                .project()
10185                .read(cx)
10186                .remote_client()
10187                .expect("main remote project should have a remote client");
10188            remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10189                remote_client.force_server_not_running(cx);
10190            });
10191        });
10192    });
10193    cx.run_until_parked();
10194
10195    let (server_session_2, connect_guard_2) =
10196        remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10197    let _headless_2 = server_cx.new(|cx| {
10198        remote_server::HeadlessProject::new(
10199            remote_server::HeadlessAppState {
10200                session: server_session_2,
10201                fs: server_fs.clone(),
10202                http_client: Arc::new(http_client::BlockedHttpClient),
10203                node_runtime: node_runtime::NodeRuntime::unavailable(),
10204                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10205                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10206                startup_time: std::time::Instant::now(),
10207            },
10208            false,
10209            cx,
10210        )
10211    });
10212    drop(connect_guard_2);
10213
10214    let window = cx.windows()[0];
10215    cx.update_window(window, |_, window, cx| {
10216        window.dispatch_action(Confirm.boxed_clone(), cx);
10217    })
10218    .unwrap();
10219
10220    cx.run_until_parked();
10221
10222    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10223        assert_eq!(
10224            mw.workspaces().count(),
10225            2,
10226            "confirming a closed remote thread should open a second workspace"
10227        );
10228        mw.workspaces()
10229            .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10230            .unwrap()
10231            .clone()
10232    });
10233
10234    server_fs
10235        .add_linked_worktree_for_repo(
10236            Path::new("/project/.git"),
10237            true,
10238            git::repository::Worktree {
10239                path: PathBuf::from("/project-wt-1"),
10240                ref_name: Some("refs/heads/feature-wt".into()),
10241                sha: "abc123".into(),
10242                is_main: false,
10243                is_bare: false,
10244            },
10245        )
10246        .await;
10247
10248    server_cx.run_until_parked();
10249    cx.run_until_parked();
10250    server_cx.run_until_parked();
10251    cx.run_until_parked();
10252
10253    let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10254    let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10255        workspace.project().read(cx).project_group_key(cx)
10256    });
10257
10258    assert_eq!(
10259        group_after_update,
10260        project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10261        "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10262         final sidebar entries: {:?}",
10263        entries_after_update,
10264    );
10265
10266    sidebar.update(cx, |sidebar, _cx| {
10267        assert_remote_project_integration_sidebar_state(
10268            sidebar,
10269            &main_thread_id,
10270            &remote_thread_id,
10271        );
10272    });
10273
10274    assert!(
10275        !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10276        "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10277         final group: {:?}; final sidebar entries: {:?}",
10278        group_after_update,
10279        entries_after_update,
10280    );
10281}
10282
10283#[gpui::test]
10284async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10285    // When the thread's folder_paths don't exactly match any workspace's
10286    // root paths (e.g. because a folder was added to the workspace after
10287    // the thread was created), workspace_to_remove is None. But the linked
10288    // worktree workspace still needs to be removed so that its worktree
10289    // entities are released, allowing git worktree removal to proceed.
10290    //
10291    // With the fix, archive_thread scans roots_to_archive for any linked
10292    // worktree workspaces and includes them in the removal set, even when
10293    // the thread's folder_paths don't match the workspace's root paths.
10294    init_test(cx);
10295    let fs = FakeFs::new(cx.executor());
10296
10297    fs.insert_tree(
10298        "/project",
10299        serde_json::json!({
10300            ".git": {
10301                "worktrees": {
10302                    "feature-a": {
10303                        "commondir": "../../",
10304                        "HEAD": "ref: refs/heads/feature-a",
10305                    },
10306                },
10307            },
10308            "src": {},
10309        }),
10310    )
10311    .await;
10312
10313    fs.insert_tree(
10314        "/worktrees/project/feature-a/project",
10315        serde_json::json!({
10316            ".git": "gitdir: /project/.git/worktrees/feature-a",
10317            "src": {
10318                "main.rs": "fn main() {}",
10319            },
10320        }),
10321    )
10322    .await;
10323
10324    fs.add_linked_worktree_for_repo(
10325        Path::new("/project/.git"),
10326        false,
10327        git::repository::Worktree {
10328            path: PathBuf::from("/worktrees/project/feature-a/project"),
10329            ref_name: Some("refs/heads/feature-a".into()),
10330            sha: "abc".into(),
10331            is_main: false,
10332            is_bare: false,
10333        },
10334    )
10335    .await;
10336
10337    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10338
10339    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10340    let worktree_project = project::Project::test(
10341        fs.clone(),
10342        ["/worktrees/project/feature-a/project".as_ref()],
10343        cx,
10344    )
10345    .await;
10346
10347    main_project
10348        .update(cx, |p, cx| p.git_scans_complete(cx))
10349        .await;
10350    worktree_project
10351        .update(cx, |p, cx| p.git_scans_complete(cx))
10352        .await;
10353
10354    let (multi_workspace, cx) =
10355        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10356    let sidebar = setup_sidebar(&multi_workspace, cx);
10357
10358    multi_workspace.update_in(cx, |mw, window, cx| {
10359        mw.test_add_workspace(worktree_project.clone(), window, cx)
10360    });
10361
10362    // Save thread metadata using folder_paths that DON'T match the
10363    // workspace's root paths. This simulates the case where the workspace's
10364    // paths diverged (e.g. a folder was added after thread creation).
10365    // This causes workspace_to_remove to be None because
10366    // workspace_for_paths can't find a workspace with these exact paths.
10367    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10368    save_thread_metadata_with_main_paths(
10369        "worktree-thread",
10370        "Worktree Thread",
10371        PathList::new(&[
10372            PathBuf::from("/worktrees/project/feature-a/project"),
10373            PathBuf::from("/nonexistent"),
10374        ]),
10375        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10376        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10377        cx,
10378    );
10379
10380    // Also save a main thread so the sidebar has something to show.
10381    save_thread_metadata(
10382        acp::SessionId::new(Arc::from("main-thread")),
10383        Some("Main Thread".into()),
10384        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10385        None,
10386        None,
10387        &main_project,
10388        cx,
10389    );
10390    cx.run_until_parked();
10391
10392    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10393    cx.run_until_parked();
10394
10395    assert_eq!(
10396        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10397        2,
10398        "should start with 2 workspaces (main + linked worktree)"
10399    );
10400
10401    // Archive the worktree thread.
10402    sidebar.update_in(cx, |sidebar, window, cx| {
10403        sidebar.archive_thread(&wt_thread_id, window, cx);
10404    });
10405
10406    cx.run_until_parked();
10407
10408    // The linked worktree workspace should have been removed, even though
10409    // workspace_to_remove was None (paths didn't match).
10410    assert_eq!(
10411        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10412        1,
10413        "linked worktree workspace should be removed after archiving, \
10414         even when folder_paths don't match workspace root paths"
10415    );
10416
10417    // The thread should still be archived (not unarchived due to an error).
10418    let still_archived = cx.update(|_, cx| {
10419        ThreadMetadataStore::global(cx)
10420            .read(cx)
10421            .entry_by_session(&wt_thread_id)
10422            .map(|t| t.archived)
10423    });
10424    assert_eq!(
10425        still_archived,
10426        Some(true),
10427        "thread should still be archived (not rolled back due to error)"
10428    );
10429
10430    // The linked worktree directory should be removed from disk.
10431    assert!(
10432        !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
10433            .await,
10434        "linked worktree directory should be removed from disk"
10435    );
10436}
10437
10438#[gpui::test]
10439async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10440    // When a workspace contains both a worktree being archived and other
10441    // worktrees that should remain, only the editor items referencing the
10442    // archived worktree should be closed — the workspace itself must be
10443    // preserved.
10444    init_test(cx);
10445    let fs = FakeFs::new(cx.executor());
10446
10447    fs.insert_tree(
10448        "/main-repo",
10449        serde_json::json!({
10450            ".git": {
10451                "worktrees": {
10452                    "feature-b": {
10453                        "commondir": "../../",
10454                        "HEAD": "ref: refs/heads/feature-b",
10455                    },
10456                },
10457            },
10458            "src": {
10459                "lib.rs": "pub fn hello() {}",
10460            },
10461        }),
10462    )
10463    .await;
10464
10465    fs.insert_tree(
10466        "/worktrees/main-repo/feature-b/main-repo",
10467        serde_json::json!({
10468            ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10469            "src": {
10470                "main.rs": "fn main() { hello(); }",
10471            },
10472        }),
10473    )
10474    .await;
10475
10476    fs.add_linked_worktree_for_repo(
10477        Path::new("/main-repo/.git"),
10478        false,
10479        git::repository::Worktree {
10480            path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10481            ref_name: Some("refs/heads/feature-b".into()),
10482            sha: "def".into(),
10483            is_main: false,
10484            is_bare: false,
10485        },
10486    )
10487    .await;
10488
10489    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10490
10491    // Create a single project that contains BOTH the main repo and the
10492    // linked worktree — this makes it a "mixed" workspace.
10493    let mixed_project = project::Project::test(
10494        fs.clone(),
10495        [
10496            "/main-repo".as_ref(),
10497            "/worktrees/main-repo/feature-b/main-repo".as_ref(),
10498        ],
10499        cx,
10500    )
10501    .await;
10502
10503    mixed_project
10504        .update(cx, |p, cx| p.git_scans_complete(cx))
10505        .await;
10506
10507    let (multi_workspace, cx) = cx
10508        .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10509    let sidebar = setup_sidebar(&multi_workspace, cx);
10510
10511    // Open editor items in both worktrees so we can verify which ones
10512    // get closed.
10513    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10514
10515    let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10516        ws.project()
10517            .read(cx)
10518            .visible_worktrees(cx)
10519            .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10520            .collect()
10521    });
10522
10523    let main_repo_wt_id = worktree_ids
10524        .iter()
10525        .find(|(_, path)| path.as_ref() == Path::new("/main-repo"))
10526        .map(|(id, _)| *id)
10527        .expect("should find main-repo worktree");
10528
10529    let feature_b_wt_id = worktree_ids
10530        .iter()
10531        .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo"))
10532        .map(|(id, _)| *id)
10533        .expect("should find feature-b worktree");
10534
10535    // Open files from both worktrees.
10536    let main_repo_path = project::ProjectPath {
10537        worktree_id: main_repo_wt_id,
10538        path: Arc::from(rel_path("src/lib.rs")),
10539    };
10540    let feature_b_path = project::ProjectPath {
10541        worktree_id: feature_b_wt_id,
10542        path: Arc::from(rel_path("src/main.rs")),
10543    };
10544
10545    workspace
10546        .update_in(cx, |ws, window, cx| {
10547            ws.open_path(main_repo_path.clone(), None, true, window, cx)
10548        })
10549        .await
10550        .expect("should open main-repo file");
10551    workspace
10552        .update_in(cx, |ws, window, cx| {
10553            ws.open_path(feature_b_path.clone(), None, true, window, cx)
10554        })
10555        .await
10556        .expect("should open feature-b file");
10557
10558    cx.run_until_parked();
10559
10560    // Verify both items are open.
10561    let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10562        ws.panes()
10563            .iter()
10564            .flat_map(|pane| {
10565                pane.read(cx)
10566                    .items()
10567                    .filter_map(|item| item.project_path(cx))
10568            })
10569            .collect()
10570    });
10571    assert!(
10572        open_paths_before
10573            .iter()
10574            .any(|pp| pp.worktree_id == main_repo_wt_id),
10575        "main-repo file should be open"
10576    );
10577    assert!(
10578        open_paths_before
10579            .iter()
10580            .any(|pp| pp.worktree_id == feature_b_wt_id),
10581        "feature-b file should be open"
10582    );
10583
10584    // Save thread metadata for the linked worktree with deliberately
10585    // mismatched folder_paths to trigger the scan-based detection.
10586    save_thread_metadata_with_main_paths(
10587        "feature-b-thread",
10588        "Feature B Thread",
10589        PathList::new(&[
10590            PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10591            PathBuf::from("/nonexistent"),
10592        ]),
10593        PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10594        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10595        cx,
10596    );
10597
10598    // Save another thread that references only the main repo (not the
10599    // linked worktree) so archiving the feature-b thread's worktree isn't
10600    // blocked by another unarchived thread referencing the same path.
10601    save_thread_metadata_with_main_paths(
10602        "other-thread",
10603        "Other Thread",
10604        PathList::new(&[PathBuf::from("/main-repo")]),
10605        PathList::new(&[PathBuf::from("/main-repo")]),
10606        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10607        cx,
10608    );
10609    cx.run_until_parked();
10610
10611    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10612    cx.run_until_parked();
10613
10614    // There should still be exactly 1 workspace.
10615    assert_eq!(
10616        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10617        1,
10618        "should have 1 workspace (the mixed workspace)"
10619    );
10620
10621    // Archive the feature-b thread.
10622    let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10623    sidebar.update_in(cx, |sidebar, window, cx| {
10624        sidebar.archive_thread(&fb_session_id, window, cx);
10625    });
10626
10627    cx.run_until_parked();
10628
10629    // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10630    assert_eq!(
10631        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10632        1,
10633        "mixed workspace should be preserved"
10634    );
10635
10636    // Only the feature-b editor item should have been closed.
10637    let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10638        ws.panes()
10639            .iter()
10640            .flat_map(|pane| {
10641                pane.read(cx)
10642                    .items()
10643                    .filter_map(|item| item.project_path(cx))
10644            })
10645            .collect()
10646    });
10647    assert!(
10648        open_paths_after
10649            .iter()
10650            .any(|pp| pp.worktree_id == main_repo_wt_id),
10651        "main-repo file should still be open"
10652    );
10653    assert!(
10654        !open_paths_after
10655            .iter()
10656            .any(|pp| pp.worktree_id == feature_b_wt_id),
10657        "feature-b file should have been closed"
10658    );
10659}
10660
10661#[test]
10662fn test_worktree_info_branch_names_for_main_worktrees() {
10663    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10664    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10665
10666    let branch_by_path: HashMap<PathBuf, SharedString> =
10667        [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10668            .into_iter()
10669            .collect();
10670
10671    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10672    assert_eq!(infos.len(), 1);
10673    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10674    assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10675    assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10676}
10677
10678#[test]
10679fn test_worktree_info_branch_names_for_linked_worktrees() {
10680    let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10681    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10682    let worktree_paths =
10683        WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10684
10685    let branch_by_path: HashMap<PathBuf, SharedString> = [(
10686        PathBuf::from("/projects/myapp-feature"),
10687        "feature-branch".into(),
10688    )]
10689    .into_iter()
10690    .collect();
10691
10692    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10693    assert_eq!(infos.len(), 1);
10694    assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10695    assert_eq!(
10696        infos[0].branch_name,
10697        Some(SharedString::from("feature-branch"))
10698    );
10699}
10700
10701#[test]
10702fn test_worktree_info_missing_branch_returns_none() {
10703    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10704    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10705
10706    let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10707
10708    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10709    assert_eq!(infos.len(), 1);
10710    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10711    assert_eq!(infos[0].branch_name, None);
10712    assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10713}
10714
10715#[gpui::test]
10716async fn test_remote_archive_thread_with_active_connection(
10717    cx: &mut TestAppContext,
10718    server_cx: &mut TestAppContext,
10719) {
10720    // End-to-end test of archiving a remote thread tied to a linked git
10721    // worktree. Archival should:
10722    //  1. Persist the worktree's git state via the remote repository RPCs
10723    //     (head_sha / create_archive_checkpoint / update_ref).
10724    //  2. Remove the linked worktree directory from the *remote* filesystem
10725    //     via the GitRemoveWorktree RPC.
10726    //  3. Mark the thread metadata archived and hide it from the sidebar.
10727    //
10728    // The mock remote transport only supports one live `RemoteClient` per
10729    // connection at a time (each client's `start_proxy` replaces the
10730    // previous server channel), so we can't split the main repo and the
10731    // linked worktree across two remote projects the way Zed does in
10732    // production. Opening both as visible worktrees of a single remote
10733    // project still exercises every interesting path of the archive flow
10734    // while staying within the mock's multiplexing limits.
10735    init_test(cx);
10736
10737    cx.update(|cx| {
10738        release_channel::init(semver::Version::new(0, 0, 0), cx);
10739    });
10740
10741    let app_state = cx.update(|cx| {
10742        let app_state = workspace::AppState::test(cx);
10743        workspace::init(app_state.clone(), cx);
10744        app_state
10745    });
10746
10747    server_cx.update(|cx| {
10748        release_channel::init(semver::Version::new(0, 0, 0), cx);
10749    });
10750
10751    // Set up the remote filesystem with a main repo and one linked worktree.
10752    let server_fs = FakeFs::new(server_cx.executor());
10753    server_fs
10754        .insert_tree(
10755            "/project",
10756            serde_json::json!({
10757                ".git": {
10758                    "worktrees": {
10759                        "feature-a": {
10760                            "commondir": "../../",
10761                            "HEAD": "ref: refs/heads/feature-a",
10762                        },
10763                    },
10764                },
10765                "src": { "main.rs": "fn main() {}" },
10766            }),
10767        )
10768        .await;
10769    server_fs
10770        .insert_tree(
10771            "/worktrees/project/feature-a/project",
10772            serde_json::json!({
10773                ".git": "gitdir: /project/.git/worktrees/feature-a",
10774                "src": { "lib.rs": "// feature" },
10775            }),
10776        )
10777        .await;
10778    server_fs
10779        .add_linked_worktree_for_repo(
10780            Path::new("/project/.git"),
10781            false,
10782            git::repository::Worktree {
10783                path: PathBuf::from("/worktrees/project/feature-a/project"),
10784                ref_name: Some("refs/heads/feature-a".into()),
10785                sha: "abc".into(),
10786                is_main: false,
10787                is_bare: false,
10788            },
10789        )
10790        .await;
10791    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10792    server_fs.set_head_for_repo(
10793        Path::new("/project/.git"),
10794        &[("src/main.rs", "fn main() {}".into())],
10795        "head-sha",
10796    );
10797
10798    // Open a single remote project with both the main repo and the linked
10799    // worktree as visible worktrees. The mock transport doesn't multiplex
10800    // multiple `RemoteClient`s over one pooled connection cleanly (each
10801    // client's `start_proxy` clobbers the previous one's server channel),
10802    // so we can't build two separate `Project::remote` instances in this
10803    // test. Folding both worktrees into one project still exercises the
10804    // archive flow's interesting paths: `build_root_plan` classifies the
10805    // linked worktree correctly, and `find_or_create_repository` finds
10806    // the main repo live on that same project — avoiding the temp-project
10807    // fallback that would also run into the multiplexing limitation.
10808    let (project, _headless, _opts) = start_remote_project(
10809        &server_fs,
10810        Path::new("/project"),
10811        &app_state,
10812        None,
10813        cx,
10814        server_cx,
10815    )
10816    .await;
10817    project
10818        .update(cx, |project, cx| {
10819            project.find_or_create_worktree(
10820                Path::new("/worktrees/project/feature-a/project"),
10821                true,
10822                cx,
10823            )
10824        })
10825        .await
10826        .expect("should open linked worktree on remote");
10827    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10828    cx.run_until_parked();
10829
10830    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10831
10832    let (multi_workspace, cx) =
10833        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10834    let sidebar = setup_sidebar(&multi_workspace, cx);
10835
10836    // The worktree thread's (main_worktree_path, folder_path) pair points
10837    // the folder at the linked worktree checkout and the main at the
10838    // parent repo, so `build_root_plan` targets the linked worktree
10839    // specifically and knows which main repo owns it.
10840    let remote_connection = project.read_with(cx, |p, cx| p.remote_connection_options(cx));
10841    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10842    cx.update(|_window, cx| {
10843        let metadata = ThreadMetadata {
10844            thread_id: ThreadId::new(),
10845            session_id: Some(wt_thread_id.clone()),
10846            agent_id: agent::ZED_AGENT_ID.clone(),
10847            title: Some("Worktree Thread".into()),
10848            updated_at: chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
10849                .unwrap(),
10850            created_at: None,
10851            interacted_at: None,
10852            worktree_paths: WorktreePaths::from_path_lists(
10853                PathList::new(&[PathBuf::from("/project")]),
10854                PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]),
10855            )
10856            .unwrap(),
10857            archived: false,
10858            remote_connection,
10859        };
10860        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10861    });
10862    cx.run_until_parked();
10863
10864    assert!(
10865        server_fs
10866            .is_dir(Path::new("/worktrees/project/feature-a/project"))
10867            .await,
10868        "linked worktree directory should exist on remote before archiving"
10869    );
10870
10871    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
10872        sidebar.archive_thread(&wt_thread_id, window, cx);
10873    });
10874    cx.run_until_parked();
10875    server_cx.run_until_parked();
10876
10877    let is_archived = cx.update(|_window, cx| {
10878        ThreadMetadataStore::global(cx)
10879            .read(cx)
10880            .entry_by_session(&wt_thread_id)
10881            .map(|t| t.archived)
10882            .unwrap_or(false)
10883    });
10884    assert!(is_archived, "worktree thread should be archived");
10885
10886    assert!(
10887        !server_fs
10888            .is_dir(Path::new("/worktrees/project/feature-a/project"))
10889            .await,
10890        "linked worktree directory should be removed from remote fs \
10891         (the GitRemoveWorktree RPC runs `Repository::remove_worktree` \
10892         on the headless server, which deletes the directory via `Fs::remove_dir` \
10893         before running `git worktree remove --force`)"
10894    );
10895
10896    let entries = visible_entries_as_strings(&sidebar, cx);
10897    assert!(
10898        !entries.iter().any(|e| e.contains("Worktree Thread")),
10899        "archived worktree thread should be hidden from sidebar: {entries:?}"
10900    );
10901}
10902
10903#[gpui::test]
10904async fn test_remote_archive_thread_with_disconnected_remote(
10905    cx: &mut TestAppContext,
10906    server_cx: &mut TestAppContext,
10907) {
10908    // When a remote thread has no linked-worktree state to archive (only
10909    // a main worktree), archival is a pure metadata operation: no RPCs
10910    // are issued against the remote server. This must succeed even when
10911    // the connection has dropped out, because losing connectivity should
10912    // not block users from cleaning up their thread list.
10913    //
10914    // Threads that *do* have linked-worktree state require a live
10915    // connection to run the git worktree removal on the server; that
10916    // path is covered by `test_remote_archive_thread_with_active_connection`.
10917    init_test(cx);
10918
10919    cx.update(|cx| {
10920        release_channel::init(semver::Version::new(0, 0, 0), cx);
10921    });
10922
10923    let app_state = cx.update(|cx| {
10924        let app_state = workspace::AppState::test(cx);
10925        workspace::init(app_state.clone(), cx);
10926        app_state
10927    });
10928
10929    server_cx.update(|cx| {
10930        release_channel::init(semver::Version::new(0, 0, 0), cx);
10931    });
10932
10933    let server_fs = FakeFs::new(server_cx.executor());
10934    server_fs
10935        .insert_tree(
10936            "/project",
10937            serde_json::json!({
10938                ".git": {},
10939                "src": { "main.rs": "fn main() {}" },
10940            }),
10941        )
10942        .await;
10943    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10944
10945    let (project, _headless, _opts) = start_remote_project(
10946        &server_fs,
10947        Path::new("/project"),
10948        &app_state,
10949        None,
10950        cx,
10951        server_cx,
10952    )
10953    .await;
10954    let remote_client = project
10955        .read_with(cx, |project, _cx| project.remote_client())
10956        .expect("remote project should expose its client");
10957
10958    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10959
10960    let (multi_workspace, cx) =
10961        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10962    let sidebar = setup_sidebar(&multi_workspace, cx);
10963
10964    let thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10965    save_thread_metadata(
10966        thread_id.clone(),
10967        Some("Remote Thread".into()),
10968        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10969        None,
10970        None,
10971        &project,
10972        cx,
10973    );
10974    cx.run_until_parked();
10975
10976    // Sanity-check: there is nothing on the remote fs outside the main
10977    // repo, so archival should not need to touch the server.
10978    assert!(
10979        !server_fs.is_dir(Path::new("/worktrees")).await,
10980        "no linked worktrees on the server before archiving"
10981    );
10982
10983    // Disconnect the remote connection before archiving. We don't
10984    // `run_until_parked` here because the disconnect itself triggers
10985    // reconnection work that can't complete in the test environment.
10986    remote_client.update(cx, |client, cx| {
10987        client.simulate_disconnect(cx).detach();
10988    });
10989
10990    sidebar.update_in(cx, |sidebar, window, cx| {
10991        sidebar.archive_thread(&thread_id, window, cx);
10992    });
10993    cx.run_until_parked();
10994
10995    let is_archived = cx.update(|_window, cx| {
10996        ThreadMetadataStore::global(cx)
10997            .read(cx)
10998            .entry_by_session(&thread_id)
10999            .map(|t| t.archived)
11000            .unwrap_or(false)
11001    });
11002    assert!(
11003        is_archived,
11004        "thread should be archived even when remote is disconnected"
11005    );
11006
11007    let entries = visible_entries_as_strings(&sidebar, cx);
11008    assert!(
11009        !entries.iter().any(|e| e.contains("Remote Thread")),
11010        "archived thread should be hidden from sidebar: {entries:?}"
11011    );
11012}
11013
11014#[gpui::test]
11015async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) {
11016    init_test(cx);
11017    let fs = FakeFs::new(cx.executor());
11018    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
11019        .await;
11020    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11021        .await;
11022    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11023    let project = project::Project::test(fs, ["/project-a".as_ref()], cx).await;
11024
11025    let (multi_workspace, cx) =
11026        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11027
11028    // Set up the sidebar while the project is local. This registers the
11029    // WorktreePathsChanged subscription for the project.
11030    let _sidebar = setup_sidebar(&multi_workspace, cx);
11031
11032    let session_id = acp::SessionId::new(Arc::from("test-thread"));
11033    save_named_thread_metadata("test-thread", "My Thread", &project, cx).await;
11034
11035    let thread_id = cx.update(|_window, cx| {
11036        ThreadMetadataStore::global(cx)
11037            .read(cx)
11038            .entry_by_session(&session_id)
11039            .map(|e| e.thread_id)
11040            .expect("thread must be in the store")
11041    });
11042
11043    cx.update(|_window, cx| {
11044        let store = ThreadMetadataStore::global(cx);
11045        let entry = store.read(cx).entry(thread_id).unwrap();
11046        assert_eq!(
11047            entry.folder_paths().paths(),
11048            &[PathBuf::from("/project-a")],
11049            "thread must be saved with /project-a before collab"
11050        );
11051    });
11052
11053    // Transition the project into collab mode. The sidebar's subscription is
11054    // still active from when the project was local.
11055    project.update(cx, |project, _cx| {
11056        project.mark_as_collab_for_testing();
11057    });
11058
11059    // Adding a worktree fires WorktreePathsChanged with old_paths = {/project-a}.
11060    // The sidebar's subscription is still active, so move_thread_paths is called.
11061    // Without the is_via_collab() guard inside move_thread_paths, this would
11062    // update the stored thread paths from {/project-a} to {/project-a, /project-b}.
11063    project
11064        .update(cx, |project, cx| {
11065            project.find_or_create_worktree("/project-b", true, cx)
11066        })
11067        .await
11068        .expect("should add worktree");
11069    cx.run_until_parked();
11070
11071    cx.update(|_window, cx| {
11072        let store = ThreadMetadataStore::global(cx);
11073        let entry = store
11074            .read(cx)
11075            .entry(thread_id)
11076            .expect("thread must still exist");
11077        assert_eq!(
11078            entry.folder_paths().paths(),
11079            &[PathBuf::from("/project-a")],
11080            "thread path must not change when project is via collab"
11081        );
11082    });
11083}
11084
11085#[gpui::test]
11086async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace(
11087    cx: &mut TestAppContext,
11088) {
11089    // Regression test for: cmd-clicking a project group header should return
11090    // the user to the workspace they most recently had active in that group,
11091    // including workspaces rooted at a linked worktree.
11092    init_test(cx);
11093    let fs = FakeFs::new(cx.executor());
11094
11095    fs.insert_tree(
11096        "/project-a",
11097        serde_json::json!({
11098            ".git": {},
11099            "src": {},
11100        }),
11101    )
11102    .await;
11103    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11104        .await;
11105
11106    fs.add_linked_worktree_for_repo(
11107        Path::new("/project-a/.git"),
11108        false,
11109        git::repository::Worktree {
11110            path: std::path::PathBuf::from("/wt-feature-a"),
11111            ref_name: Some("refs/heads/feature-a".into()),
11112            sha: "aaa".into(),
11113            is_main: false,
11114            is_bare: false,
11115        },
11116    )
11117    .await;
11118
11119    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11120
11121    let main_project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
11122    let worktree_project_a =
11123        project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
11124    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
11125
11126    main_project_a
11127        .update(cx, |p, cx| p.git_scans_complete(cx))
11128        .await;
11129    worktree_project_a
11130        .update(cx, |p, cx| p.git_scans_complete(cx))
11131        .await;
11132
11133    // The multi-workspace starts with the main-paths workspace of group A
11134    // as the initially active workspace.
11135    let (multi_workspace, cx) = cx
11136        .add_window_view(|window, cx| MultiWorkspace::test_new(main_project_a.clone(), window, cx));
11137
11138    let sidebar = setup_sidebar(&multi_workspace, cx);
11139
11140    // Capture the initially active workspace (group A's main-paths workspace)
11141    // *before* registering additional workspaces, since `workspaces()` returns
11142    // retained workspaces in registration order — not activation order — and
11143    // the multi-workspace's starting workspace may not be retained yet.
11144    let main_workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11145
11146    // Register the linked-worktree workspace (group A) and the group-B
11147    // workspace. Both get retained by the multi-workspace.
11148    let worktree_workspace_a = multi_workspace.update_in(cx, |mw, window, cx| {
11149        mw.test_add_workspace(worktree_project_a.clone(), window, cx)
11150    });
11151    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
11152        mw.test_add_workspace(project_b.clone(), window, cx)
11153    });
11154
11155    cx.run_until_parked();
11156
11157    // Step 1: activate the linked-worktree workspace. The MultiWorkspace
11158    // records this as the last-active workspace for group A on its
11159    // ProjectGroupState. (We don't assert on the initial active workspace
11160    // because `test_add_workspace` may auto-activate newly registered
11161    // workspaces — what matters for this test is the explicit sequence of
11162    // activations below.)
11163    multi_workspace.update_in(cx, |mw, window, cx| {
11164        mw.activate(worktree_workspace_a.clone(), None, window, cx);
11165    });
11166    cx.run_until_parked();
11167    assert_eq!(
11168        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11169        worktree_workspace_a,
11170        "linked-worktree workspace should be active after step 1"
11171    );
11172
11173    // Step 2: switch to the workspace for group B. Group A's last-active
11174    // workspace remains the linked-worktree one (group B getting activated
11175    // records *its own* last-active workspace, not group A's).
11176    multi_workspace.update_in(cx, |mw, window, cx| {
11177        mw.activate(workspace_b.clone(), None, window, cx);
11178    });
11179    cx.run_until_parked();
11180    assert_eq!(
11181        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11182        workspace_b,
11183        "group B's workspace should be active after step 2"
11184    );
11185
11186    // Step 3: simulate cmd-click on group A's header. The project group key
11187    // for group A is derived from the *main-paths* workspace (linked-worktree
11188    // workspaces share the same key because it normalizes to main-worktree
11189    // paths).
11190    let group_a_key = main_workspace_a.read_with(cx, |ws, cx| ws.project_group_key(cx));
11191    sidebar.update_in(cx, |sidebar, window, cx| {
11192        sidebar.activate_or_open_workspace_for_group(&group_a_key, window, cx);
11193    });
11194    cx.run_until_parked();
11195
11196    // Expected: we're back in the linked-worktree workspace, not the
11197    // main-paths one.
11198    let active_after_cmd_click = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11199    assert_eq!(
11200        active_after_cmd_click, worktree_workspace_a,
11201        "cmd-click on group A's header should return to the last-active \
11202         linked-worktree workspace, not the main-paths workspace"
11203    );
11204    assert_ne!(
11205        active_after_cmd_click, main_workspace_a,
11206        "cmd-click must not fall back to the main-paths workspace when a \
11207         linked-worktree workspace was the last-active one for the group"
11208    );
11209}