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