sidebar_tests.rs

    1use super::*;
    2use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
    3use agent::ThreadStore;
    4use agent_ui::{
    5    ThreadId,
    6    test_support::{
    7        active_session_id, active_thread_id, open_thread_with_connection, send_message,
    8    },
    9    thread_metadata_store::{ThreadMetadata, WorktreePaths},
   10};
   11use chrono::DateTime;
   12use fs::{FakeFs, Fs};
   13use gpui::TestAppContext;
   14use pretty_assertions::assert_eq;
   15use project::AgentId;
   16use settings::SettingsStore;
   17use std::{
   18    path::{Path, PathBuf},
   19    sync::Arc,
   20};
   21use util::{path_list::PathList, rel_path::rel_path};
   22
   23fn init_test(cx: &mut TestAppContext) {
   24    cx.update(|cx| {
   25        let settings_store = SettingsStore::test(cx);
   26        cx.set_global(settings_store);
   27        theme_settings::init(theme::LoadThemes::JustBase, cx);
   28        editor::init(cx);
   29        ThreadStore::init_global(cx);
   30        ThreadMetadataStore::init_global(cx);
   31        language_model::LanguageModelRegistry::test(cx);
   32        prompt_store::init(cx);
   33    });
   34}
   35
   36#[track_caller]
   37fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
   38    let active = sidebar.active_entry.as_ref();
   39    let matches = active.is_some_and(|entry| {
   40        // Match by session_id directly on active_entry.
   41        entry.session_id.as_ref() == Some(session_id)
   42            // Or match by finding the thread in sidebar entries.
   43            || sidebar.contents.entries.iter().any(|list_entry| {
   44                matches!(list_entry, ListEntry::Thread(t)
   45                    if t.metadata.session_id.as_ref() == Some(session_id)
   46                        && entry.matches_entry(list_entry))
   47            })
   48    });
   49    assert!(
   50        matches,
   51        "{msg}: expected active_entry for session {session_id:?}, got {:?}",
   52        active,
   53    );
   54}
   55
   56#[track_caller]
   57fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   58    let thread_id = sidebar
   59        .contents
   60        .entries
   61        .iter()
   62        .find_map(|entry| match entry {
   63            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
   64                Some(t.metadata.thread_id)
   65            }
   66            _ => None,
   67        });
   68    match thread_id {
   69        Some(tid) => {
   70            matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid)
   71        }
   72        // Thread not in sidebar entries — can't confirm it's active.
   73        None => false,
   74    }
   75}
   76
   77#[track_caller]
   78fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
   79    assert!(
   80        matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace),
   81        "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
   82        workspace.entity_id(),
   83        sidebar.active_entry,
   84    );
   85}
   86
   87fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   88    sidebar
   89        .contents
   90        .entries
   91        .iter()
   92        .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
   93}
   94
   95#[track_caller]
   96fn assert_remote_project_integration_sidebar_state(
   97    sidebar: &mut Sidebar,
   98    main_thread_id: &acp::SessionId,
   99    remote_thread_id: &acp::SessionId,
  100) {
  101    let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
  102        if let ListEntry::ProjectHeader { label, .. } = entry {
  103            Some(label.as_ref())
  104        } else {
  105            None
  106        }
  107    });
  108
  109    let Some(project_header) = project_headers.next() else {
  110        panic!("expected exactly one sidebar project header named `project`, found none");
  111    };
  112    assert_eq!(
  113        project_header, "project",
  114        "expected the only sidebar project header to be `project`"
  115    );
  116    if let Some(unexpected_header) = project_headers.next() {
  117        panic!(
  118            "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
  119        );
  120    }
  121
  122    let mut saw_main_thread = false;
  123    let mut saw_remote_thread = false;
  124    for entry in &sidebar.contents.entries {
  125        match entry {
  126            ListEntry::ProjectHeader { label, .. } => {
  127                assert_eq!(
  128                    label.as_ref(),
  129                    "project",
  130                    "expected the only sidebar project header to be `project`"
  131                );
  132            }
  133            ListEntry::Thread(thread)
  134                if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
  135            {
  136                saw_main_thread = true;
  137            }
  138            ListEntry::Thread(thread)
  139                if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
  140            {
  141                saw_remote_thread = true;
  142            }
  143            ListEntry::Thread(thread) => {
  144                let title = thread.metadata.display_title();
  145                panic!(
  146                    "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
  147                    title
  148                );
  149            }
  150        }
  151    }
  152
  153    assert!(
  154        saw_main_thread,
  155        "expected the sidebar to keep showing `Main Thread` under `project`"
  156    );
  157    assert!(
  158        saw_remote_thread,
  159        "expected the sidebar to keep showing `Worktree Thread` under `project`"
  160    );
  161}
  162
  163async fn init_test_project(
  164    worktree_path: &str,
  165    cx: &mut TestAppContext,
  166) -> Entity<project::Project> {
  167    init_test(cx);
  168    let fs = FakeFs::new(cx.executor());
  169    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
  170        .await;
  171    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
  172    project::Project::test(fs, [worktree_path.as_ref()], cx).await
  173}
  174
  175fn setup_sidebar(
  176    multi_workspace: &Entity<MultiWorkspace>,
  177    cx: &mut gpui::VisualTestContext,
  178) -> Entity<Sidebar> {
  179    let sidebar = setup_sidebar_closed(multi_workspace, cx);
  180    multi_workspace.update_in(cx, |mw, window, cx| {
  181        mw.toggle_sidebar(window, cx);
  182    });
  183    cx.run_until_parked();
  184    sidebar
  185}
  186
  187fn setup_sidebar_closed(
  188    multi_workspace: &Entity<MultiWorkspace>,
  189    cx: &mut gpui::VisualTestContext,
  190) -> Entity<Sidebar> {
  191    let multi_workspace = multi_workspace.clone();
  192    let sidebar =
  193        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  194    multi_workspace.update(cx, |mw, cx| {
  195        mw.register_sidebar(sidebar.clone(), cx);
  196    });
  197    cx.run_until_parked();
  198    sidebar
  199}
  200
  201async fn save_n_test_threads(
  202    count: u32,
  203    project: &Entity<project::Project>,
  204    cx: &mut gpui::VisualTestContext,
  205) {
  206    for i in 0..count {
  207        save_thread_metadata(
  208            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
  209            Some(format!("Thread {}", i + 1).into()),
  210            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
  211            None,
  212            None,
  213            project,
  214            cx,
  215        )
  216    }
  217    cx.run_until_parked();
  218}
  219
  220async fn save_test_thread_metadata(
  221    session_id: &acp::SessionId,
  222    project: &Entity<project::Project>,
  223    cx: &mut TestAppContext,
  224) {
  225    save_thread_metadata(
  226        session_id.clone(),
  227        Some("Test".into()),
  228        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  229        None,
  230        None,
  231        project,
  232        cx,
  233    )
  234}
  235
  236async fn save_named_thread_metadata(
  237    session_id: &str,
  238    title: &str,
  239    project: &Entity<project::Project>,
  240    cx: &mut gpui::VisualTestContext,
  241) {
  242    save_thread_metadata(
  243        acp::SessionId::new(Arc::from(session_id)),
  244        Some(SharedString::from(title.to_string())),
  245        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  246        None,
  247        None,
  248        project,
  249        cx,
  250    );
  251    cx.run_until_parked();
  252}
  253
  254/// Spins up a fresh remote project backed by a headless server sharing
  255/// `server_fs`, opens the given worktree path on it, and returns the
  256/// project together with the headless entity (which the caller must keep
  257/// alive for the duration of the test) and the `RemoteConnectionOptions`
  258/// used for the fake server. Passing those options back into
  259/// `reuse_opts` on a subsequent call makes the new project share the
  260/// same `RemoteConnectionIdentity`, matching how Zed treats multiple
  261/// projects on the same SSH host.
  262async fn start_remote_project(
  263    server_fs: &Arc<FakeFs>,
  264    worktree_path: &Path,
  265    app_state: &Arc<workspace::AppState>,
  266    reuse_opts: Option<&remote::RemoteConnectionOptions>,
  267    cx: &mut TestAppContext,
  268    server_cx: &mut TestAppContext,
  269) -> (
  270    Entity<project::Project>,
  271    Entity<remote_server::HeadlessProject>,
  272    remote::RemoteConnectionOptions,
  273) {
  274    // Bare `_` on the guard so it's dropped immediately; holding onto it
  275    // would deadlock `connect_mock` below since the client waits on the
  276    // guard before completing the mock handshake.
  277    let (opts, server_session) = match reuse_opts {
  278        Some(existing) => {
  279            let (session, _) = remote::RemoteClient::fake_server_with_opts(existing, cx, server_cx);
  280            (existing.clone(), session)
  281        }
  282        None => {
  283            let (opts, session, _) = remote::RemoteClient::fake_server(cx, server_cx);
  284            (opts, session)
  285        }
  286    };
  287
  288    server_cx.update(remote_server::HeadlessProject::init);
  289    let server_executor = server_cx.executor();
  290    let fs = server_fs.clone();
  291    let headless = server_cx.new(|cx| {
  292        remote_server::HeadlessProject::new(
  293            remote_server::HeadlessAppState {
  294                session: server_session,
  295                fs,
  296                http_client: Arc::new(http_client::BlockedHttpClient),
  297                node_runtime: node_runtime::NodeRuntime::unavailable(),
  298                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
  299                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
  300                startup_time: std::time::Instant::now(),
  301            },
  302            false,
  303            cx,
  304        )
  305    });
  306
  307    let remote_client = remote::RemoteClient::connect_mock(opts.clone(), cx).await;
  308    let project = cx.update(|cx| {
  309        let project_client = client::Client::new(
  310            Arc::new(clock::FakeSystemClock::new()),
  311            http_client::FakeHttpClient::with_404_response(),
  312            cx,
  313        );
  314        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
  315        project::Project::remote(
  316            remote_client,
  317            project_client,
  318            node_runtime::NodeRuntime::unavailable(),
  319            user_store,
  320            app_state.languages.clone(),
  321            app_state.fs.clone(),
  322            false,
  323            cx,
  324        )
  325    });
  326
  327    project
  328        .update(cx, |project, cx| {
  329            project.find_or_create_worktree(worktree_path, true, cx)
  330        })
  331        .await
  332        .expect("should open remote worktree");
  333    cx.run_until_parked();
  334
  335    (project, headless, opts)
  336}
  337
  338fn save_thread_metadata(
  339    session_id: acp::SessionId,
  340    title: Option<SharedString>,
  341    updated_at: DateTime<Utc>,
  342    created_at: Option<DateTime<Utc>>,
  343    interacted_at: Option<DateTime<Utc>>,
  344    project: &Entity<project::Project>,
  345    cx: &mut TestAppContext,
  346) {
  347    cx.update(|cx| {
  348        let worktree_paths = project.read(cx).worktree_paths(cx);
  349        let remote_connection = project.read(cx).remote_connection_options(cx);
  350        let thread_id = ThreadMetadataStore::global(cx)
  351            .read(cx)
  352            .entries()
  353            .find(|e| e.session_id.as_ref() == Some(&session_id))
  354            .map(|e| e.thread_id)
  355            .unwrap_or_else(ThreadId::new);
  356        let metadata = ThreadMetadata {
  357            thread_id,
  358            session_id: Some(session_id),
  359            agent_id: agent::ZED_AGENT_ID.clone(),
  360            title,
  361            updated_at,
  362            created_at,
  363            interacted_at,
  364            worktree_paths,
  365            archived: false,
  366            remote_connection,
  367        };
  368        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  369    });
  370    cx.run_until_parked();
  371}
  372
  373fn save_thread_metadata_with_main_paths(
  374    session_id: &str,
  375    title: &str,
  376    folder_paths: PathList,
  377    main_worktree_paths: PathList,
  378    updated_at: DateTime<Utc>,
  379    cx: &mut TestAppContext,
  380) {
  381    let session_id = acp::SessionId::new(Arc::from(session_id));
  382    let title = SharedString::from(title.to_string());
  383    let thread_id = cx.update(|cx| {
  384        ThreadMetadataStore::global(cx)
  385            .read(cx)
  386            .entries()
  387            .find(|e| e.session_id.as_ref() == Some(&session_id))
  388            .map(|e| e.thread_id)
  389            .unwrap_or_else(ThreadId::new)
  390    });
  391    let metadata = ThreadMetadata {
  392        thread_id,
  393        session_id: Some(session_id),
  394        agent_id: agent::ZED_AGENT_ID.clone(),
  395        title: Some(title),
  396        updated_at,
  397        created_at: None,
  398        interacted_at: None,
  399        worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
  400        archived: false,
  401        remote_connection: None,
  402    };
  403    cx.update(|cx| {
  404        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  405    });
  406    cx.run_until_parked();
  407}
  408
  409fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
  410    sidebar.update_in(cx, |_, window, cx| {
  411        cx.focus_self(window);
  412    });
  413    cx.run_until_parked();
  414}
  415
  416fn request_test_tool_authorization(
  417    thread: &Entity<AcpThread>,
  418    tool_call_id: &str,
  419    option_id: &str,
  420    cx: &mut gpui::VisualTestContext,
  421) {
  422    let tool_call_id = acp::ToolCallId::new(tool_call_id);
  423    let label = format!("Tool {tool_call_id}");
  424    let option_id = acp::PermissionOptionId::new(option_id);
  425    let _authorization_task = cx.update(|_, cx| {
  426        thread.update(cx, |thread, cx| {
  427            thread
  428                .request_tool_call_authorization(
  429                    acp::ToolCall::new(tool_call_id, label)
  430                        .kind(acp::ToolKind::Edit)
  431                        .into(),
  432                    PermissionOptions::Flat(vec![acp::PermissionOption::new(
  433                        option_id,
  434                        "Allow",
  435                        acp::PermissionOptionKind::AllowOnce,
  436                    )]),
  437                    cx,
  438                )
  439                .unwrap()
  440        })
  441    });
  442    cx.run_until_parked();
  443}
  444
  445fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
  446    let mut seen = Vec::new();
  447    let mut chips = Vec::new();
  448    for wt in worktrees {
  449        if wt.kind == ui::WorktreeKind::Main {
  450            continue;
  451        }
  452        let Some(name) = wt.worktree_name.as_ref() else {
  453            continue;
  454        };
  455        if !seen.contains(name) {
  456            seen.push(name.clone());
  457            chips.push(format!("{{{}}}", name));
  458        }
  459    }
  460    if chips.is_empty() {
  461        String::new()
  462    } else {
  463        format!(" {}", chips.join(", "))
  464    }
  465}
  466
  467fn visible_entries_as_strings(
  468    sidebar: &Entity<Sidebar>,
  469    cx: &mut gpui::VisualTestContext,
  470) -> Vec<String> {
  471    sidebar.read_with(cx, |sidebar, cx| {
  472        sidebar
  473            .contents
  474            .entries
  475            .iter()
  476            .enumerate()
  477            .map(|(ix, entry)| {
  478                let selected = if sidebar.selection == Some(ix) {
  479                    "  <== selected"
  480                } else {
  481                    ""
  482                };
  483                match entry {
  484                    ListEntry::ProjectHeader {
  485                        label,
  486                        key,
  487                        highlight_positions: _,
  488                        ..
  489                    } => {
  490                        let icon = if sidebar.is_group_collapsed(key, cx) {
  491                            ">"
  492                        } else {
  493                            "v"
  494                        };
  495                        format!("{} [{}]{}", icon, label, selected)
  496                    }
  497                    ListEntry::Thread(thread) => {
  498                        let title = thread.metadata.display_title();
  499                        let worktree = format_linked_worktree_chips(&thread.worktrees);
  500
  501                        {
  502                            let live = if thread.is_live { " *" } else { "" };
  503                            let status_str = match thread.status {
  504                                AgentThreadStatus::Running => " (running)",
  505                                AgentThreadStatus::Error => " (error)",
  506                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
  507                                _ => "",
  508                            };
  509                            let notified = if sidebar
  510                                .contents
  511                                .is_thread_notified(&thread.metadata.thread_id)
  512                            {
  513                                " (!)"
  514                            } else {
  515                                ""
  516                            };
  517                            format!("  {title}{worktree}{live}{status_str}{notified}{selected}")
  518                        }
  519                    }
  520                }
  521            })
  522            .collect()
  523    })
  524}
  525
  526#[gpui::test]
  527async fn test_serialization_round_trip(cx: &mut TestAppContext) {
  528    let project = init_test_project("/my-project", cx).await;
  529    let (multi_workspace, cx) =
  530        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  531    let sidebar = setup_sidebar(&multi_workspace, cx);
  532
  533    save_n_test_threads(3, &project, cx).await;
  534
  535    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  536
  537    // Set a custom width and collapse the group.
  538    sidebar.update_in(cx, |sidebar, window, cx| {
  539        sidebar.set_width(Some(px(420.0)), cx);
  540        sidebar.toggle_collapse(&project_group_key, window, cx);
  541    });
  542    cx.run_until_parked();
  543
  544    // Capture the serialized state from the first sidebar.
  545    let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
  546    let serialized = serialized.expect("serialized_state should return Some");
  547
  548    // Create a fresh sidebar and restore into it.
  549    let sidebar2 =
  550        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  551    cx.run_until_parked();
  552
  553    sidebar2.update_in(cx, |sidebar, window, cx| {
  554        sidebar.restore_serialized_state(&serialized, window, cx);
  555    });
  556    cx.run_until_parked();
  557
  558    // Assert all serialized fields match.
  559    let width1 = sidebar.read_with(cx, |s, _| s.width);
  560    let width2 = sidebar2.read_with(cx, |s, _| s.width);
  561
  562    assert_eq!(width1, width2);
  563    assert_eq!(width1, px(420.0));
  564}
  565
  566#[gpui::test]
  567async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
  568    // A regression test to ensure that restoring a serialized archive view does not panic.
  569    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  570    let (multi_workspace, cx) =
  571        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  572    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  573    cx.update(|_window, cx| {
  574        AgentRegistryStore::init_test_global(cx, vec![]);
  575    });
  576
  577    let serialized = serde_json::to_string(&SerializedSidebar {
  578        width: Some(400.0),
  579        active_view: SerializedSidebarView::History,
  580    })
  581    .expect("serialization should succeed");
  582
  583    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
  584        if let Some(sidebar) = multi_workspace.sidebar() {
  585            sidebar.restore_serialized_state(&serialized, window, cx);
  586        }
  587    });
  588    cx.run_until_parked();
  589
  590    // After the deferred `show_archive` runs, the view should be Archive.
  591    sidebar.read_with(cx, |sidebar, _cx| {
  592        assert!(
  593            matches!(sidebar.view, SidebarView::Archive(_)),
  594            "expected sidebar view to be Archive after restore, got ThreadList"
  595        );
  596    });
  597}
  598
  599#[gpui::test]
  600async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
  601    let project = init_test_project("/my-project", cx).await;
  602    let (multi_workspace, cx) =
  603        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  604    let sidebar = setup_sidebar(&multi_workspace, cx);
  605
  606    let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
  607    let weak_sidebar = sidebar.downgrade();
  608    let weak_multi_workspace = multi_workspace.downgrade();
  609
  610    drop(sidebar);
  611    drop(multi_workspace);
  612    cx.update(|window, _cx| window.remove_window());
  613    cx.run_until_parked();
  614
  615    weak_multi_workspace.assert_released();
  616    weak_sidebar.assert_released();
  617    weak_workspace.assert_released();
  618}
  619
  620#[gpui::test]
  621async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
  622    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  623    let (multi_workspace, cx) =
  624        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  625    let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  626
  627    assert_eq!(
  628        visible_entries_as_strings(&_sidebar, cx),
  629        vec!["v [my-project]"]
  630    );
  631}
  632
  633#[gpui::test]
  634async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
  635    let project = init_test_project("/my-project", cx).await;
  636    let (multi_workspace, cx) =
  637        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  638    let sidebar = setup_sidebar(&multi_workspace, cx);
  639
  640    save_thread_metadata(
  641        acp::SessionId::new(Arc::from("thread-1")),
  642        Some("Fix crash in project panel".into()),
  643        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
  644        None,
  645        None,
  646        &project,
  647        cx,
  648    );
  649
  650    save_thread_metadata(
  651        acp::SessionId::new(Arc::from("thread-2")),
  652        Some("Add inline diff view".into()),
  653        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
  654        None,
  655        None,
  656        &project,
  657        cx,
  658    );
  659    cx.run_until_parked();
  660
  661    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  662    cx.run_until_parked();
  663
  664    assert_eq!(
  665        visible_entries_as_strings(&sidebar, cx),
  666        vec![
  667            //
  668            "v [my-project]",
  669            "  Fix crash in project panel",
  670            "  Add inline diff view",
  671        ]
  672    );
  673}
  674
  675#[gpui::test]
  676async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
  677    let project = init_test_project("/project-a", cx).await;
  678    let (multi_workspace, cx) =
  679        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  680    let sidebar = setup_sidebar(&multi_workspace, cx);
  681
  682    // Single workspace with a thread
  683    save_thread_metadata(
  684        acp::SessionId::new(Arc::from("thread-a1")),
  685        Some("Thread A1".into()),
  686        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  687        None,
  688        None,
  689        &project,
  690        cx,
  691    );
  692    cx.run_until_parked();
  693
  694    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  695    cx.run_until_parked();
  696
  697    assert_eq!(
  698        visible_entries_as_strings(&sidebar, cx),
  699        vec![
  700            //
  701            "v [project-a]",
  702            "  Thread A1",
  703        ]
  704    );
  705
  706    // Add a second workspace
  707    multi_workspace.update_in(cx, |mw, window, cx| {
  708        mw.create_test_workspace(window, cx).detach();
  709    });
  710    cx.run_until_parked();
  711
  712    assert_eq!(
  713        visible_entries_as_strings(&sidebar, cx),
  714        vec![
  715            //
  716            "v [project-a]",
  717            "  Thread A1",
  718        ]
  719    );
  720}
  721
  722#[gpui::test]
  723async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
  724    let project = init_test_project("/my-project", cx).await;
  725    let (multi_workspace, cx) =
  726        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  727    let sidebar = setup_sidebar(&multi_workspace, cx);
  728
  729    save_n_test_threads(1, &project, cx).await;
  730
  731    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  732
  733    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  734    cx.run_until_parked();
  735
  736    assert_eq!(
  737        visible_entries_as_strings(&sidebar, cx),
  738        vec![
  739            //
  740            "v [my-project]",
  741            "  Thread 1",
  742        ]
  743    );
  744
  745    // Collapse
  746    sidebar.update_in(cx, |s, window, cx| {
  747        s.toggle_collapse(&project_group_key, window, cx);
  748    });
  749    cx.run_until_parked();
  750
  751    assert_eq!(
  752        visible_entries_as_strings(&sidebar, cx),
  753        vec![
  754            //
  755            "> [my-project]",
  756        ]
  757    );
  758
  759    // Expand
  760    sidebar.update_in(cx, |s, window, cx| {
  761        s.toggle_collapse(&project_group_key, window, cx);
  762    });
  763    cx.run_until_parked();
  764
  765    assert_eq!(
  766        visible_entries_as_strings(&sidebar, cx),
  767        vec![
  768            //
  769            "v [my-project]",
  770            "  Thread 1",
  771        ]
  772    );
  773}
  774
  775#[gpui::test]
  776async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
  777    // When a worktree is added to a project, the project group key changes.
  778    // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
  779    // UI state must survive the key change.
  780    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
  781    let (multi_workspace, cx) =
  782        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  783    let sidebar = setup_sidebar(&multi_workspace, cx);
  784
  785    save_n_test_threads(2, &project, cx).await;
  786    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  787    cx.run_until_parked();
  788
  789    assert_eq!(
  790        visible_entries_as_strings(&sidebar, cx),
  791        vec!["v [project-a]", "  Thread 2", "  Thread 1",]
  792    );
  793
  794    // Collapse the group.
  795    let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  796    sidebar.update_in(cx, |sidebar, window, cx| {
  797        sidebar.toggle_collapse(&old_key, window, cx);
  798    });
  799    cx.run_until_parked();
  800
  801    assert_eq!(
  802        visible_entries_as_strings(&sidebar, cx),
  803        vec!["> [project-a]"]
  804    );
  805
  806    // Add a second worktree — the key changes from [/project-a] to
  807    // [/project-a, /project-b].
  808    project
  809        .update(cx, |project, cx| {
  810            project.find_or_create_worktree("/project-b", true, cx)
  811        })
  812        .await
  813        .expect("should add worktree");
  814    cx.run_until_parked();
  815
  816    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  817    cx.run_until_parked();
  818
  819    // The group should still be collapsed under the new key.
  820    assert_eq!(
  821        visible_entries_as_strings(&sidebar, cx),
  822        vec!["> [project-a, project-b]"]
  823    );
  824}
  825
  826#[gpui::test]
  827async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
  828    use workspace::ProjectGroup;
  829
  830    let project = init_test_project("/my-project", cx).await;
  831    let (multi_workspace, cx) =
  832        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  833    let sidebar = setup_sidebar(&multi_workspace, cx);
  834
  835    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
  836    let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
  837    let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
  838
  839    // Set the collapsed group state through multi_workspace
  840    multi_workspace.update(cx, |mw, _cx| {
  841        mw.test_add_project_group(ProjectGroup {
  842            key: ProjectGroupKey::new(None, collapsed_path.clone()),
  843            workspaces: Vec::new(),
  844            expanded: false,
  845        });
  846    });
  847
  848    sidebar.update_in(cx, |s, _window, _cx| {
  849        let notified_thread_id = ThreadId::new();
  850        s.contents.notified_threads.insert(notified_thread_id);
  851        s.contents.entries = vec![
  852            // Expanded project header
  853            ListEntry::ProjectHeader {
  854                key: ProjectGroupKey::new(None, expanded_path.clone()),
  855                label: "expanded-project".into(),
  856                highlight_positions: Vec::new(),
  857                has_running_threads: false,
  858                waiting_thread_count: 0,
  859                is_active: true,
  860                has_threads: true,
  861            },
  862            ListEntry::Thread(ThreadEntry {
  863                metadata: ThreadMetadata {
  864                    thread_id: ThreadId::new(),
  865                    session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
  866                    agent_id: AgentId::new("zed-agent"),
  867                    worktree_paths: WorktreePaths::default(),
  868                    title: Some("Completed thread".into()),
  869                    updated_at: Utc::now(),
  870                    created_at: Some(Utc::now()),
  871                    interacted_at: None,
  872                    archived: false,
  873                    remote_connection: None,
  874                },
  875                icon: IconName::ZedAgent,
  876                icon_from_external_svg: None,
  877                status: AgentThreadStatus::Completed,
  878                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  879                is_live: false,
  880                is_background: false,
  881                is_title_generating: false,
  882                highlight_positions: Vec::new(),
  883                worktrees: Vec::new(),
  884                diff_stats: DiffStats::default(),
  885            }),
  886            // Active thread with Running status
  887            ListEntry::Thread(ThreadEntry {
  888                metadata: ThreadMetadata {
  889                    thread_id: ThreadId::new(),
  890                    session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
  891                    agent_id: AgentId::new("zed-agent"),
  892                    worktree_paths: WorktreePaths::default(),
  893                    title: Some("Running thread".into()),
  894                    updated_at: Utc::now(),
  895                    created_at: Some(Utc::now()),
  896                    interacted_at: None,
  897                    archived: false,
  898                    remote_connection: None,
  899                },
  900                icon: IconName::ZedAgent,
  901                icon_from_external_svg: None,
  902                status: AgentThreadStatus::Running,
  903                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  904                is_live: true,
  905                is_background: false,
  906                is_title_generating: false,
  907                highlight_positions: Vec::new(),
  908                worktrees: Vec::new(),
  909                diff_stats: DiffStats::default(),
  910            }),
  911            // Active thread with Error status
  912            ListEntry::Thread(ThreadEntry {
  913                metadata: ThreadMetadata {
  914                    thread_id: ThreadId::new(),
  915                    session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
  916                    agent_id: AgentId::new("zed-agent"),
  917                    worktree_paths: WorktreePaths::default(),
  918                    title: Some("Error thread".into()),
  919                    updated_at: Utc::now(),
  920                    created_at: Some(Utc::now()),
  921                    interacted_at: None,
  922                    archived: false,
  923                    remote_connection: None,
  924                },
  925                icon: IconName::ZedAgent,
  926                icon_from_external_svg: None,
  927                status: AgentThreadStatus::Error,
  928                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  929                is_live: true,
  930                is_background: false,
  931                is_title_generating: false,
  932                highlight_positions: Vec::new(),
  933                worktrees: Vec::new(),
  934                diff_stats: DiffStats::default(),
  935            }),
  936            // Thread with WaitingForConfirmation status, not active
  937            // remote_connection: None,
  938            ListEntry::Thread(ThreadEntry {
  939                metadata: ThreadMetadata {
  940                    thread_id: ThreadId::new(),
  941                    session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
  942                    agent_id: AgentId::new("zed-agent"),
  943                    worktree_paths: WorktreePaths::default(),
  944                    title: Some("Waiting thread".into()),
  945                    updated_at: Utc::now(),
  946                    created_at: Some(Utc::now()),
  947                    interacted_at: None,
  948                    archived: false,
  949                    remote_connection: None,
  950                },
  951                icon: IconName::ZedAgent,
  952                icon_from_external_svg: None,
  953                status: AgentThreadStatus::WaitingForConfirmation,
  954                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  955                is_live: false,
  956                is_background: false,
  957                is_title_generating: false,
  958                highlight_positions: Vec::new(),
  959                worktrees: Vec::new(),
  960                diff_stats: DiffStats::default(),
  961            }),
  962            // Background thread that completed (should show notification)
  963            // remote_connection: None,
  964            ListEntry::Thread(ThreadEntry {
  965                metadata: ThreadMetadata {
  966                    thread_id: notified_thread_id,
  967                    session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
  968                    agent_id: AgentId::new("zed-agent"),
  969                    worktree_paths: WorktreePaths::default(),
  970                    title: Some("Notified thread".into()),
  971                    updated_at: Utc::now(),
  972                    created_at: Some(Utc::now()),
  973                    interacted_at: None,
  974                    archived: false,
  975                    remote_connection: None,
  976                },
  977                icon: IconName::ZedAgent,
  978                icon_from_external_svg: None,
  979                status: AgentThreadStatus::Completed,
  980                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  981                is_live: true,
  982                is_background: true,
  983                is_title_generating: false,
  984                highlight_positions: Vec::new(),
  985                worktrees: Vec::new(),
  986                diff_stats: DiffStats::default(),
  987            }),
  988            // Collapsed project header
  989            ListEntry::ProjectHeader {
  990                key: ProjectGroupKey::new(None, collapsed_path.clone()),
  991                label: "collapsed-project".into(),
  992                highlight_positions: Vec::new(),
  993                has_running_threads: false,
  994                waiting_thread_count: 0,
  995                is_active: false,
  996                has_threads: false,
  997            },
  998        ];
  999
 1000        // Select the Running thread (index 2)
 1001        s.selection = Some(2);
 1002    });
 1003
 1004    assert_eq!(
 1005        visible_entries_as_strings(&sidebar, cx),
 1006        vec![
 1007            //
 1008            "v [expanded-project]",
 1009            "  Completed thread",
 1010            "  Running thread * (running)  <== selected",
 1011            "  Error thread * (error)",
 1012            "  Waiting thread (waiting)",
 1013            "  Notified thread * (!)",
 1014            "> [collapsed-project]",
 1015        ]
 1016    );
 1017
 1018    // Move selection to the collapsed header
 1019    sidebar.update_in(cx, |s, _window, _cx| {
 1020        s.selection = Some(6);
 1021    });
 1022
 1023    assert_eq!(
 1024        visible_entries_as_strings(&sidebar, cx).last().cloned(),
 1025        Some("> [collapsed-project]  <== selected".to_string()),
 1026    );
 1027
 1028    // Clear selection
 1029    sidebar.update_in(cx, |s, _window, _cx| {
 1030        s.selection = None;
 1031    });
 1032
 1033    // No entry should have the selected marker
 1034    let entries = visible_entries_as_strings(&sidebar, cx);
 1035    for entry in &entries {
 1036        assert!(
 1037            !entry.contains("<== selected"),
 1038            "unexpected selection marker in: {}",
 1039            entry
 1040        );
 1041    }
 1042}
 1043
 1044#[gpui::test]
 1045async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
 1046    let project = init_test_project("/my-project", cx).await;
 1047    let (multi_workspace, cx) =
 1048        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1049    let sidebar = setup_sidebar(&multi_workspace, cx);
 1050
 1051    save_n_test_threads(3, &project, cx).await;
 1052
 1053    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1054    cx.run_until_parked();
 1055
 1056    // Entries: [header, thread3, thread2, thread1]
 1057    // Focusing the sidebar does not set a selection; select_next/select_previous
 1058    // handle None gracefully by starting from the first or last entry.
 1059    focus_sidebar(&sidebar, cx);
 1060    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1061
 1062    // First SelectNext from None starts at index 0
 1063    cx.dispatch_action(SelectNext);
 1064    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1065
 1066    // Move down through remaining entries
 1067    cx.dispatch_action(SelectNext);
 1068    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1069
 1070    cx.dispatch_action(SelectNext);
 1071    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1072
 1073    cx.dispatch_action(SelectNext);
 1074    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1075
 1076    // At the end, wraps back to first entry
 1077    cx.dispatch_action(SelectNext);
 1078    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1079
 1080    // Navigate back to the end
 1081    cx.dispatch_action(SelectNext);
 1082    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1083    cx.dispatch_action(SelectNext);
 1084    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1085    cx.dispatch_action(SelectNext);
 1086    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1087
 1088    // Move back up
 1089    cx.dispatch_action(SelectPrevious);
 1090    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1091
 1092    cx.dispatch_action(SelectPrevious);
 1093    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1094
 1095    cx.dispatch_action(SelectPrevious);
 1096    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1097
 1098    // At the top, selection clears (focus returns to editor)
 1099    cx.dispatch_action(SelectPrevious);
 1100    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1101}
 1102
 1103#[gpui::test]
 1104async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
 1105    let project = init_test_project("/my-project", cx).await;
 1106    let (multi_workspace, cx) =
 1107        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1108    let sidebar = setup_sidebar(&multi_workspace, cx);
 1109
 1110    save_n_test_threads(3, &project, cx).await;
 1111    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1112    cx.run_until_parked();
 1113
 1114    focus_sidebar(&sidebar, cx);
 1115
 1116    // SelectLast jumps to the end
 1117    cx.dispatch_action(SelectLast);
 1118    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1119
 1120    // SelectFirst jumps to the beginning
 1121    cx.dispatch_action(SelectFirst);
 1122    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1123}
 1124
 1125#[gpui::test]
 1126async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
 1127    let project = init_test_project("/my-project", cx).await;
 1128    let (multi_workspace, cx) =
 1129        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1130    let sidebar = setup_sidebar(&multi_workspace, cx);
 1131
 1132    // Initially no selection
 1133    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1134
 1135    // Open the sidebar so it's rendered, then focus it to trigger focus_in.
 1136    // focus_in no longer sets a default selection.
 1137    focus_sidebar(&sidebar, cx);
 1138    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1139
 1140    // Manually set a selection, blur, then refocus — selection should be preserved
 1141    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1142        sidebar.selection = Some(0);
 1143    });
 1144
 1145    cx.update(|window, _cx| {
 1146        window.blur();
 1147    });
 1148    cx.run_until_parked();
 1149
 1150    sidebar.update_in(cx, |_, window, cx| {
 1151        cx.focus_self(window);
 1152    });
 1153    cx.run_until_parked();
 1154    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1155}
 1156
 1157#[gpui::test]
 1158async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
 1159    let project = init_test_project("/my-project", cx).await;
 1160    let (multi_workspace, cx) =
 1161        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1162    let sidebar = setup_sidebar(&multi_workspace, cx);
 1163
 1164    save_n_test_threads(1, &project, cx).await;
 1165    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1166    cx.run_until_parked();
 1167
 1168    assert_eq!(
 1169        visible_entries_as_strings(&sidebar, cx),
 1170        vec![
 1171            //
 1172            "v [my-project]",
 1173            "  Thread 1",
 1174        ]
 1175    );
 1176
 1177    // Focus the sidebar and select the header
 1178    focus_sidebar(&sidebar, cx);
 1179    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1180        sidebar.selection = Some(0);
 1181    });
 1182
 1183    // Confirm on project header collapses the group
 1184    cx.dispatch_action(Confirm);
 1185    cx.run_until_parked();
 1186
 1187    assert_eq!(
 1188        visible_entries_as_strings(&sidebar, cx),
 1189        vec![
 1190            //
 1191            "> [my-project]  <== selected",
 1192        ]
 1193    );
 1194
 1195    // Confirm again expands the group
 1196    cx.dispatch_action(Confirm);
 1197    cx.run_until_parked();
 1198
 1199    assert_eq!(
 1200        visible_entries_as_strings(&sidebar, cx),
 1201        vec![
 1202            //
 1203            "v [my-project]  <== selected",
 1204            "  Thread 1",
 1205        ]
 1206    );
 1207}
 1208
 1209#[gpui::test]
 1210async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
 1211    let project = init_test_project("/my-project", cx).await;
 1212    let (multi_workspace, cx) =
 1213        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1214    let sidebar = setup_sidebar(&multi_workspace, cx);
 1215
 1216    save_n_test_threads(1, &project, cx).await;
 1217    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1218    cx.run_until_parked();
 1219
 1220    assert_eq!(
 1221        visible_entries_as_strings(&sidebar, cx),
 1222        vec![
 1223            //
 1224            "v [my-project]",
 1225            "  Thread 1",
 1226        ]
 1227    );
 1228
 1229    // Focus sidebar and manually select the header (index 0). Press left to collapse.
 1230    focus_sidebar(&sidebar, cx);
 1231    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1232        sidebar.selection = Some(0);
 1233    });
 1234
 1235    cx.dispatch_action(SelectParent);
 1236    cx.run_until_parked();
 1237
 1238    assert_eq!(
 1239        visible_entries_as_strings(&sidebar, cx),
 1240        vec![
 1241            //
 1242            "> [my-project]  <== selected",
 1243        ]
 1244    );
 1245
 1246    // Press right to expand
 1247    cx.dispatch_action(SelectChild);
 1248    cx.run_until_parked();
 1249
 1250    assert_eq!(
 1251        visible_entries_as_strings(&sidebar, cx),
 1252        vec![
 1253            //
 1254            "v [my-project]  <== selected",
 1255            "  Thread 1",
 1256        ]
 1257    );
 1258
 1259    // Press right again on already-expanded header moves selection down
 1260    cx.dispatch_action(SelectChild);
 1261    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1262}
 1263
 1264#[gpui::test]
 1265async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
 1266    let project = init_test_project("/my-project", cx).await;
 1267    let (multi_workspace, cx) =
 1268        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1269    let sidebar = setup_sidebar(&multi_workspace, cx);
 1270
 1271    save_n_test_threads(1, &project, cx).await;
 1272    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1273    cx.run_until_parked();
 1274
 1275    // Focus sidebar (selection starts at None), then navigate down to the thread (child)
 1276    focus_sidebar(&sidebar, cx);
 1277    cx.dispatch_action(SelectNext);
 1278    cx.dispatch_action(SelectNext);
 1279    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1280
 1281    assert_eq!(
 1282        visible_entries_as_strings(&sidebar, cx),
 1283        vec![
 1284            //
 1285            "v [my-project]",
 1286            "  Thread 1  <== selected",
 1287        ]
 1288    );
 1289
 1290    // Pressing left on a child collapses the parent group and selects it
 1291    cx.dispatch_action(SelectParent);
 1292    cx.run_until_parked();
 1293
 1294    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1295    assert_eq!(
 1296        visible_entries_as_strings(&sidebar, cx),
 1297        vec![
 1298            //
 1299            "> [my-project]  <== selected",
 1300        ]
 1301    );
 1302}
 1303
 1304#[gpui::test]
 1305async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
 1306    let project = init_test_project_with_agent_panel("/empty-project", cx).await;
 1307    let (multi_workspace, cx) =
 1308        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1309    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1310
 1311    // An empty project has only the header (no auto-created draft).
 1312    assert_eq!(
 1313        visible_entries_as_strings(&sidebar, cx),
 1314        vec!["v [empty-project]"]
 1315    );
 1316
 1317    // Focus sidebar — focus_in does not set a selection
 1318    focus_sidebar(&sidebar, cx);
 1319    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1320
 1321    // First SelectNext from None starts at index 0 (header)
 1322    cx.dispatch_action(SelectNext);
 1323    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1324
 1325    // SelectNext with only one entry stays at index 0
 1326    cx.dispatch_action(SelectNext);
 1327    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1328
 1329    // SelectPrevious from first entry clears selection (returns to editor)
 1330    cx.dispatch_action(SelectPrevious);
 1331    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1332
 1333    // SelectPrevious from None selects the last entry
 1334    cx.dispatch_action(SelectPrevious);
 1335    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1336}
 1337
 1338#[gpui::test]
 1339async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
 1340    let project = init_test_project("/my-project", cx).await;
 1341    let (multi_workspace, cx) =
 1342        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1343    let sidebar = setup_sidebar(&multi_workspace, cx);
 1344
 1345    save_n_test_threads(1, &project, cx).await;
 1346    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1347    cx.run_until_parked();
 1348
 1349    // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
 1350    focus_sidebar(&sidebar, cx);
 1351    cx.dispatch_action(SelectNext);
 1352    cx.dispatch_action(SelectNext);
 1353    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1354
 1355    // Collapse the group, which removes the thread from the list
 1356    cx.dispatch_action(SelectParent);
 1357    cx.run_until_parked();
 1358
 1359    // Selection should be clamped to the last valid index (0 = header)
 1360    let selection = sidebar.read_with(cx, |s, _| s.selection);
 1361    let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
 1362    assert!(
 1363        selection.unwrap_or(0) < entry_count,
 1364        "selection {} should be within bounds (entries: {})",
 1365        selection.unwrap_or(0),
 1366        entry_count,
 1367    );
 1368}
 1369
 1370async fn init_test_project_with_agent_panel(
 1371    worktree_path: &str,
 1372    cx: &mut TestAppContext,
 1373) -> Entity<project::Project> {
 1374    agent_ui::test_support::init_test(cx);
 1375    cx.update(|cx| {
 1376        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 1377        ThreadStore::init_global(cx);
 1378        ThreadMetadataStore::init_global(cx);
 1379        language_model::LanguageModelRegistry::test(cx);
 1380        prompt_store::init(cx);
 1381    });
 1382
 1383    let fs = FakeFs::new(cx.executor());
 1384    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
 1385        .await;
 1386    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 1387    project::Project::test(fs, [worktree_path.as_ref()], cx).await
 1388}
 1389
 1390fn add_agent_panel(
 1391    workspace: &Entity<Workspace>,
 1392    cx: &mut gpui::VisualTestContext,
 1393) -> Entity<AgentPanel> {
 1394    workspace.update_in(cx, |workspace, window, cx| {
 1395        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 1396        workspace.add_panel(panel.clone(), window, cx);
 1397        panel
 1398    })
 1399}
 1400
 1401fn setup_sidebar_with_agent_panel(
 1402    multi_workspace: &Entity<MultiWorkspace>,
 1403    cx: &mut gpui::VisualTestContext,
 1404) -> (Entity<Sidebar>, Entity<AgentPanel>) {
 1405    let sidebar = setup_sidebar(multi_workspace, cx);
 1406    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 1407    let panel = add_agent_panel(&workspace, cx);
 1408    (sidebar, panel)
 1409}
 1410
 1411#[gpui::test]
 1412async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
 1413    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1414    let (multi_workspace, cx) =
 1415        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1416    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1417
 1418    // Open thread A and keep it generating.
 1419    let connection = StubAgentConnection::new();
 1420    open_thread_with_connection(&panel, connection.clone(), cx);
 1421    send_message(&panel, cx);
 1422
 1423    let session_id_a = active_session_id(&panel, cx);
 1424    save_test_thread_metadata(&session_id_a, &project, cx).await;
 1425
 1426    cx.update(|_, cx| {
 1427        connection.send_update(
 1428            session_id_a.clone(),
 1429            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 1430            cx,
 1431        );
 1432    });
 1433    cx.run_until_parked();
 1434
 1435    // Open thread B (idle, default response) — thread A goes to background.
 1436    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1437        acp::ContentChunk::new("Done".into()),
 1438    )]);
 1439    open_thread_with_connection(&panel, connection, cx);
 1440    send_message(&panel, cx);
 1441
 1442    let session_id_b = active_session_id(&panel, cx);
 1443    save_test_thread_metadata(&session_id_b, &project, cx).await;
 1444
 1445    cx.run_until_parked();
 1446
 1447    let mut entries = visible_entries_as_strings(&sidebar, cx);
 1448    entries[1..].sort();
 1449    assert_eq!(
 1450        entries,
 1451        vec![
 1452            //
 1453            "v [my-project]",
 1454            "  Hello *",
 1455            "  Hello * (running)",
 1456        ]
 1457    );
 1458}
 1459
 1460#[gpui::test]
 1461async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
 1462    cx: &mut TestAppContext,
 1463) {
 1464    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1465    let (multi_workspace, cx) =
 1466        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1467    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1468
 1469    let connection = StubAgentConnection::new().with_supports_load_session(true);
 1470    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1471        acp::ContentChunk::new("Done".into()),
 1472    )]);
 1473    open_thread_with_connection(&panel, connection, cx);
 1474    send_message(&panel, cx);
 1475
 1476    let parent_session_id = active_session_id(&panel, cx);
 1477    save_test_thread_metadata(&parent_session_id, &project, cx).await;
 1478
 1479    let subagent_session_id = acp::SessionId::new("subagent-session");
 1480    cx.update(|_, cx| {
 1481        let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
 1482        parent_thread.update(cx, |thread: &mut AcpThread, cx| {
 1483            thread.subagent_spawned(subagent_session_id.clone(), cx);
 1484        });
 1485    });
 1486    cx.run_until_parked();
 1487
 1488    let subagent_thread = panel.read_with(cx, |panel, cx| {
 1489        panel
 1490            .active_conversation_view()
 1491            .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id))
 1492            .map(|thread_view| thread_view.read(cx).thread.clone())
 1493            .expect("Expected subagent thread to be loaded into the conversation")
 1494    });
 1495    request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
 1496
 1497    let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
 1498        sidebar
 1499            .contents
 1500            .entries
 1501            .iter()
 1502            .find_map(|entry| match entry {
 1503                ListEntry::Thread(thread)
 1504                    if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
 1505                {
 1506                    Some(thread.status)
 1507                }
 1508                _ => None,
 1509            })
 1510            .expect("Expected parent thread entry in sidebar")
 1511    });
 1512
 1513    assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
 1514}
 1515
 1516#[gpui::test]
 1517async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
 1518    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 1519    let (multi_workspace, cx) =
 1520        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1521    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1522
 1523    // Open thread on workspace A and keep it generating.
 1524    let connection_a = StubAgentConnection::new();
 1525    open_thread_with_connection(&panel_a, connection_a.clone(), cx);
 1526    send_message(&panel_a, cx);
 1527
 1528    let session_id_a = active_session_id(&panel_a, cx);
 1529    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 1530
 1531    cx.update(|_, cx| {
 1532        connection_a.send_update(
 1533            session_id_a.clone(),
 1534            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
 1535            cx,
 1536        );
 1537    });
 1538    cx.run_until_parked();
 1539
 1540    // Add a second workspace and activate it (making workspace A the background).
 1541    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 1542    let project_b = project::Project::test(fs, [], cx).await;
 1543    multi_workspace.update_in(cx, |mw, window, cx| {
 1544        mw.test_add_workspace(project_b, window, cx);
 1545    });
 1546    cx.run_until_parked();
 1547
 1548    // Thread A is still running; no notification yet.
 1549    assert_eq!(
 1550        visible_entries_as_strings(&sidebar, cx),
 1551        vec![
 1552            //
 1553            "v [project-a]",
 1554            "  Hello * (running)",
 1555        ]
 1556    );
 1557
 1558    // Complete thread A's turn (transition Running → Completed).
 1559    connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
 1560    cx.run_until_parked();
 1561
 1562    // The completed background thread shows a notification indicator.
 1563    assert_eq!(
 1564        visible_entries_as_strings(&sidebar, cx),
 1565        vec![
 1566            //
 1567            "v [project-a]",
 1568            "  Hello * (!)",
 1569        ]
 1570    );
 1571}
 1572
 1573fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
 1574    sidebar.update_in(cx, |sidebar, window, cx| {
 1575        window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
 1576        sidebar.filter_editor.update(cx, |editor, cx| {
 1577            editor.set_text(query, window, cx);
 1578        });
 1579    });
 1580    cx.run_until_parked();
 1581}
 1582
 1583#[gpui::test]
 1584async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
 1585    let project = init_test_project("/my-project", cx).await;
 1586    let (multi_workspace, cx) =
 1587        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1588    let sidebar = setup_sidebar(&multi_workspace, cx);
 1589
 1590    for (id, title, hour) in [
 1591        ("t-1", "Fix crash in project panel", 3),
 1592        ("t-2", "Add inline diff view", 2),
 1593        ("t-3", "Refactor settings module", 1),
 1594    ] {
 1595        save_thread_metadata(
 1596            acp::SessionId::new(Arc::from(id)),
 1597            Some(title.into()),
 1598            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1599            None,
 1600            None,
 1601            &project,
 1602            cx,
 1603        );
 1604    }
 1605    cx.run_until_parked();
 1606
 1607    assert_eq!(
 1608        visible_entries_as_strings(&sidebar, cx),
 1609        vec![
 1610            //
 1611            "v [my-project]",
 1612            "  Fix crash in project panel",
 1613            "  Add inline diff view",
 1614            "  Refactor settings module",
 1615        ]
 1616    );
 1617
 1618    // User types "diff" in the search box — only the matching thread remains,
 1619    // with its workspace header preserved for context.
 1620    type_in_search(&sidebar, "diff", cx);
 1621    assert_eq!(
 1622        visible_entries_as_strings(&sidebar, cx),
 1623        vec![
 1624            //
 1625            "v [my-project]",
 1626            "  Add inline diff view  <== selected",
 1627        ]
 1628    );
 1629
 1630    // User changes query to something with no matches — list is empty.
 1631    type_in_search(&sidebar, "nonexistent", cx);
 1632    assert_eq!(
 1633        visible_entries_as_strings(&sidebar, cx),
 1634        Vec::<String>::new()
 1635    );
 1636}
 1637
 1638#[gpui::test]
 1639async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
 1640    // Scenario: A user remembers a thread title but not the exact casing.
 1641    // Search should match case-insensitively so they can still find it.
 1642    let project = init_test_project("/my-project", cx).await;
 1643    let (multi_workspace, cx) =
 1644        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1645    let sidebar = setup_sidebar(&multi_workspace, cx);
 1646
 1647    save_thread_metadata(
 1648        acp::SessionId::new(Arc::from("thread-1")),
 1649        Some("Fix Crash In Project Panel".into()),
 1650        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 1651        None,
 1652        None,
 1653        &project,
 1654        cx,
 1655    );
 1656    cx.run_until_parked();
 1657
 1658    // Lowercase query matches mixed-case title.
 1659    type_in_search(&sidebar, "fix crash", cx);
 1660    assert_eq!(
 1661        visible_entries_as_strings(&sidebar, cx),
 1662        vec![
 1663            //
 1664            "v [my-project]",
 1665            "  Fix Crash In Project Panel  <== selected",
 1666        ]
 1667    );
 1668
 1669    // Uppercase query also matches the same title.
 1670    type_in_search(&sidebar, "FIX CRASH", cx);
 1671    assert_eq!(
 1672        visible_entries_as_strings(&sidebar, cx),
 1673        vec![
 1674            //
 1675            "v [my-project]",
 1676            "  Fix Crash In Project Panel  <== selected",
 1677        ]
 1678    );
 1679}
 1680
 1681#[gpui::test]
 1682async fn test_escape_from_search_focuses_first_thread(cx: &mut TestAppContext) {
 1683    // Scenario: A user searches, finds what they need, then presses Escape
 1684    // in the search field to hand keyboard control back to the thread list.
 1685    let project = init_test_project("/my-project", cx).await;
 1686    let (multi_workspace, cx) =
 1687        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1688    let sidebar = setup_sidebar(&multi_workspace, cx);
 1689
 1690    for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
 1691        save_thread_metadata(
 1692            acp::SessionId::new(Arc::from(id)),
 1693            Some(title.into()),
 1694            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1695            None,
 1696            None,
 1697            &project,
 1698            cx,
 1699        )
 1700    }
 1701    cx.run_until_parked();
 1702
 1703    // Confirm the full list is showing.
 1704    assert_eq!(
 1705        visible_entries_as_strings(&sidebar, cx),
 1706        vec![
 1707            //
 1708            "v [my-project]",
 1709            "  Alpha thread",
 1710            "  Beta thread",
 1711        ]
 1712    );
 1713
 1714    // User types a search query to filter down.
 1715    focus_sidebar(&sidebar, cx);
 1716    type_in_search(&sidebar, "alpha", cx);
 1717    assert_eq!(
 1718        visible_entries_as_strings(&sidebar, cx),
 1719        vec![
 1720            //
 1721            "v [my-project]",
 1722            "  Alpha thread  <== selected",
 1723        ]
 1724    );
 1725
 1726    // First Escape clears the search text, restoring the full list.
 1727    // Focus stays on the filter editor.
 1728    cx.dispatch_action(Cancel);
 1729    cx.run_until_parked();
 1730    assert_eq!(
 1731        visible_entries_as_strings(&sidebar, cx),
 1732        vec![
 1733            //
 1734            "v [my-project]",
 1735            "  Alpha thread",
 1736            "  Beta thread",
 1737        ]
 1738    );
 1739    sidebar.update_in(cx, |sidebar, window, cx| {
 1740        assert!(sidebar.filter_editor.read(cx).is_focused(window));
 1741        assert!(!sidebar.focus_handle.is_focused(window));
 1742    });
 1743
 1744    // Second Escape moves focus from the empty search field to the thread list.
 1745    cx.dispatch_action(Cancel);
 1746    cx.run_until_parked();
 1747    sidebar.update_in(cx, |sidebar, window, cx| {
 1748        assert_eq!(sidebar.selection, Some(1));
 1749        assert!(sidebar.focus_handle.is_focused(window));
 1750        assert!(!sidebar.filter_editor.read(cx).is_focused(window));
 1751    });
 1752}
 1753
 1754#[gpui::test]
 1755async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
 1756    let project_a = init_test_project("/project-a", cx).await;
 1757    let (multi_workspace, cx) =
 1758        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1759    let sidebar = setup_sidebar(&multi_workspace, cx);
 1760
 1761    for (id, title, hour) in [
 1762        ("a1", "Fix bug in sidebar", 2),
 1763        ("a2", "Add tests for editor", 1),
 1764    ] {
 1765        save_thread_metadata(
 1766            acp::SessionId::new(Arc::from(id)),
 1767            Some(title.into()),
 1768            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1769            None,
 1770            None,
 1771            &project_a,
 1772            cx,
 1773        )
 1774    }
 1775
 1776    // Add a second workspace.
 1777    multi_workspace.update_in(cx, |mw, window, cx| {
 1778        mw.create_test_workspace(window, cx).detach();
 1779    });
 1780    cx.run_until_parked();
 1781
 1782    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 1783        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 1784    });
 1785
 1786    for (id, title, hour) in [
 1787        ("b1", "Refactor sidebar layout", 3),
 1788        ("b2", "Fix typo in README", 1),
 1789    ] {
 1790        save_thread_metadata(
 1791            acp::SessionId::new(Arc::from(id)),
 1792            Some(title.into()),
 1793            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1794            None,
 1795            None,
 1796            &project_b,
 1797            cx,
 1798        )
 1799    }
 1800    cx.run_until_parked();
 1801
 1802    assert_eq!(
 1803        visible_entries_as_strings(&sidebar, cx),
 1804        vec![
 1805            //
 1806            "v [project-a]",
 1807            "  Fix bug in sidebar",
 1808            "  Add tests for editor",
 1809        ]
 1810    );
 1811
 1812    // "sidebar" matches a thread in each workspace — both headers stay visible.
 1813    type_in_search(&sidebar, "sidebar", cx);
 1814    assert_eq!(
 1815        visible_entries_as_strings(&sidebar, cx),
 1816        vec![
 1817            //
 1818            "v [project-a]",
 1819            "  Fix bug in sidebar  <== selected",
 1820        ]
 1821    );
 1822
 1823    // "typo" only matches in the second workspace — the first header disappears.
 1824    type_in_search(&sidebar, "typo", cx);
 1825    assert_eq!(
 1826        visible_entries_as_strings(&sidebar, cx),
 1827        Vec::<String>::new()
 1828    );
 1829
 1830    // "project-a" matches the first workspace name — the header appears
 1831    // with all child threads included.
 1832    type_in_search(&sidebar, "project-a", cx);
 1833    assert_eq!(
 1834        visible_entries_as_strings(&sidebar, cx),
 1835        vec![
 1836            //
 1837            "v [project-a]",
 1838            "  Fix bug in sidebar  <== selected",
 1839            "  Add tests for editor",
 1840        ]
 1841    );
 1842}
 1843
 1844#[gpui::test]
 1845async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
 1846    let project_a = init_test_project("/alpha-project", cx).await;
 1847    let (multi_workspace, cx) =
 1848        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1849    let sidebar = setup_sidebar(&multi_workspace, cx);
 1850
 1851    for (id, title, hour) in [
 1852        ("a1", "Fix bug in sidebar", 2),
 1853        ("a2", "Add tests for editor", 1),
 1854    ] {
 1855        save_thread_metadata(
 1856            acp::SessionId::new(Arc::from(id)),
 1857            Some(title.into()),
 1858            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1859            None,
 1860            None,
 1861            &project_a,
 1862            cx,
 1863        )
 1864    }
 1865
 1866    // Add a second workspace.
 1867    multi_workspace.update_in(cx, |mw, window, cx| {
 1868        mw.create_test_workspace(window, cx).detach();
 1869    });
 1870    cx.run_until_parked();
 1871
 1872    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 1873        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 1874    });
 1875
 1876    for (id, title, hour) in [
 1877        ("b1", "Refactor sidebar layout", 3),
 1878        ("b2", "Fix typo in README", 1),
 1879    ] {
 1880        save_thread_metadata(
 1881            acp::SessionId::new(Arc::from(id)),
 1882            Some(title.into()),
 1883            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1884            None,
 1885            None,
 1886            &project_b,
 1887            cx,
 1888        )
 1889    }
 1890    cx.run_until_parked();
 1891
 1892    // "alpha" matches the workspace name "alpha-project" but no thread titles.
 1893    // The workspace header should appear with all child threads included.
 1894    type_in_search(&sidebar, "alpha", cx);
 1895    assert_eq!(
 1896        visible_entries_as_strings(&sidebar, cx),
 1897        vec![
 1898            //
 1899            "v [alpha-project]",
 1900            "  Fix bug in sidebar  <== selected",
 1901            "  Add tests for editor",
 1902        ]
 1903    );
 1904
 1905    // "sidebar" matches thread titles in both workspaces but not workspace names.
 1906    // Both headers appear with their matching threads.
 1907    type_in_search(&sidebar, "sidebar", cx);
 1908    assert_eq!(
 1909        visible_entries_as_strings(&sidebar, cx),
 1910        vec![
 1911            //
 1912            "v [alpha-project]",
 1913            "  Fix bug in sidebar  <== selected",
 1914        ]
 1915    );
 1916
 1917    // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
 1918    // doesn't match) — but does not match either workspace name or any thread.
 1919    // Actually let's test something simpler: a query that matches both a workspace
 1920    // name AND some threads in that workspace. Matching threads should still appear.
 1921    type_in_search(&sidebar, "fix", cx);
 1922    assert_eq!(
 1923        visible_entries_as_strings(&sidebar, cx),
 1924        vec![
 1925            //
 1926            "v [alpha-project]",
 1927            "  Fix bug in sidebar  <== selected",
 1928        ]
 1929    );
 1930
 1931    // A query that matches a workspace name AND a thread in that same workspace.
 1932    // Both the header (highlighted) and all child threads should appear.
 1933    type_in_search(&sidebar, "alpha", cx);
 1934    assert_eq!(
 1935        visible_entries_as_strings(&sidebar, cx),
 1936        vec![
 1937            //
 1938            "v [alpha-project]",
 1939            "  Fix bug in sidebar  <== selected",
 1940            "  Add tests for editor",
 1941        ]
 1942    );
 1943
 1944    // Now search for something that matches only a workspace name when there
 1945    // are also threads with matching titles — the non-matching workspace's
 1946    // threads should still appear if their titles match.
 1947    type_in_search(&sidebar, "alp", cx);
 1948    assert_eq!(
 1949        visible_entries_as_strings(&sidebar, cx),
 1950        vec![
 1951            //
 1952            "v [alpha-project]",
 1953            "  Fix bug in sidebar  <== selected",
 1954            "  Add tests for editor",
 1955        ]
 1956    );
 1957}
 1958
 1959#[gpui::test]
 1960async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
 1961    let project = init_test_project("/my-project", cx).await;
 1962    let (multi_workspace, cx) =
 1963        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1964    let sidebar = setup_sidebar(&multi_workspace, cx);
 1965
 1966    save_thread_metadata(
 1967        acp::SessionId::new(Arc::from("thread-1")),
 1968        Some("Important thread".into()),
 1969        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 1970        None,
 1971        None,
 1972        &project,
 1973        cx,
 1974    );
 1975    cx.run_until_parked();
 1976
 1977    // User focuses the sidebar and collapses the group using keyboard:
 1978    // manually select the header, then press SelectParent to collapse.
 1979    focus_sidebar(&sidebar, cx);
 1980    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1981        sidebar.selection = Some(0);
 1982    });
 1983    cx.dispatch_action(SelectParent);
 1984    cx.run_until_parked();
 1985
 1986    assert_eq!(
 1987        visible_entries_as_strings(&sidebar, cx),
 1988        vec![
 1989            //
 1990            "> [my-project]  <== selected",
 1991        ]
 1992    );
 1993
 1994    // User types a search — the thread appears even though its group is collapsed.
 1995    type_in_search(&sidebar, "important", cx);
 1996    assert_eq!(
 1997        visible_entries_as_strings(&sidebar, cx),
 1998        vec![
 1999            //
 2000            "> [my-project]",
 2001            "  Important thread  <== selected",
 2002        ]
 2003    );
 2004}
 2005
 2006#[gpui::test]
 2007async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
 2008    let project = init_test_project("/my-project", cx).await;
 2009    let (multi_workspace, cx) =
 2010        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2011    let sidebar = setup_sidebar(&multi_workspace, cx);
 2012
 2013    for (id, title, hour) in [
 2014        ("t-1", "Fix crash in panel", 3),
 2015        ("t-2", "Fix lint warnings", 2),
 2016        ("t-3", "Add new feature", 1),
 2017    ] {
 2018        save_thread_metadata(
 2019            acp::SessionId::new(Arc::from(id)),
 2020            Some(title.into()),
 2021            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2022            None,
 2023            None,
 2024            &project,
 2025            cx,
 2026        )
 2027    }
 2028    cx.run_until_parked();
 2029
 2030    focus_sidebar(&sidebar, cx);
 2031
 2032    // User types "fix" — two threads match.
 2033    type_in_search(&sidebar, "fix", cx);
 2034    assert_eq!(
 2035        visible_entries_as_strings(&sidebar, cx),
 2036        vec![
 2037            //
 2038            "v [my-project]",
 2039            "  Fix crash in panel  <== selected",
 2040            "  Fix lint warnings",
 2041        ]
 2042    );
 2043
 2044    // Selection starts on the first matching thread. User presses
 2045    // SelectNext to move to the second match.
 2046    cx.dispatch_action(SelectNext);
 2047    assert_eq!(
 2048        visible_entries_as_strings(&sidebar, cx),
 2049        vec![
 2050            //
 2051            "v [my-project]",
 2052            "  Fix crash in panel",
 2053            "  Fix lint warnings  <== selected",
 2054        ]
 2055    );
 2056
 2057    // User can also jump back with SelectPrevious.
 2058    cx.dispatch_action(SelectPrevious);
 2059    assert_eq!(
 2060        visible_entries_as_strings(&sidebar, cx),
 2061        vec![
 2062            //
 2063            "v [my-project]",
 2064            "  Fix crash in panel  <== selected",
 2065            "  Fix lint warnings",
 2066        ]
 2067    );
 2068}
 2069
 2070#[gpui::test]
 2071async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
 2072    let project = init_test_project("/my-project", cx).await;
 2073    let (multi_workspace, cx) =
 2074        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2075    let sidebar = setup_sidebar(&multi_workspace, cx);
 2076
 2077    multi_workspace.update_in(cx, |mw, window, cx| {
 2078        mw.create_test_workspace(window, cx).detach();
 2079    });
 2080    cx.run_until_parked();
 2081
 2082    let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
 2083        (
 2084            mw.workspaces().next().unwrap().clone(),
 2085            mw.workspaces().nth(1).unwrap().clone(),
 2086        )
 2087    });
 2088
 2089    save_thread_metadata(
 2090        acp::SessionId::new(Arc::from("hist-1")),
 2091        Some("Historical Thread".into()),
 2092        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2093        None,
 2094        None,
 2095        &project,
 2096        cx,
 2097    );
 2098    cx.run_until_parked();
 2099    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2100    cx.run_until_parked();
 2101
 2102    assert_eq!(
 2103        visible_entries_as_strings(&sidebar, cx),
 2104        vec![
 2105            //
 2106            "v [my-project]",
 2107            "  Historical Thread",
 2108        ]
 2109    );
 2110
 2111    // Switch to workspace 1 so we can verify the confirm switches back.
 2112    multi_workspace.update_in(cx, |mw, window, cx| {
 2113        let workspace = mw.workspaces().nth(1).unwrap().clone();
 2114        mw.activate(workspace, None, window, cx);
 2115    });
 2116    cx.run_until_parked();
 2117    assert_eq!(
 2118        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2119        workspace_1
 2120    );
 2121
 2122    // Confirm on the historical (non-live) thread at index 1.
 2123    // Before a previous fix, the workspace field was Option<usize> and
 2124    // historical threads had None, so activate_thread early-returned
 2125    // without switching the workspace.
 2126    sidebar.update_in(cx, |sidebar, window, cx| {
 2127        sidebar.selection = Some(1);
 2128        sidebar.confirm(&Confirm, window, cx);
 2129    });
 2130    cx.run_until_parked();
 2131
 2132    assert_eq!(
 2133        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2134        workspace_0
 2135    );
 2136}
 2137
 2138#[gpui::test]
 2139async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
 2140    cx: &mut TestAppContext,
 2141) {
 2142    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2143    let (multi_workspace, cx) =
 2144        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2145    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2146
 2147    let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
 2148    let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
 2149    save_thread_metadata(
 2150        newer_session_id,
 2151        Some("Newer Historical Thread".into()),
 2152        newer_timestamp,
 2153        Some(newer_timestamp),
 2154        None,
 2155        &project,
 2156        cx,
 2157    );
 2158
 2159    let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
 2160    let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
 2161    save_thread_metadata(
 2162        older_session_id.clone(),
 2163        Some("Older Historical Thread".into()),
 2164        older_timestamp,
 2165        Some(older_timestamp),
 2166        None,
 2167        &project,
 2168        cx,
 2169    );
 2170
 2171    cx.run_until_parked();
 2172    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2173    cx.run_until_parked();
 2174
 2175    let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2176        .into_iter()
 2177        .filter(|entry| entry.contains("Historical Thread"))
 2178        .collect();
 2179    assert_eq!(
 2180        historical_entries_before,
 2181        vec![
 2182            "  Newer Historical Thread".to_string(),
 2183            "  Older Historical Thread".to_string(),
 2184        ],
 2185        "expected the sidebar to sort historical threads by their saved timestamp before activation"
 2186    );
 2187
 2188    let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
 2189        sidebar
 2190            .contents
 2191            .entries
 2192            .iter()
 2193            .position(|entry| {
 2194                matches!(entry, ListEntry::Thread(thread)
 2195                    if thread.metadata.session_id.as_ref() == Some(&older_session_id))
 2196            })
 2197            .expect("expected Older Historical Thread to appear in the sidebar")
 2198    });
 2199
 2200    sidebar.update_in(cx, |sidebar, window, cx| {
 2201        sidebar.selection = Some(older_entry_index);
 2202        sidebar.confirm(&Confirm, window, cx);
 2203    });
 2204    cx.run_until_parked();
 2205
 2206    let older_metadata = cx.update(|_, cx| {
 2207        ThreadMetadataStore::global(cx)
 2208            .read(cx)
 2209            .entry_by_session(&older_session_id)
 2210            .cloned()
 2211            .expect("expected metadata for Older Historical Thread after activation")
 2212    });
 2213    assert_eq!(
 2214        older_metadata.created_at,
 2215        Some(older_timestamp),
 2216        "activating a historical thread should not rewrite its saved created_at timestamp"
 2217    );
 2218
 2219    let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2220        .into_iter()
 2221        .filter(|entry| entry.contains("Historical Thread"))
 2222        .collect();
 2223    assert_eq!(
 2224        historical_entries_after,
 2225        vec![
 2226            "  Newer Historical Thread".to_string(),
 2227            "  Older Historical Thread  <== selected".to_string(),
 2228        ],
 2229        "activating an older historical thread should not reorder it ahead of a newer historical thread"
 2230    );
 2231}
 2232
 2233#[gpui::test]
 2234async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
 2235    cx: &mut TestAppContext,
 2236) {
 2237    use workspace::ProjectGroup;
 2238
 2239    agent_ui::test_support::init_test(cx);
 2240    cx.update(|cx| {
 2241        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 2242        ThreadStore::init_global(cx);
 2243        ThreadMetadataStore::init_global(cx);
 2244        language_model::LanguageModelRegistry::test(cx);
 2245        prompt_store::init(cx);
 2246    });
 2247
 2248    let fs = FakeFs::new(cx.executor());
 2249    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 2250        .await;
 2251    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2252        .await;
 2253    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 2254
 2255    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 2256    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 2257
 2258    let (multi_workspace, cx) =
 2259        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2260    let sidebar = setup_sidebar(&multi_workspace, cx);
 2261
 2262    let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
 2263    multi_workspace.update(cx, |mw, _cx| {
 2264        mw.test_add_project_group(ProjectGroup {
 2265            key: project_b_key.clone(),
 2266            workspaces: Vec::new(),
 2267            expanded: true,
 2268        });
 2269    });
 2270
 2271    let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
 2272    save_thread_metadata(
 2273        session_id.clone(),
 2274        Some("Historical Thread in New Group".into()),
 2275        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2276        None,
 2277        None,
 2278        &project_b,
 2279        cx,
 2280    );
 2281    cx.run_until_parked();
 2282
 2283    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2284    cx.run_until_parked();
 2285
 2286    let entries_before = visible_entries_as_strings(&sidebar, cx);
 2287    assert_eq!(
 2288        entries_before,
 2289        vec![
 2290            "v [project-a]",
 2291            "v [project-b]",
 2292            "  Historical Thread in New Group",
 2293        ],
 2294        "expected the closed project group to show the historical thread before first open"
 2295    );
 2296
 2297    assert_eq!(
 2298        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2299        1,
 2300        "should start without an open workspace for the new project group"
 2301    );
 2302
 2303    sidebar.update_in(cx, |sidebar, window, cx| {
 2304        sidebar.selection = Some(2);
 2305        sidebar.confirm(&Confirm, window, cx);
 2306    });
 2307
 2308    cx.run_until_parked();
 2309
 2310    assert_eq!(
 2311        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2312        2,
 2313        "confirming the historical thread should open a workspace for the new project group"
 2314    );
 2315
 2316    let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
 2317        mw.workspaces()
 2318            .find(|workspace| {
 2319                PathList::new(&workspace.read(cx).root_paths(cx))
 2320                    == project_b_key.path_list().clone()
 2321            })
 2322            .cloned()
 2323            .expect("expected workspace for project-b after opening the historical thread")
 2324    });
 2325
 2326    assert_eq!(
 2327        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2328        workspace_b,
 2329        "opening the historical thread should activate the new project's workspace"
 2330    );
 2331
 2332    let panel = workspace_b.read_with(cx, |workspace, cx| {
 2333        workspace
 2334            .panel::<AgentPanel>(cx)
 2335            .expect("expected first-open activation to bootstrap the agent panel")
 2336    });
 2337
 2338    let expected_thread_id = cx.update(|_, cx| {
 2339        ThreadMetadataStore::global(cx)
 2340            .read(cx)
 2341            .entries()
 2342            .find(|e| e.session_id.as_ref() == Some(&session_id))
 2343            .map(|e| e.thread_id)
 2344            .expect("metadata should still map session id to thread id")
 2345    });
 2346
 2347    assert_eq!(
 2348        panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 2349        Some(expected_thread_id),
 2350        "expected the agent panel to activate the real historical thread rather than a draft"
 2351    );
 2352
 2353    let entries_after = visible_entries_as_strings(&sidebar, cx);
 2354    let matching_rows: Vec<_> = entries_after
 2355        .iter()
 2356        .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
 2357        .cloned()
 2358        .collect();
 2359    assert_eq!(
 2360        matching_rows.len(),
 2361        1,
 2362        "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
 2363    );
 2364    assert!(
 2365        matching_rows[0].contains("Historical Thread in New Group"),
 2366        "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
 2367    );
 2368    assert!(
 2369        !matching_rows[0].contains("Draft"),
 2370        "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
 2371    );
 2372}
 2373
 2374#[gpui::test]
 2375async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
 2376    let project = init_test_project("/my-project", cx).await;
 2377    let (multi_workspace, cx) =
 2378        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2379    let sidebar = setup_sidebar(&multi_workspace, cx);
 2380
 2381    save_thread_metadata(
 2382        acp::SessionId::new(Arc::from("t-1")),
 2383        Some("Thread A".into()),
 2384        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 2385        None,
 2386        None,
 2387        &project,
 2388        cx,
 2389    );
 2390
 2391    save_thread_metadata(
 2392        acp::SessionId::new(Arc::from("t-2")),
 2393        Some("Thread B".into()),
 2394        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 2395        None,
 2396        None,
 2397        &project,
 2398        cx,
 2399    );
 2400
 2401    cx.run_until_parked();
 2402    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2403    cx.run_until_parked();
 2404
 2405    assert_eq!(
 2406        visible_entries_as_strings(&sidebar, cx),
 2407        vec![
 2408            //
 2409            "v [my-project]",
 2410            "  Thread A",
 2411            "  Thread B",
 2412        ]
 2413    );
 2414
 2415    // Keyboard confirm preserves selection.
 2416    sidebar.update_in(cx, |sidebar, window, cx| {
 2417        sidebar.selection = Some(1);
 2418        sidebar.confirm(&Confirm, window, cx);
 2419    });
 2420    assert_eq!(
 2421        sidebar.read_with(cx, |sidebar, _| sidebar.selection),
 2422        Some(1)
 2423    );
 2424
 2425    // Click handlers clear selection to None so no highlight lingers
 2426    // after a click regardless of focus state. The hover style provides
 2427    // visual feedback during mouse interaction instead.
 2428    sidebar.update_in(cx, |sidebar, window, cx| {
 2429        sidebar.selection = None;
 2430        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 2431        let project_group_key = ProjectGroupKey::new(None, path_list);
 2432        sidebar.toggle_collapse(&project_group_key, window, cx);
 2433    });
 2434    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2435
 2436    // When the user tabs back into the sidebar, focus_in no longer
 2437    // restores selection — it stays None.
 2438    sidebar.update_in(cx, |sidebar, window, cx| {
 2439        sidebar.focus_in(window, cx);
 2440    });
 2441    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2442}
 2443
 2444#[gpui::test]
 2445async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
 2446    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2447    let (multi_workspace, cx) =
 2448        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2449    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2450
 2451    let connection = StubAgentConnection::new();
 2452    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2453        acp::ContentChunk::new("Hi there!".into()),
 2454    )]);
 2455    open_thread_with_connection(&panel, connection, cx);
 2456    send_message(&panel, cx);
 2457
 2458    let session_id = active_session_id(&panel, cx);
 2459    save_test_thread_metadata(&session_id, &project, cx).await;
 2460    cx.run_until_parked();
 2461
 2462    assert_eq!(
 2463        visible_entries_as_strings(&sidebar, cx),
 2464        vec![
 2465            //
 2466            "v [my-project]",
 2467            "  Hello *",
 2468        ]
 2469    );
 2470
 2471    // Simulate the agent generating a title. The notification chain is:
 2472    // AcpThread::set_title emits TitleUpdated →
 2473    // ConnectionView::handle_thread_event calls cx.notify() →
 2474    // AgentPanel observer fires and emits AgentPanelEvent →
 2475    // Sidebar subscription calls update_entries / rebuild_contents.
 2476    //
 2477    // Before the fix, handle_thread_event did NOT call cx.notify() for
 2478    // TitleUpdated, so the AgentPanel observer never fired and the
 2479    // sidebar kept showing the old title.
 2480    let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
 2481    thread.update(cx, |thread, cx| {
 2482        thread
 2483            .set_title("Friendly Greeting with AI".into(), cx)
 2484            .detach();
 2485    });
 2486    cx.run_until_parked();
 2487
 2488    assert_eq!(
 2489        visible_entries_as_strings(&sidebar, cx),
 2490        vec![
 2491            //
 2492            "v [my-project]",
 2493            "  Friendly Greeting with AI *",
 2494        ]
 2495    );
 2496}
 2497
 2498#[gpui::test]
 2499async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
 2500    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 2501    let (multi_workspace, cx) =
 2502        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2503    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2504
 2505    // Save a thread so it appears in the list.
 2506    let connection_a = StubAgentConnection::new();
 2507    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2508        acp::ContentChunk::new("Done".into()),
 2509    )]);
 2510    open_thread_with_connection(&panel_a, connection_a, cx);
 2511    send_message(&panel_a, cx);
 2512    let session_id_a = active_session_id(&panel_a, cx);
 2513    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 2514
 2515    // Add a second workspace with its own agent panel.
 2516    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 2517    fs.as_fake()
 2518        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2519        .await;
 2520    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
 2521    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 2522        mw.test_add_workspace(project_b.clone(), window, cx)
 2523    });
 2524    let panel_b = add_agent_panel(&workspace_b, cx);
 2525    cx.run_until_parked();
 2526
 2527    let workspace_a =
 2528        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 2529
 2530    // ── 1. Initial state: focused thread derived from active panel ─────
 2531    sidebar.read_with(cx, |sidebar, _cx| {
 2532        assert_active_thread(
 2533            sidebar,
 2534            &session_id_a,
 2535            "The active panel's thread should be focused on startup",
 2536        );
 2537    });
 2538
 2539    let thread_metadata_a = cx.update(|_window, cx| {
 2540        ThreadMetadataStore::global(cx)
 2541            .read(cx)
 2542            .entry_by_session(&session_id_a)
 2543            .cloned()
 2544            .expect("session_id_a should exist in metadata store")
 2545    });
 2546    sidebar.update_in(cx, |sidebar, window, cx| {
 2547        sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
 2548    });
 2549    cx.run_until_parked();
 2550
 2551    sidebar.read_with(cx, |sidebar, _cx| {
 2552        assert_active_thread(
 2553            sidebar,
 2554            &session_id_a,
 2555            "After clicking a thread, it should be the focused thread",
 2556        );
 2557        assert!(
 2558            has_thread_entry(sidebar, &session_id_a),
 2559            "The clicked thread should be present in the entries"
 2560        );
 2561    });
 2562
 2563    workspace_a.read_with(cx, |workspace, cx| {
 2564        assert!(
 2565            workspace.panel::<AgentPanel>(cx).is_some(),
 2566            "Agent panel should exist"
 2567        );
 2568        let dock = workspace.left_dock().read(cx);
 2569        assert!(
 2570            dock.is_open(),
 2571            "Clicking a thread should open the agent panel dock"
 2572        );
 2573    });
 2574
 2575    let connection_b = StubAgentConnection::new();
 2576    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2577        acp::ContentChunk::new("Thread B".into()),
 2578    )]);
 2579    open_thread_with_connection(&panel_b, connection_b, cx);
 2580    send_message(&panel_b, cx);
 2581    let session_id_b = active_session_id(&panel_b, cx);
 2582    save_test_thread_metadata(&session_id_b, &project_b, cx).await;
 2583    cx.run_until_parked();
 2584
 2585    // Workspace A is currently active. Click a thread in workspace B,
 2586    // which also triggers a workspace switch.
 2587    let thread_metadata_b = cx.update(|_window, cx| {
 2588        ThreadMetadataStore::global(cx)
 2589            .read(cx)
 2590            .entry_by_session(&session_id_b)
 2591            .cloned()
 2592            .expect("session_id_b should exist in metadata store")
 2593    });
 2594    sidebar.update_in(cx, |sidebar, window, cx| {
 2595        sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
 2596    });
 2597    cx.run_until_parked();
 2598
 2599    sidebar.read_with(cx, |sidebar, _cx| {
 2600        assert_active_thread(
 2601            sidebar,
 2602            &session_id_b,
 2603            "Clicking a thread in another workspace should focus that thread",
 2604        );
 2605        assert!(
 2606            has_thread_entry(sidebar, &session_id_b),
 2607            "The cross-workspace thread should be present in the entries"
 2608        );
 2609    });
 2610
 2611    multi_workspace.update_in(cx, |mw, window, cx| {
 2612        let workspace = mw.workspaces().next().unwrap().clone();
 2613        mw.activate(workspace, None, window, cx);
 2614    });
 2615    cx.run_until_parked();
 2616
 2617    sidebar.read_with(cx, |sidebar, _cx| {
 2618        assert_active_thread(
 2619            sidebar,
 2620            &session_id_a,
 2621            "Switching workspace should seed focused_thread from the new active panel",
 2622        );
 2623        assert!(
 2624            has_thread_entry(sidebar, &session_id_a),
 2625            "The seeded thread should be present in the entries"
 2626        );
 2627    });
 2628
 2629    let connection_b2 = StubAgentConnection::new();
 2630    connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2631        acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
 2632    )]);
 2633    open_thread_with_connection(&panel_b, connection_b2, cx);
 2634    send_message(&panel_b, cx);
 2635    let session_id_b2 = active_session_id(&panel_b, cx);
 2636    save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
 2637    cx.run_until_parked();
 2638
 2639    // Panel B is not the active workspace's panel (workspace A is
 2640    // active), so opening a thread there should not change focused_thread.
 2641    // This prevents running threads in background workspaces from causing
 2642    // the selection highlight to jump around.
 2643    sidebar.read_with(cx, |sidebar, _cx| {
 2644        assert_active_thread(
 2645            sidebar,
 2646            &session_id_a,
 2647            "Opening a thread in a non-active panel should not change focused_thread",
 2648        );
 2649    });
 2650
 2651    workspace_b.update_in(cx, |workspace, window, cx| {
 2652        workspace.focus_handle(cx).focus(window, cx);
 2653    });
 2654    cx.run_until_parked();
 2655
 2656    sidebar.read_with(cx, |sidebar, _cx| {
 2657        assert_active_thread(
 2658            sidebar,
 2659            &session_id_a,
 2660            "Defocusing the sidebar should not change focused_thread",
 2661        );
 2662    });
 2663
 2664    // Switching workspaces via the multi_workspace (simulates clicking
 2665    // a workspace header) should clear focused_thread.
 2666    multi_workspace.update_in(cx, |mw, window, cx| {
 2667        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
 2668        if let Some(workspace) = workspace {
 2669            mw.activate(workspace, None, window, cx);
 2670        }
 2671    });
 2672    cx.run_until_parked();
 2673
 2674    sidebar.read_with(cx, |sidebar, _cx| {
 2675        assert_active_thread(
 2676            sidebar,
 2677            &session_id_b2,
 2678            "Switching workspace should seed focused_thread from the new active panel",
 2679        );
 2680        assert!(
 2681            has_thread_entry(sidebar, &session_id_b2),
 2682            "The seeded thread should be present in the entries"
 2683        );
 2684    });
 2685
 2686    // ── 8. Focusing the agent panel thread keeps focused_thread ────
 2687    // Workspace B still has session_id_b2 loaded in the agent panel.
 2688    // Clicking into the thread (simulated by focusing its view) should
 2689    // keep focused_thread since it was already seeded on workspace switch.
 2690    panel_b.update_in(cx, |panel, window, cx| {
 2691        if let Some(thread_view) = panel.active_conversation_view() {
 2692            thread_view.read(cx).focus_handle(cx).focus(window, cx);
 2693        }
 2694    });
 2695    cx.run_until_parked();
 2696
 2697    sidebar.read_with(cx, |sidebar, _cx| {
 2698        assert_active_thread(
 2699            sidebar,
 2700            &session_id_b2,
 2701            "Focusing the agent panel thread should set focused_thread",
 2702        );
 2703        assert!(
 2704            has_thread_entry(sidebar, &session_id_b2),
 2705            "The focused thread should be present in the entries"
 2706        );
 2707    });
 2708}
 2709
 2710#[gpui::test]
 2711async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
 2712    let project = init_test_project_with_agent_panel("/project-a", cx).await;
 2713    let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
 2714    let (multi_workspace, cx) =
 2715        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2716    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2717
 2718    // Start a thread and send a message so it has history.
 2719    let connection = StubAgentConnection::new();
 2720    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2721        acp::ContentChunk::new("Done".into()),
 2722    )]);
 2723    open_thread_with_connection(&panel, connection, cx);
 2724    send_message(&panel, cx);
 2725    let session_id = active_session_id(&panel, cx);
 2726    save_test_thread_metadata(&session_id, &project, cx).await;
 2727    cx.run_until_parked();
 2728
 2729    // Verify the thread appears in the sidebar.
 2730    assert_eq!(
 2731        visible_entries_as_strings(&sidebar, cx),
 2732        vec![
 2733            //
 2734            "v [project-a]",
 2735            "  Hello *",
 2736        ]
 2737    );
 2738
 2739    // The "New Thread" button should NOT be in "active/draft" state
 2740    // because the panel has a thread with messages.
 2741    sidebar.read_with(cx, |sidebar, _cx| {
 2742        assert!(
 2743            matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
 2744            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
 2745            sidebar.active_entry,
 2746        );
 2747    });
 2748
 2749    // Now add a second folder to the workspace, changing the path_list.
 2750    fs.as_fake()
 2751        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2752        .await;
 2753    project
 2754        .update(cx, |project, cx| {
 2755            project.find_or_create_worktree("/project-b", true, cx)
 2756        })
 2757        .await
 2758        .expect("should add worktree");
 2759    cx.run_until_parked();
 2760
 2761    // The workspace path_list is now [project-a, project-b]. The active
 2762    // thread's metadata was re-saved with the new paths by the agent panel's
 2763    // project subscription. The old [project-a] key is replaced by the new
 2764    // key since no other workspace claims it.
 2765    let entries = visible_entries_as_strings(&sidebar, cx);
 2766    // After adding a worktree, the thread migrates to the new group key.
 2767    // A reconciliation draft may appear during the transition.
 2768    assert!(
 2769        entries.contains(&"  Hello *".to_string()),
 2770        "thread should still be present after adding folder: {entries:?}"
 2771    );
 2772    assert_eq!(entries[0], "v [project-a, project-b]");
 2773
 2774    // The "New Thread" button must still be clickable (not stuck in
 2775    // "active/draft" state). Verify that `active_thread_is_draft` is
 2776    // false — the panel still has the old thread with messages.
 2777    sidebar.read_with(cx, |sidebar, _cx| {
 2778        assert!(
 2779            matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
 2780            "After adding a folder the panel still has a thread with messages, \
 2781                 so active_entry should be Thread, got {:?}",
 2782            sidebar.active_entry,
 2783        );
 2784    });
 2785
 2786    // Actually click "New Thread" by calling create_new_thread and
 2787    // verify a new draft is created.
 2788    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 2789    sidebar.update_in(cx, |sidebar, window, cx| {
 2790        sidebar.create_new_thread(&workspace, window, cx);
 2791    });
 2792    cx.run_until_parked();
 2793
 2794    // After creating a new thread, the panel should now be in draft
 2795    // state (no messages on the new thread).
 2796    sidebar.read_with(cx, |sidebar, _cx| {
 2797        assert_active_draft(
 2798            sidebar,
 2799            &workspace,
 2800            "After creating a new thread active_entry should be Draft",
 2801        );
 2802    });
 2803}
 2804#[gpui::test]
 2805async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
 2806    // When the user presses Cmd-N (NewThread action) while viewing a
 2807    // non-empty thread, the panel should switch to the draft thread.
 2808    // Drafts are not shown as sidebar rows.
 2809    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2810    let (multi_workspace, cx) =
 2811        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2812    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2813
 2814    // Create a non-empty thread (has messages).
 2815    let connection = StubAgentConnection::new();
 2816    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2817        acp::ContentChunk::new("Done".into()),
 2818    )]);
 2819    open_thread_with_connection(&panel, connection, cx);
 2820    send_message(&panel, cx);
 2821
 2822    let session_id = active_session_id(&panel, cx);
 2823    save_test_thread_metadata(&session_id, &project, cx).await;
 2824    cx.run_until_parked();
 2825
 2826    assert_eq!(
 2827        visible_entries_as_strings(&sidebar, cx),
 2828        vec![
 2829            //
 2830            "v [my-project]",
 2831            "  Hello *",
 2832        ]
 2833    );
 2834
 2835    // Simulate cmd-n
 2836    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 2837    panel.update_in(cx, |panel, window, cx| {
 2838        panel.new_thread(&NewThread, window, cx);
 2839    });
 2840    workspace.update_in(cx, |workspace, window, cx| {
 2841        workspace.focus_panel::<AgentPanel>(window, cx);
 2842    });
 2843    cx.run_until_parked();
 2844
 2845    // Drafts are not shown as sidebar rows, so entries stay the same.
 2846    assert_eq!(
 2847        visible_entries_as_strings(&sidebar, cx),
 2848        vec!["v [my-project]", "  Hello *"],
 2849        "After Cmd-N the sidebar should not show a Draft entry"
 2850    );
 2851
 2852    // The panel should be on the draft and active_entry should track it.
 2853    panel.read_with(cx, |panel, cx| {
 2854        assert!(
 2855            panel.active_thread_is_draft(cx),
 2856            "panel should be showing the draft after Cmd-N",
 2857        );
 2858    });
 2859    sidebar.read_with(cx, |sidebar, _cx| {
 2860        assert_active_draft(
 2861            sidebar,
 2862            &workspace,
 2863            "active_entry should be Draft after Cmd-N",
 2864        );
 2865    });
 2866}
 2867
 2868#[gpui::test]
 2869async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
 2870    // When the active workspace is an absorbed git worktree, cmd-n
 2871    // should activate the draft thread in the panel. Drafts are not
 2872    // shown as sidebar rows.
 2873    agent_ui::test_support::init_test(cx);
 2874    cx.update(|cx| {
 2875        ThreadStore::init_global(cx);
 2876        ThreadMetadataStore::init_global(cx);
 2877        language_model::LanguageModelRegistry::test(cx);
 2878        prompt_store::init(cx);
 2879    });
 2880
 2881    let fs = FakeFs::new(cx.executor());
 2882
 2883    // Main repo with a linked worktree.
 2884    fs.insert_tree(
 2885        "/project",
 2886        serde_json::json!({
 2887            ".git": {},
 2888            "src": {},
 2889        }),
 2890    )
 2891    .await;
 2892
 2893    // Worktree checkout pointing back to the main repo.
 2894    fs.add_linked_worktree_for_repo(
 2895        Path::new("/project/.git"),
 2896        false,
 2897        git::repository::Worktree {
 2898            path: std::path::PathBuf::from("/wt-feature-a"),
 2899            ref_name: Some("refs/heads/feature-a".into()),
 2900            sha: "aaa".into(),
 2901            is_main: false,
 2902            is_bare: false,
 2903        },
 2904    )
 2905    .await;
 2906
 2907    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 2908
 2909    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 2910    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 2911
 2912    main_project
 2913        .update(cx, |p, cx| p.git_scans_complete(cx))
 2914        .await;
 2915    worktree_project
 2916        .update(cx, |p, cx| p.git_scans_complete(cx))
 2917        .await;
 2918
 2919    let (multi_workspace, cx) =
 2920        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 2921
 2922    let sidebar = setup_sidebar(&multi_workspace, cx);
 2923
 2924    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 2925        mw.test_add_workspace(worktree_project.clone(), window, cx)
 2926    });
 2927
 2928    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 2929
 2930    // Switch to the worktree workspace.
 2931    multi_workspace.update_in(cx, |mw, window, cx| {
 2932        let workspace = mw.workspaces().nth(1).unwrap().clone();
 2933        mw.activate(workspace, None, window, cx);
 2934    });
 2935
 2936    // Create a non-empty thread in the worktree workspace.
 2937    let connection = StubAgentConnection::new();
 2938    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2939        acp::ContentChunk::new("Done".into()),
 2940    )]);
 2941    open_thread_with_connection(&worktree_panel, connection, cx);
 2942    send_message(&worktree_panel, cx);
 2943
 2944    let session_id = active_session_id(&worktree_panel, cx);
 2945    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 2946    cx.run_until_parked();
 2947
 2948    assert_eq!(
 2949        visible_entries_as_strings(&sidebar, cx),
 2950        vec![
 2951            //
 2952            "v [project]",
 2953            "  Hello {wt-feature-a} *",
 2954        ]
 2955    );
 2956
 2957    // Simulate Cmd-N in the worktree workspace.
 2958    worktree_panel.update_in(cx, |panel, window, cx| {
 2959        panel.new_thread(&NewThread, window, cx);
 2960    });
 2961    worktree_workspace.update_in(cx, |workspace, window, cx| {
 2962        workspace.focus_panel::<AgentPanel>(window, cx);
 2963    });
 2964    cx.run_until_parked();
 2965
 2966    // Drafts are not shown as sidebar rows, so entries stay the same.
 2967    assert_eq!(
 2968        visible_entries_as_strings(&sidebar, cx),
 2969        vec![
 2970            //
 2971            "v [project]",
 2972            "  Hello {wt-feature-a} *"
 2973        ],
 2974        "After Cmd-N the sidebar should not show a Draft entry"
 2975    );
 2976
 2977    // The panel should be on the draft and active_entry should track it.
 2978    worktree_panel.read_with(cx, |panel, cx| {
 2979        assert!(
 2980            panel.active_thread_is_draft(cx),
 2981            "panel should be showing the draft after Cmd-N",
 2982        );
 2983    });
 2984    sidebar.read_with(cx, |sidebar, _cx| {
 2985        assert_active_draft(
 2986            sidebar,
 2987            &worktree_workspace,
 2988            "active_entry should be Draft after Cmd-N",
 2989        );
 2990    });
 2991}
 2992
 2993async fn init_test_project_with_git(
 2994    worktree_path: &str,
 2995    cx: &mut TestAppContext,
 2996) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
 2997    init_test(cx);
 2998    let fs = FakeFs::new(cx.executor());
 2999    fs.insert_tree(
 3000        worktree_path,
 3001        serde_json::json!({
 3002            ".git": {},
 3003            "src": {},
 3004        }),
 3005    )
 3006    .await;
 3007    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3008    let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
 3009    (project, fs)
 3010}
 3011
 3012#[gpui::test]
 3013async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
 3014    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3015
 3016    fs.as_fake()
 3017        .add_linked_worktree_for_repo(
 3018            Path::new("/project/.git"),
 3019            false,
 3020            git::repository::Worktree {
 3021                path: std::path::PathBuf::from("/wt/rosewood"),
 3022                ref_name: Some("refs/heads/rosewood".into()),
 3023                sha: "abc".into(),
 3024                is_main: false,
 3025                is_bare: false,
 3026            },
 3027        )
 3028        .await;
 3029
 3030    project
 3031        .update(cx, |project, cx| project.git_scans_complete(cx))
 3032        .await;
 3033
 3034    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3035    worktree_project
 3036        .update(cx, |p, cx| p.git_scans_complete(cx))
 3037        .await;
 3038
 3039    let (multi_workspace, cx) =
 3040        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3041    let sidebar = setup_sidebar(&multi_workspace, cx);
 3042
 3043    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
 3044    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
 3045
 3046    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3047    cx.run_until_parked();
 3048
 3049    // Search for "rosewood" — should match the worktree name, not the title.
 3050    type_in_search(&sidebar, "rosewood", cx);
 3051
 3052    assert_eq!(
 3053        visible_entries_as_strings(&sidebar, cx),
 3054        vec![
 3055            //
 3056            "v [project]",
 3057            "  Fix Bug {rosewood}  <== selected",
 3058        ],
 3059    );
 3060}
 3061
 3062#[gpui::test]
 3063async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
 3064    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3065
 3066    project
 3067        .update(cx, |project, cx| project.git_scans_complete(cx))
 3068        .await;
 3069
 3070    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3071    worktree_project
 3072        .update(cx, |p, cx| p.git_scans_complete(cx))
 3073        .await;
 3074
 3075    let (multi_workspace, cx) =
 3076        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3077    let sidebar = setup_sidebar(&multi_workspace, cx);
 3078
 3079    // Save a thread against a worktree path with the correct main
 3080    // worktree association (as if the git state had been resolved).
 3081    save_thread_metadata_with_main_paths(
 3082        "wt-thread",
 3083        "Worktree Thread",
 3084        PathList::new(&[PathBuf::from("/wt/rosewood")]),
 3085        PathList::new(&[PathBuf::from("/project")]),
 3086        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3087        cx,
 3088    );
 3089
 3090    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3091    cx.run_until_parked();
 3092
 3093    // Thread is visible because its main_worktree_paths match the group.
 3094    // The chip name is derived from the path even before git discovery.
 3095    assert_eq!(
 3096        visible_entries_as_strings(&sidebar, cx),
 3097        vec!["v [project]", "  Worktree Thread {rosewood}"]
 3098    );
 3099
 3100    // Now add the worktree to the git state and trigger a rescan.
 3101    fs.as_fake()
 3102        .add_linked_worktree_for_repo(
 3103            Path::new("/project/.git"),
 3104            true,
 3105            git::repository::Worktree {
 3106                path: std::path::PathBuf::from("/wt/rosewood"),
 3107                ref_name: Some("refs/heads/rosewood".into()),
 3108                sha: "abc".into(),
 3109                is_main: false,
 3110                is_bare: false,
 3111            },
 3112        )
 3113        .await;
 3114
 3115    cx.run_until_parked();
 3116
 3117    assert_eq!(
 3118        visible_entries_as_strings(&sidebar, cx),
 3119        vec![
 3120            //
 3121            "v [project]",
 3122            "  Worktree Thread {rosewood}",
 3123        ]
 3124    );
 3125}
 3126
 3127#[gpui::test]
 3128async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
 3129    init_test(cx);
 3130    let fs = FakeFs::new(cx.executor());
 3131
 3132    // Create the main repo directory (not opened as a workspace yet).
 3133    fs.insert_tree(
 3134        "/project",
 3135        serde_json::json!({
 3136            ".git": {
 3137            },
 3138            "src": {},
 3139        }),
 3140    )
 3141    .await;
 3142
 3143    // Two worktree checkouts whose .git files point back to the main repo.
 3144    fs.add_linked_worktree_for_repo(
 3145        Path::new("/project/.git"),
 3146        false,
 3147        git::repository::Worktree {
 3148            path: std::path::PathBuf::from("/wt-feature-a"),
 3149            ref_name: Some("refs/heads/feature-a".into()),
 3150            sha: "aaa".into(),
 3151            is_main: false,
 3152            is_bare: false,
 3153        },
 3154    )
 3155    .await;
 3156    fs.add_linked_worktree_for_repo(
 3157        Path::new("/project/.git"),
 3158        false,
 3159        git::repository::Worktree {
 3160            path: std::path::PathBuf::from("/wt-feature-b"),
 3161            ref_name: Some("refs/heads/feature-b".into()),
 3162            sha: "bbb".into(),
 3163            is_main: false,
 3164            is_bare: false,
 3165        },
 3166    )
 3167    .await;
 3168
 3169    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3170
 3171    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3172    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3173
 3174    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3175    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3176
 3177    // Open both worktrees as workspaces — no main repo yet.
 3178    let (multi_workspace, cx) =
 3179        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3180    multi_workspace.update_in(cx, |mw, window, cx| {
 3181        mw.test_add_workspace(project_b.clone(), window, cx);
 3182    });
 3183    let sidebar = setup_sidebar(&multi_workspace, cx);
 3184
 3185    save_thread_metadata(
 3186        acp::SessionId::new(Arc::from("thread-a")),
 3187        Some("Thread A".into()),
 3188        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3189        None,
 3190        None,
 3191        &project_a,
 3192        cx,
 3193    );
 3194    save_thread_metadata(
 3195        acp::SessionId::new(Arc::from("thread-b")),
 3196        Some("Thread B".into()),
 3197        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 3198        None,
 3199        None,
 3200        &project_b,
 3201        cx,
 3202    );
 3203
 3204    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3205    cx.run_until_parked();
 3206
 3207    // Without the main repo, each worktree has its own header.
 3208    assert_eq!(
 3209        visible_entries_as_strings(&sidebar, cx),
 3210        vec![
 3211            //
 3212            "v [project]",
 3213            "  Thread B {wt-feature-b}",
 3214            "  Thread A {wt-feature-a}",
 3215        ]
 3216    );
 3217
 3218    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3219    main_project
 3220        .update(cx, |p, cx| p.git_scans_complete(cx))
 3221        .await;
 3222
 3223    multi_workspace.update_in(cx, |mw, window, cx| {
 3224        mw.test_add_workspace(main_project.clone(), window, cx);
 3225    });
 3226    cx.run_until_parked();
 3227
 3228    // Both worktree workspaces should now be absorbed under the main
 3229    // repo header, with worktree chips.
 3230    assert_eq!(
 3231        visible_entries_as_strings(&sidebar, cx),
 3232        vec![
 3233            //
 3234            "v [project]",
 3235            "  Thread B {wt-feature-b}",
 3236            "  Thread A {wt-feature-a}",
 3237        ]
 3238    );
 3239}
 3240
 3241#[gpui::test]
 3242async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
 3243    // When a group has two workspaces — one with threads and one
 3244    // without — the threadless workspace should appear as a
 3245    // "New Thread" button with its worktree chip.
 3246    init_test(cx);
 3247    let fs = FakeFs::new(cx.executor());
 3248
 3249    // Main repo with two linked worktrees.
 3250    fs.insert_tree(
 3251        "/project",
 3252        serde_json::json!({
 3253            ".git": {},
 3254            "src": {},
 3255        }),
 3256    )
 3257    .await;
 3258    fs.add_linked_worktree_for_repo(
 3259        Path::new("/project/.git"),
 3260        false,
 3261        git::repository::Worktree {
 3262            path: std::path::PathBuf::from("/wt-feature-a"),
 3263            ref_name: Some("refs/heads/feature-a".into()),
 3264            sha: "aaa".into(),
 3265            is_main: false,
 3266            is_bare: false,
 3267        },
 3268    )
 3269    .await;
 3270    fs.add_linked_worktree_for_repo(
 3271        Path::new("/project/.git"),
 3272        false,
 3273        git::repository::Worktree {
 3274            path: std::path::PathBuf::from("/wt-feature-b"),
 3275            ref_name: Some("refs/heads/feature-b".into()),
 3276            sha: "bbb".into(),
 3277            is_main: false,
 3278            is_bare: false,
 3279        },
 3280    )
 3281    .await;
 3282
 3283    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3284
 3285    // Workspace A: worktree feature-a (has threads).
 3286    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3287    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3288
 3289    // Workspace B: worktree feature-b (no threads).
 3290    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3291    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3292
 3293    let (multi_workspace, cx) =
 3294        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3295    multi_workspace.update_in(cx, |mw, window, cx| {
 3296        mw.test_add_workspace(project_b.clone(), window, cx);
 3297    });
 3298    let sidebar = setup_sidebar(&multi_workspace, cx);
 3299
 3300    // Only save a thread for workspace A.
 3301    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
 3302
 3303    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3304    cx.run_until_parked();
 3305
 3306    // Workspace A's thread appears normally. Workspace B (threadless)
 3307    // appears as a "New Thread" button with its worktree chip.
 3308    assert_eq!(
 3309        visible_entries_as_strings(&sidebar, cx),
 3310        vec!["v [project]", "  Thread A {wt-feature-a}",]
 3311    );
 3312}
 3313
 3314#[gpui::test]
 3315async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
 3316    // A thread created in a workspace with roots from different git
 3317    // worktrees should show a chip for each distinct worktree name.
 3318    init_test(cx);
 3319    let fs = FakeFs::new(cx.executor());
 3320
 3321    // Two main repos.
 3322    fs.insert_tree(
 3323        "/project_a",
 3324        serde_json::json!({
 3325            ".git": {},
 3326            "src": {},
 3327        }),
 3328    )
 3329    .await;
 3330    fs.insert_tree(
 3331        "/project_b",
 3332        serde_json::json!({
 3333            ".git": {},
 3334            "src": {},
 3335        }),
 3336    )
 3337    .await;
 3338
 3339    // Worktree checkouts.
 3340    for repo in &["project_a", "project_b"] {
 3341        let git_path = format!("/{repo}/.git");
 3342        for branch in &["olivetti", "selectric"] {
 3343            fs.add_linked_worktree_for_repo(
 3344                Path::new(&git_path),
 3345                false,
 3346                git::repository::Worktree {
 3347                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
 3348                    ref_name: Some(format!("refs/heads/{branch}").into()),
 3349                    sha: "aaa".into(),
 3350                    is_main: false,
 3351                    is_bare: false,
 3352                },
 3353            )
 3354            .await;
 3355        }
 3356    }
 3357
 3358    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3359
 3360    // Open a workspace with the worktree checkout paths as roots
 3361    // (this is the workspace the thread was created in).
 3362    let project = project::Project::test(
 3363        fs.clone(),
 3364        [
 3365            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3366            "/worktrees/project_b/selectric/project_b".as_ref(),
 3367        ],
 3368        cx,
 3369    )
 3370    .await;
 3371    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3372
 3373    let (multi_workspace, cx) =
 3374        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3375    let sidebar = setup_sidebar(&multi_workspace, cx);
 3376
 3377    // Save a thread under the same paths as the workspace roots.
 3378    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
 3379
 3380    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3381    cx.run_until_parked();
 3382
 3383    // Should show two distinct worktree chips.
 3384    assert_eq!(
 3385        visible_entries_as_strings(&sidebar, cx),
 3386        vec![
 3387            //
 3388            "v [project_a, project_b]",
 3389            "  Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
 3390        ]
 3391    );
 3392}
 3393
 3394#[gpui::test]
 3395async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
 3396    // When a thread's roots span multiple repos but share the same
 3397    // worktree name (e.g. both in "olivetti"), only one chip should
 3398    // appear.
 3399    init_test(cx);
 3400    let fs = FakeFs::new(cx.executor());
 3401
 3402    fs.insert_tree(
 3403        "/project_a",
 3404        serde_json::json!({
 3405            ".git": {},
 3406            "src": {},
 3407        }),
 3408    )
 3409    .await;
 3410    fs.insert_tree(
 3411        "/project_b",
 3412        serde_json::json!({
 3413            ".git": {},
 3414            "src": {},
 3415        }),
 3416    )
 3417    .await;
 3418
 3419    for repo in &["project_a", "project_b"] {
 3420        let git_path = format!("/{repo}/.git");
 3421        fs.add_linked_worktree_for_repo(
 3422            Path::new(&git_path),
 3423            false,
 3424            git::repository::Worktree {
 3425                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
 3426                ref_name: Some("refs/heads/olivetti".into()),
 3427                sha: "aaa".into(),
 3428                is_main: false,
 3429                is_bare: false,
 3430            },
 3431        )
 3432        .await;
 3433    }
 3434
 3435    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3436
 3437    let project = project::Project::test(
 3438        fs.clone(),
 3439        [
 3440            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3441            "/worktrees/project_b/olivetti/project_b".as_ref(),
 3442        ],
 3443        cx,
 3444    )
 3445    .await;
 3446    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3447
 3448    let (multi_workspace, cx) =
 3449        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3450    let sidebar = setup_sidebar(&multi_workspace, cx);
 3451
 3452    // Thread with roots in both repos' "olivetti" worktrees.
 3453    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
 3454
 3455    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3456    cx.run_until_parked();
 3457
 3458    // Both worktree paths have the name "olivetti", so only one chip.
 3459    assert_eq!(
 3460        visible_entries_as_strings(&sidebar, cx),
 3461        vec![
 3462            //
 3463            "v [project_a, project_b]",
 3464            "  Same Branch Thread {olivetti}",
 3465        ]
 3466    );
 3467}
 3468
 3469#[gpui::test]
 3470async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
 3471    // When a worktree workspace is absorbed under the main repo, a
 3472    // running thread in the worktree's agent panel should still show
 3473    // live status (spinner + "(running)") in the sidebar.
 3474    agent_ui::test_support::init_test(cx);
 3475    cx.update(|cx| {
 3476        ThreadStore::init_global(cx);
 3477        ThreadMetadataStore::init_global(cx);
 3478        language_model::LanguageModelRegistry::test(cx);
 3479        prompt_store::init(cx);
 3480    });
 3481
 3482    let fs = FakeFs::new(cx.executor());
 3483
 3484    // Main repo with a linked worktree.
 3485    fs.insert_tree(
 3486        "/project",
 3487        serde_json::json!({
 3488            ".git": {},
 3489            "src": {},
 3490        }),
 3491    )
 3492    .await;
 3493
 3494    // Worktree checkout pointing back to the main repo.
 3495    fs.add_linked_worktree_for_repo(
 3496        Path::new("/project/.git"),
 3497        false,
 3498        git::repository::Worktree {
 3499            path: std::path::PathBuf::from("/wt-feature-a"),
 3500            ref_name: Some("refs/heads/feature-a".into()),
 3501            sha: "aaa".into(),
 3502            is_main: false,
 3503            is_bare: false,
 3504        },
 3505    )
 3506    .await;
 3507
 3508    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3509
 3510    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3511    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3512
 3513    main_project
 3514        .update(cx, |p, cx| p.git_scans_complete(cx))
 3515        .await;
 3516    worktree_project
 3517        .update(cx, |p, cx| p.git_scans_complete(cx))
 3518        .await;
 3519
 3520    // Create the MultiWorkspace with both projects.
 3521    let (multi_workspace, cx) =
 3522        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3523
 3524    let sidebar = setup_sidebar(&multi_workspace, cx);
 3525
 3526    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3527        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3528    });
 3529
 3530    // Add an agent panel to the worktree workspace so we can run a
 3531    // thread inside it.
 3532    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3533
 3534    // Switch back to the main workspace before setting up the sidebar.
 3535    multi_workspace.update_in(cx, |mw, window, cx| {
 3536        let workspace = mw.workspaces().next().unwrap().clone();
 3537        mw.activate(workspace, None, window, cx);
 3538    });
 3539
 3540    // Start a thread in the worktree workspace's panel and keep it
 3541    // generating (don't resolve it).
 3542    let connection = StubAgentConnection::new();
 3543    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 3544    send_message(&worktree_panel, cx);
 3545
 3546    let session_id = active_session_id(&worktree_panel, cx);
 3547
 3548    // Save metadata so the sidebar knows about this thread.
 3549    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3550
 3551    // Keep the thread generating by sending a chunk without ending
 3552    // the turn.
 3553    cx.update(|_, cx| {
 3554        connection.send_update(
 3555            session_id.clone(),
 3556            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 3557            cx,
 3558        );
 3559    });
 3560    cx.run_until_parked();
 3561
 3562    // The worktree thread should be absorbed under the main project
 3563    // and show live running status.
 3564    let entries = visible_entries_as_strings(&sidebar, cx);
 3565    assert_eq!(
 3566        entries,
 3567        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 3568    );
 3569}
 3570
 3571#[gpui::test]
 3572async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
 3573    agent_ui::test_support::init_test(cx);
 3574    cx.update(|cx| {
 3575        ThreadStore::init_global(cx);
 3576        ThreadMetadataStore::init_global(cx);
 3577        language_model::LanguageModelRegistry::test(cx);
 3578        prompt_store::init(cx);
 3579    });
 3580
 3581    let fs = FakeFs::new(cx.executor());
 3582
 3583    fs.insert_tree(
 3584        "/project",
 3585        serde_json::json!({
 3586            ".git": {},
 3587            "src": {},
 3588        }),
 3589    )
 3590    .await;
 3591
 3592    fs.add_linked_worktree_for_repo(
 3593        Path::new("/project/.git"),
 3594        false,
 3595        git::repository::Worktree {
 3596            path: std::path::PathBuf::from("/wt-feature-a"),
 3597            ref_name: Some("refs/heads/feature-a".into()),
 3598            sha: "aaa".into(),
 3599            is_main: false,
 3600            is_bare: false,
 3601        },
 3602    )
 3603    .await;
 3604
 3605    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3606
 3607    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3608    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3609
 3610    main_project
 3611        .update(cx, |p, cx| p.git_scans_complete(cx))
 3612        .await;
 3613    worktree_project
 3614        .update(cx, |p, cx| p.git_scans_complete(cx))
 3615        .await;
 3616
 3617    let (multi_workspace, cx) =
 3618        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3619
 3620    let sidebar = setup_sidebar(&multi_workspace, cx);
 3621
 3622    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3623        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3624    });
 3625
 3626    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3627
 3628    multi_workspace.update_in(cx, |mw, window, cx| {
 3629        let workspace = mw.workspaces().next().unwrap().clone();
 3630        mw.activate(workspace, None, window, cx);
 3631    });
 3632
 3633    let connection = StubAgentConnection::new();
 3634    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 3635    send_message(&worktree_panel, cx);
 3636
 3637    let session_id = active_session_id(&worktree_panel, cx);
 3638    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3639
 3640    cx.update(|_, cx| {
 3641        connection.send_update(
 3642            session_id.clone(),
 3643            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 3644            cx,
 3645        );
 3646    });
 3647    cx.run_until_parked();
 3648
 3649    assert_eq!(
 3650        visible_entries_as_strings(&sidebar, cx),
 3651        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 3652    );
 3653
 3654    connection.end_turn(session_id, acp::StopReason::EndTurn);
 3655    cx.run_until_parked();
 3656
 3657    assert_eq!(
 3658        visible_entries_as_strings(&sidebar, cx),
 3659        vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
 3660    );
 3661}
 3662
 3663#[gpui::test]
 3664async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
 3665    init_test(cx);
 3666    let fs = FakeFs::new(cx.executor());
 3667
 3668    fs.insert_tree(
 3669        "/project",
 3670        serde_json::json!({
 3671            ".git": {},
 3672            "src": {},
 3673        }),
 3674    )
 3675    .await;
 3676
 3677    fs.add_linked_worktree_for_repo(
 3678        Path::new("/project/.git"),
 3679        false,
 3680        git::repository::Worktree {
 3681            path: std::path::PathBuf::from("/wt-feature-a"),
 3682            ref_name: Some("refs/heads/feature-a".into()),
 3683            sha: "aaa".into(),
 3684            is_main: false,
 3685            is_bare: false,
 3686        },
 3687    )
 3688    .await;
 3689
 3690    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3691
 3692    // Only open the main repo — no workspace for the worktree.
 3693    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3694    main_project
 3695        .update(cx, |p, cx| p.git_scans_complete(cx))
 3696        .await;
 3697
 3698    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3699    worktree_project
 3700        .update(cx, |p, cx| p.git_scans_complete(cx))
 3701        .await;
 3702
 3703    let (multi_workspace, cx) =
 3704        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3705    let sidebar = setup_sidebar(&multi_workspace, cx);
 3706
 3707    // Save a thread for the worktree path (no workspace for it).
 3708    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 3709
 3710    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3711    cx.run_until_parked();
 3712
 3713    // Thread should appear under the main repo with a worktree chip.
 3714    assert_eq!(
 3715        visible_entries_as_strings(&sidebar, cx),
 3716        vec![
 3717            //
 3718            "v [project]",
 3719            "  WT Thread {wt-feature-a}",
 3720        ],
 3721    );
 3722
 3723    // Only 1 workspace should exist.
 3724    assert_eq!(
 3725        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 3726        1,
 3727    );
 3728
 3729    // Focus the sidebar and select the worktree thread.
 3730    focus_sidebar(&sidebar, cx);
 3731    sidebar.update_in(cx, |sidebar, _window, _cx| {
 3732        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 3733    });
 3734
 3735    // Confirm to open the worktree thread.
 3736    cx.dispatch_action(Confirm);
 3737    cx.run_until_parked();
 3738
 3739    // A new workspace should have been created for the worktree path.
 3740    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
 3741        assert_eq!(
 3742            mw.workspaces().count(),
 3743            2,
 3744            "confirming a worktree thread without a workspace should open one",
 3745        );
 3746        mw.workspaces().nth(1).unwrap().clone()
 3747    });
 3748
 3749    let new_path_list =
 3750        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 3751    assert_eq!(
 3752        new_path_list,
 3753        PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
 3754        "the new workspace should have been opened for the worktree path",
 3755    );
 3756}
 3757
 3758#[gpui::test]
 3759async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
 3760    cx: &mut TestAppContext,
 3761) {
 3762    init_test(cx);
 3763    let fs = FakeFs::new(cx.executor());
 3764
 3765    fs.insert_tree(
 3766        "/project",
 3767        serde_json::json!({
 3768            ".git": {},
 3769            "src": {},
 3770        }),
 3771    )
 3772    .await;
 3773
 3774    fs.add_linked_worktree_for_repo(
 3775        Path::new("/project/.git"),
 3776        false,
 3777        git::repository::Worktree {
 3778            path: std::path::PathBuf::from("/wt-feature-a"),
 3779            ref_name: Some("refs/heads/feature-a".into()),
 3780            sha: "aaa".into(),
 3781            is_main: false,
 3782            is_bare: false,
 3783        },
 3784    )
 3785    .await;
 3786
 3787    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3788
 3789    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3790    main_project
 3791        .update(cx, |p, cx| p.git_scans_complete(cx))
 3792        .await;
 3793
 3794    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3795    worktree_project
 3796        .update(cx, |p, cx| p.git_scans_complete(cx))
 3797        .await;
 3798
 3799    let (multi_workspace, cx) =
 3800        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3801    let sidebar = setup_sidebar(&multi_workspace, cx);
 3802
 3803    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 3804
 3805    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3806    cx.run_until_parked();
 3807
 3808    assert_eq!(
 3809        visible_entries_as_strings(&sidebar, cx),
 3810        vec![
 3811            //
 3812            "v [project]",
 3813            "  WT Thread {wt-feature-a}",
 3814        ],
 3815    );
 3816
 3817    focus_sidebar(&sidebar, cx);
 3818    sidebar.update_in(cx, |sidebar, _window, _cx| {
 3819        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 3820    });
 3821
 3822    let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
 3823        let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
 3824            if let ListEntry::ProjectHeader { label, .. } = entry {
 3825                Some(label.as_ref())
 3826            } else {
 3827                None
 3828            }
 3829        });
 3830
 3831        let Some(project_header) = project_headers.next() else {
 3832            panic!("expected exactly one sidebar project header named `project`, found none");
 3833        };
 3834        assert_eq!(
 3835            project_header, "project",
 3836            "expected the only sidebar project header to be `project`"
 3837        );
 3838        if let Some(unexpected_header) = project_headers.next() {
 3839            panic!(
 3840                "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
 3841            );
 3842        }
 3843
 3844        let mut saw_expected_thread = false;
 3845        for entry in &sidebar.contents.entries {
 3846            match entry {
 3847                ListEntry::ProjectHeader { label, .. } => {
 3848                    assert_eq!(
 3849                        label.as_ref(),
 3850                        "project",
 3851                        "expected the only sidebar project header to be `project`"
 3852                    );
 3853                }
 3854                ListEntry::Thread(thread)
 3855                    if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
 3856                        && thread
 3857                            .worktrees
 3858                            .first()
 3859                            .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
 3860                            == Some("wt-feature-a") =>
 3861                {
 3862                    saw_expected_thread = true;
 3863                }
 3864                ListEntry::Thread(thread) => {
 3865                    let title = thread.metadata.display_title();
 3866                    let worktree_name = thread
 3867                        .worktrees
 3868                        .first()
 3869                        .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
 3870                        .unwrap_or("<none>");
 3871                    panic!(
 3872                        "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
 3873                        title, worktree_name
 3874                    );
 3875                }
 3876            }
 3877        }
 3878
 3879        assert!(
 3880            saw_expected_thread,
 3881            "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
 3882        );
 3883    };
 3884
 3885    sidebar
 3886        .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
 3887        .detach();
 3888
 3889    let window = cx.windows()[0];
 3890    cx.update_window(window, |_, window, cx| {
 3891        window.dispatch_action(Confirm.boxed_clone(), cx);
 3892    })
 3893    .unwrap();
 3894
 3895    cx.run_until_parked();
 3896
 3897    sidebar.update(cx, assert_sidebar_state);
 3898}
 3899
 3900#[gpui::test]
 3901async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
 3902    cx: &mut TestAppContext,
 3903) {
 3904    init_test(cx);
 3905    let fs = FakeFs::new(cx.executor());
 3906
 3907    fs.insert_tree(
 3908        "/project",
 3909        serde_json::json!({
 3910            ".git": {},
 3911            "src": {},
 3912        }),
 3913    )
 3914    .await;
 3915
 3916    fs.add_linked_worktree_for_repo(
 3917        Path::new("/project/.git"),
 3918        false,
 3919        git::repository::Worktree {
 3920            path: std::path::PathBuf::from("/wt-feature-a"),
 3921            ref_name: Some("refs/heads/feature-a".into()),
 3922            sha: "aaa".into(),
 3923            is_main: false,
 3924            is_bare: false,
 3925        },
 3926    )
 3927    .await;
 3928
 3929    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3930
 3931    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3932    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3933
 3934    main_project
 3935        .update(cx, |p, cx| p.git_scans_complete(cx))
 3936        .await;
 3937    worktree_project
 3938        .update(cx, |p, cx| p.git_scans_complete(cx))
 3939        .await;
 3940
 3941    let (multi_workspace, cx) =
 3942        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3943
 3944    let sidebar = setup_sidebar(&multi_workspace, cx);
 3945
 3946    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3947        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3948    });
 3949
 3950    // Activate the main workspace before setting up the sidebar.
 3951    let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3952        let workspace = mw.workspaces().next().unwrap().clone();
 3953        mw.activate(workspace.clone(), None, window, cx);
 3954        workspace
 3955    });
 3956
 3957    save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
 3958    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 3959
 3960    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3961    cx.run_until_parked();
 3962
 3963    // The worktree workspace should be absorbed under the main repo.
 3964    let entries = visible_entries_as_strings(&sidebar, cx);
 3965    assert_eq!(entries.len(), 3);
 3966    assert_eq!(entries[0], "v [project]");
 3967    assert!(entries.contains(&"  Main Thread".to_string()));
 3968    assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
 3969
 3970    let wt_thread_index = entries
 3971        .iter()
 3972        .position(|e| e.contains("WT Thread"))
 3973        .expect("should find the worktree thread entry");
 3974
 3975    assert_eq!(
 3976        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 3977        main_workspace,
 3978        "main workspace should be active initially"
 3979    );
 3980
 3981    // Focus the sidebar and select the absorbed worktree thread.
 3982    focus_sidebar(&sidebar, cx);
 3983    sidebar.update_in(cx, |sidebar, _window, _cx| {
 3984        sidebar.selection = Some(wt_thread_index);
 3985    });
 3986
 3987    // Confirm to activate the worktree thread.
 3988    cx.dispatch_action(Confirm);
 3989    cx.run_until_parked();
 3990
 3991    // The worktree workspace should now be active, not the main one.
 3992    let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 3993    assert_eq!(
 3994        active_workspace, worktree_workspace,
 3995        "clicking an absorbed worktree thread should activate the worktree workspace"
 3996    );
 3997}
 3998
 3999// Reproduces the core of the user-reported bug: a thread belonging to
 4000// a multi-root workspace that mixes a standalone project and a linked
 4001// git worktree can become invisible in the sidebar when its stored
 4002// `main_worktree_paths` don't match the workspace's project group
 4003// key. The metadata still exists and Thread History still shows it,
 4004// but the sidebar rebuild's lookups all miss.
 4005//
 4006// Real-world setup: a single multi-root workspace whose roots are
 4007// `[/cloud, /worktrees/zed/wt_a/zed]`, where:
 4008//   - `/cloud` is a standalone git repo (main == folder).
 4009//   - `/worktrees/zed/wt_a/zed` is a linked worktree of `/zed`.
 4010//
 4011// Once git scans complete the project group key is
 4012// `[/cloud, /zed]` — the main paths of the two roots. A thread
 4013// created in this workspace is written with
 4014// `main=[/cloud, /zed], folder=[/cloud, /worktrees/zed/wt_a/zed]`
 4015// and the sidebar finds it via `entries_for_main_worktree_path`.
 4016//
 4017// If some other code path (stale data on reload, a path-less archive
 4018// restored via the project picker, a legacy write …) persists the
 4019// thread with `main == folder` instead, the stored
 4020// `main_worktree_paths` is
 4021// `[/cloud, /worktrees/zed/wt_a/zed]` ≠ `[/cloud, /zed]`. The three
 4022// lookups in `rebuild_contents` all miss:
 4023//
 4024//   1. `entries_for_main_worktree_path([/cloud, /zed])` — the
 4025//      thread's stored main doesn't equal the group key.
 4026//   2. `entries_for_path([/cloud, /zed])` — the thread's folder paths
 4027//      don't equal the group key either.
 4028//   3. The linked-worktree fallback iterates the group's workspaces'
 4029//      `linked_worktrees()` snapshots. Those yield *sibling* linked
 4030//      worktrees of the repo, not the workspace's own roots, so the
 4031//      thread's folder `/worktrees/zed/wt_a/zed` doesn't match.
 4032//
 4033// The row falls out of the sidebar entirely — matching the user's
 4034// symptom of a thread visible in the agent panel but missing from
 4035// the sidebar. It only reappears once something re-writes the
 4036// thread's metadata in the good shape (e.g. `handle_conversation_event`
 4037// firing after the user sends a message).
 4038//
 4039// We directly persist the bad shape via `store.save(...)` rather
 4040// than trying to reproduce the original writer. The bug is
 4041// ultimately about the sidebar's tolerance for any stale row whose
 4042// folder paths correspond to an open workspace's roots, regardless
 4043// of how that row came to be in the store.
 4044#[gpui::test]
 4045async fn test_sidebar_keeps_multi_root_thread_with_stale_main_paths(cx: &mut TestAppContext) {
 4046    agent_ui::test_support::init_test(cx);
 4047    cx.update(|cx| {
 4048        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 4049        ThreadStore::init_global(cx);
 4050        ThreadMetadataStore::init_global(cx);
 4051        language_model::LanguageModelRegistry::test(cx);
 4052        prompt_store::init(cx);
 4053    });
 4054
 4055    let fs = FakeFs::new(cx.executor());
 4056
 4057    // Standalone repo — one of the workspace's two roots, main
 4058    // worktree of its own .git.
 4059    fs.insert_tree(
 4060        "/cloud",
 4061        serde_json::json!({
 4062            ".git": {},
 4063            "src": {},
 4064        }),
 4065    )
 4066    .await;
 4067
 4068    // Separate /zed repo whose linked worktree will form the second
 4069    // workspace root. /zed itself is NOT opened as a workspace root.
 4070    fs.insert_tree(
 4071        "/zed",
 4072        serde_json::json!({
 4073            ".git": {},
 4074            "src": {},
 4075        }),
 4076    )
 4077    .await;
 4078    fs.insert_tree(
 4079        "/worktrees/zed/wt_a/zed",
 4080        serde_json::json!({
 4081            ".git": "gitdir: /zed/.git/worktrees/wt_a",
 4082            "src": {},
 4083        }),
 4084    )
 4085    .await;
 4086    fs.add_linked_worktree_for_repo(
 4087        Path::new("/zed/.git"),
 4088        false,
 4089        git::repository::Worktree {
 4090            path: std::path::PathBuf::from("/worktrees/zed/wt_a/zed"),
 4091            ref_name: Some("refs/heads/wt_a".into()),
 4092            sha: "aaa".into(),
 4093            is_main: false,
 4094            is_bare: false,
 4095        },
 4096    )
 4097    .await;
 4098
 4099    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4100
 4101    // Single multi-root project with both /cloud and the linked
 4102    // worktree of /zed.
 4103    let project = project::Project::test(
 4104        fs.clone(),
 4105        ["/cloud".as_ref(), "/worktrees/zed/wt_a/zed".as_ref()],
 4106        cx,
 4107    )
 4108    .await;
 4109    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 4110
 4111    let (multi_workspace, cx) =
 4112        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 4113    let sidebar = setup_sidebar(&multi_workspace, cx);
 4114    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4115    let _panel = add_agent_panel(&workspace, cx);
 4116    cx.run_until_parked();
 4117
 4118    // Sanity-check the shapes the rest of the test depends on.
 4119    let group_key = workspace.read_with(cx, |ws, cx| ws.project_group_key(cx));
 4120    let expected_main_paths = PathList::new(&[PathBuf::from("/cloud"), PathBuf::from("/zed")]);
 4121    assert_eq!(
 4122        group_key.path_list(),
 4123        &expected_main_paths,
 4124        "expected the multi-root workspace's project group key to normalize to \
 4125         [/cloud, /zed] (main of the standalone repo + main of the linked worktree)"
 4126    );
 4127
 4128    let folder_paths = PathList::new(&[
 4129        PathBuf::from("/cloud"),
 4130        PathBuf::from("/worktrees/zed/wt_a/zed"),
 4131    ]);
 4132    let workspace_root_paths = workspace.read_with(cx, |ws, cx| PathList::new(&ws.root_paths(cx)));
 4133    assert_eq!(
 4134        workspace_root_paths, folder_paths,
 4135        "expected the workspace's root paths to equal [/cloud, /worktrees/zed/wt_a/zed]"
 4136    );
 4137
 4138    let session_id = acp::SessionId::new(Arc::from("multi-root-stale-paths"));
 4139    let thread_id = ThreadId::new();
 4140
 4141    // Persist the thread in the "bad" shape that the bug manifests as:
 4142    // main == folder for every root. Any stale row where
 4143    // `main_worktree_paths` no longer equals the group key produces
 4144    // the same user-visible symptom; this is the concrete shape
 4145    // produced by `WorktreePaths::from_folder_paths` on the workspace
 4146    // roots.
 4147    cx.update(|_, cx| {
 4148        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 4149            store.save(
 4150                ThreadMetadata {
 4151                    thread_id,
 4152                    session_id: Some(session_id.clone()),
 4153                    agent_id: agent::ZED_AGENT_ID.clone(),
 4154                    title: Some("Stale Multi-Root Thread".into()),
 4155                    updated_at: Utc::now(),
 4156                    created_at: None,
 4157                    interacted_at: None,
 4158                    worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
 4159                    archived: false,
 4160                    remote_connection: None,
 4161                },
 4162                cx,
 4163            )
 4164        });
 4165    });
 4166    cx.run_until_parked();
 4167
 4168    let entries = visible_entries_as_strings(&sidebar, cx);
 4169    let visible = sidebar.read_with(cx, |sidebar, _cx| has_thread_entry(sidebar, &session_id));
 4170
 4171    // If this assert fails, we've reproduced the bug: the sidebar's
 4172    // rebuild queries can't locate the thread under the current
 4173    // project group, even though the metadata is intact and the
 4174    // thread's folder paths exactly equal the open workspace's roots.
 4175    assert!(
 4176        visible,
 4177        "thread disappeared from the sidebar when its main_worktree_paths \
 4178         ({folder_paths:?}) diverged from the project group key ({expected_main_paths:?}); \
 4179         sidebar entries: {entries:?}"
 4180    );
 4181}
 4182
 4183#[gpui::test]
 4184async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
 4185    cx: &mut TestAppContext,
 4186) {
 4187    // Thread has saved metadata in ThreadStore. A matching workspace is
 4188    // already open. Expected: activates the matching workspace.
 4189    init_test(cx);
 4190    let fs = FakeFs::new(cx.executor());
 4191    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4192        .await;
 4193    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4194        .await;
 4195    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4196
 4197    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4198    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4199
 4200    let (multi_workspace, cx) =
 4201        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 4202
 4203    let sidebar = setup_sidebar(&multi_workspace, cx);
 4204
 4205    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4206        mw.test_add_workspace(project_b.clone(), window, cx)
 4207    });
 4208    let workspace_a =
 4209        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4210
 4211    // Save a thread with path_list pointing to project-b.
 4212    let session_id = acp::SessionId::new(Arc::from("archived-1"));
 4213    save_test_thread_metadata(&session_id, &project_b, cx).await;
 4214
 4215    // Ensure workspace A is active.
 4216    multi_workspace.update_in(cx, |mw, window, cx| {
 4217        let workspace = mw.workspaces().next().unwrap().clone();
 4218        mw.activate(workspace, None, window, cx);
 4219    });
 4220    cx.run_until_parked();
 4221    assert_eq!(
 4222        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4223        workspace_a
 4224    );
 4225
 4226    // Call activate_archived_thread – should resolve saved paths and
 4227    // switch to the workspace for project-b.
 4228    sidebar.update_in(cx, |sidebar, window, cx| {
 4229        sidebar.open_thread_from_archive(
 4230            ThreadMetadata {
 4231                thread_id: ThreadId::new(),
 4232                session_id: Some(session_id.clone()),
 4233                agent_id: agent::ZED_AGENT_ID.clone(),
 4234                title: Some("Archived Thread".into()),
 4235                updated_at: Utc::now(),
 4236                created_at: None,
 4237                interacted_at: None,
 4238                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4239                    "/project-b",
 4240                )])),
 4241                archived: false,
 4242                remote_connection: None,
 4243            },
 4244            window,
 4245            cx,
 4246        );
 4247    });
 4248    cx.run_until_parked();
 4249
 4250    assert_eq!(
 4251        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4252        workspace_b,
 4253        "should have switched to the workspace matching the saved paths"
 4254    );
 4255}
 4256
 4257#[gpui::test]
 4258async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
 4259    cx: &mut TestAppContext,
 4260) {
 4261    // Thread has no saved metadata but session_info has cwd. A matching
 4262    // workspace is open. Expected: uses cwd to find and activate it.
 4263    init_test(cx);
 4264    let fs = FakeFs::new(cx.executor());
 4265    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4266        .await;
 4267    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4268        .await;
 4269    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4270
 4271    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4272    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4273
 4274    let (multi_workspace, cx) =
 4275        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4276
 4277    let sidebar = setup_sidebar(&multi_workspace, cx);
 4278
 4279    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4280        mw.test_add_workspace(project_b, window, cx)
 4281    });
 4282    let workspace_a =
 4283        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4284
 4285    // Start with workspace A active.
 4286    multi_workspace.update_in(cx, |mw, window, cx| {
 4287        let workspace = mw.workspaces().next().unwrap().clone();
 4288        mw.activate(workspace, None, window, cx);
 4289    });
 4290    cx.run_until_parked();
 4291    assert_eq!(
 4292        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4293        workspace_a
 4294    );
 4295
 4296    // No thread saved to the store – cwd is the only path hint.
 4297    sidebar.update_in(cx, |sidebar, window, cx| {
 4298        sidebar.open_thread_from_archive(
 4299            ThreadMetadata {
 4300                thread_id: ThreadId::new(),
 4301                session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
 4302                agent_id: agent::ZED_AGENT_ID.clone(),
 4303                title: Some("CWD Thread".into()),
 4304                updated_at: Utc::now(),
 4305                created_at: None,
 4306                interacted_at: None,
 4307                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 4308                    std::path::PathBuf::from("/project-b"),
 4309                ])),
 4310                archived: false,
 4311                remote_connection: None,
 4312            },
 4313            window,
 4314            cx,
 4315        );
 4316    });
 4317    cx.run_until_parked();
 4318
 4319    assert_eq!(
 4320        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4321        workspace_b,
 4322        "should have activated the workspace matching the cwd"
 4323    );
 4324}
 4325
 4326#[gpui::test]
 4327async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
 4328    cx: &mut TestAppContext,
 4329) {
 4330    // Thread has no saved metadata and no cwd. Expected: falls back to
 4331    // the currently active workspace.
 4332    init_test(cx);
 4333    let fs = FakeFs::new(cx.executor());
 4334    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4335        .await;
 4336    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4337        .await;
 4338    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4339
 4340    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4341    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4342
 4343    let (multi_workspace, cx) =
 4344        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4345
 4346    let sidebar = setup_sidebar(&multi_workspace, cx);
 4347
 4348    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4349        mw.test_add_workspace(project_b, window, cx)
 4350    });
 4351
 4352    // Activate workspace B (index 1) to make it the active one.
 4353    multi_workspace.update_in(cx, |mw, window, cx| {
 4354        let workspace = mw.workspaces().nth(1).unwrap().clone();
 4355        mw.activate(workspace, None, window, cx);
 4356    });
 4357    cx.run_until_parked();
 4358    assert_eq!(
 4359        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4360        workspace_b
 4361    );
 4362
 4363    // No saved thread, no cwd – should fall back to the active workspace.
 4364    sidebar.update_in(cx, |sidebar, window, cx| {
 4365        sidebar.open_thread_from_archive(
 4366            ThreadMetadata {
 4367                thread_id: ThreadId::new(),
 4368                session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
 4369                agent_id: agent::ZED_AGENT_ID.clone(),
 4370                title: Some("Contextless Thread".into()),
 4371                updated_at: Utc::now(),
 4372                created_at: None,
 4373                interacted_at: None,
 4374                worktree_paths: WorktreePaths::default(),
 4375                archived: false,
 4376                remote_connection: None,
 4377            },
 4378            window,
 4379            cx,
 4380        );
 4381    });
 4382    cx.run_until_parked();
 4383
 4384    assert_eq!(
 4385        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4386        workspace_b,
 4387        "should have stayed on the active workspace when no path info is available"
 4388    );
 4389}
 4390
 4391#[gpui::test]
 4392async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
 4393    // Thread has saved metadata pointing to a path with no open workspace.
 4394    // Expected: opens a new workspace for that path.
 4395    init_test(cx);
 4396    let fs = FakeFs::new(cx.executor());
 4397    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4398        .await;
 4399    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4400        .await;
 4401    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4402
 4403    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4404
 4405    let (multi_workspace, cx) =
 4406        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4407
 4408    let sidebar = setup_sidebar(&multi_workspace, cx);
 4409
 4410    // Save a thread with path_list pointing to project-b – which has no
 4411    // open workspace.
 4412    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 4413    let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
 4414
 4415    assert_eq!(
 4416        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4417        1,
 4418        "should start with one workspace"
 4419    );
 4420
 4421    sidebar.update_in(cx, |sidebar, window, cx| {
 4422        sidebar.open_thread_from_archive(
 4423            ThreadMetadata {
 4424                thread_id: ThreadId::new(),
 4425                session_id: Some(session_id.clone()),
 4426                agent_id: agent::ZED_AGENT_ID.clone(),
 4427                title: Some("New WS Thread".into()),
 4428                updated_at: Utc::now(),
 4429                created_at: None,
 4430                interacted_at: None,
 4431                worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 4432                archived: false,
 4433                remote_connection: None,
 4434            },
 4435            window,
 4436            cx,
 4437        );
 4438    });
 4439    cx.run_until_parked();
 4440
 4441    assert_eq!(
 4442        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4443        2,
 4444        "should have opened a second workspace for the archived thread's saved paths"
 4445    );
 4446}
 4447
 4448#[gpui::test]
 4449async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
 4450    init_test(cx);
 4451    let fs = FakeFs::new(cx.executor());
 4452    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4453        .await;
 4454    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4455        .await;
 4456    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4457
 4458    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4459    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4460
 4461    let multi_workspace_a =
 4462        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4463    let multi_workspace_b =
 4464        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4465
 4466    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4467    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4468
 4469    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4470    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4471
 4472    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4473    let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4474
 4475    let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
 4476
 4477    sidebar.update_in(cx_a, |sidebar, window, cx| {
 4478        sidebar.open_thread_from_archive(
 4479            ThreadMetadata {
 4480                thread_id: ThreadId::new(),
 4481                session_id: Some(session_id.clone()),
 4482                agent_id: agent::ZED_AGENT_ID.clone(),
 4483                title: Some("Cross Window Thread".into()),
 4484                updated_at: Utc::now(),
 4485                created_at: None,
 4486                interacted_at: None,
 4487                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4488                    "/project-b",
 4489                )])),
 4490                archived: false,
 4491                remote_connection: None,
 4492            },
 4493            window,
 4494            cx,
 4495        );
 4496    });
 4497    cx_a.run_until_parked();
 4498
 4499    assert_eq!(
 4500        multi_workspace_a
 4501            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4502            .unwrap(),
 4503        1,
 4504        "should not add the other window's workspace into the current window"
 4505    );
 4506    assert_eq!(
 4507        multi_workspace_b
 4508            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4509            .unwrap(),
 4510        1,
 4511        "should reuse the existing workspace in the other window"
 4512    );
 4513    assert!(
 4514        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4515        "should activate the window that already owns the matching workspace"
 4516    );
 4517    sidebar.read_with(cx_a, |sidebar, _| {
 4518            assert!(
 4519                !is_active_session(&sidebar, &session_id),
 4520                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4521            );
 4522        });
 4523}
 4524
 4525#[gpui::test]
 4526async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
 4527    cx: &mut TestAppContext,
 4528) {
 4529    init_test(cx);
 4530    let fs = FakeFs::new(cx.executor());
 4531    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4532        .await;
 4533    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4534        .await;
 4535    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4536
 4537    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4538    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4539
 4540    let multi_workspace_a =
 4541        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4542    let multi_workspace_b =
 4543        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
 4544
 4545    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4546    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4547
 4548    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4549    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4550
 4551    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4552    let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4553    let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
 4554    let _panel_b = add_agent_panel(&workspace_b, cx_b);
 4555
 4556    let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
 4557
 4558    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4559        sidebar.open_thread_from_archive(
 4560            ThreadMetadata {
 4561                thread_id: ThreadId::new(),
 4562                session_id: Some(session_id.clone()),
 4563                agent_id: agent::ZED_AGENT_ID.clone(),
 4564                title: Some("Cross Window Thread".into()),
 4565                updated_at: Utc::now(),
 4566                created_at: None,
 4567                interacted_at: None,
 4568                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4569                    "/project-b",
 4570                )])),
 4571                archived: false,
 4572                remote_connection: None,
 4573            },
 4574            window,
 4575            cx,
 4576        );
 4577    });
 4578    cx_a.run_until_parked();
 4579
 4580    assert_eq!(
 4581        multi_workspace_a
 4582            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4583            .unwrap(),
 4584        1,
 4585        "should not add the other window's workspace into the current window"
 4586    );
 4587    assert_eq!(
 4588        multi_workspace_b
 4589            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4590            .unwrap(),
 4591        1,
 4592        "should reuse the existing workspace in the other window"
 4593    );
 4594    assert!(
 4595        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4596        "should activate the window that already owns the matching workspace"
 4597    );
 4598    sidebar_a.read_with(cx_a, |sidebar, _| {
 4599            assert!(
 4600                !is_active_session(&sidebar, &session_id),
 4601                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4602            );
 4603        });
 4604    sidebar_b.read_with(cx_b, |sidebar, _| {
 4605        assert_active_thread(
 4606            sidebar,
 4607            &session_id,
 4608            "target window's sidebar should eagerly focus the activated archived thread",
 4609        );
 4610    });
 4611}
 4612
 4613#[gpui::test]
 4614async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
 4615    cx: &mut TestAppContext,
 4616) {
 4617    init_test(cx);
 4618    let fs = FakeFs::new(cx.executor());
 4619    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4620        .await;
 4621    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4622
 4623    let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4624    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4625
 4626    let multi_workspace_b =
 4627        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4628    let multi_workspace_a =
 4629        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4630
 4631    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4632    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4633
 4634    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4635    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4636
 4637    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4638    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4639
 4640    let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
 4641
 4642    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4643        sidebar.open_thread_from_archive(
 4644            ThreadMetadata {
 4645                thread_id: ThreadId::new(),
 4646                session_id: Some(session_id.clone()),
 4647                agent_id: agent::ZED_AGENT_ID.clone(),
 4648                title: Some("Current Window Thread".into()),
 4649                updated_at: Utc::now(),
 4650                created_at: None,
 4651                interacted_at: None,
 4652                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4653                    "/project-a",
 4654                )])),
 4655                archived: false,
 4656                remote_connection: None,
 4657            },
 4658            window,
 4659            cx,
 4660        );
 4661    });
 4662    cx_a.run_until_parked();
 4663
 4664    assert!(
 4665        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
 4666        "should keep activation in the current window when it already has a matching workspace"
 4667    );
 4668    sidebar_a.read_with(cx_a, |sidebar, _| {
 4669        assert_active_thread(
 4670            sidebar,
 4671            &session_id,
 4672            "current window's sidebar should eagerly focus the activated archived thread",
 4673        );
 4674    });
 4675    assert_eq!(
 4676        multi_workspace_a
 4677            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4678            .unwrap(),
 4679        1,
 4680        "current window should continue reusing its existing workspace"
 4681    );
 4682    assert_eq!(
 4683        multi_workspace_b
 4684            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4685            .unwrap(),
 4686        1,
 4687        "other windows should not be activated just because they also match the saved paths"
 4688    );
 4689}
 4690
 4691#[gpui::test]
 4692async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
 4693    // Regression test: archive_thread previously always loaded the next thread
 4694    // through group_workspace (the main workspace's ProjectHeader), even when
 4695    // the next thread belonged to an absorbed linked-worktree workspace. That
 4696    // caused the worktree thread to be loaded in the main panel, which bound it
 4697    // to the main project and corrupted its stored folder_paths.
 4698    //
 4699    // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
 4700    // falling back to group_workspace only for Closed workspaces.
 4701    agent_ui::test_support::init_test(cx);
 4702    cx.update(|cx| {
 4703        ThreadStore::init_global(cx);
 4704        ThreadMetadataStore::init_global(cx);
 4705        language_model::LanguageModelRegistry::test(cx);
 4706        prompt_store::init(cx);
 4707    });
 4708
 4709    let fs = FakeFs::new(cx.executor());
 4710
 4711    fs.insert_tree(
 4712        "/project",
 4713        serde_json::json!({
 4714            ".git": {},
 4715            "src": {},
 4716        }),
 4717    )
 4718    .await;
 4719
 4720    fs.add_linked_worktree_for_repo(
 4721        Path::new("/project/.git"),
 4722        false,
 4723        git::repository::Worktree {
 4724            path: std::path::PathBuf::from("/wt-feature-a"),
 4725            ref_name: Some("refs/heads/feature-a".into()),
 4726            sha: "aaa".into(),
 4727            is_main: false,
 4728            is_bare: false,
 4729        },
 4730    )
 4731    .await;
 4732
 4733    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4734
 4735    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4736    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4737
 4738    main_project
 4739        .update(cx, |p, cx| p.git_scans_complete(cx))
 4740        .await;
 4741    worktree_project
 4742        .update(cx, |p, cx| p.git_scans_complete(cx))
 4743        .await;
 4744
 4745    let (multi_workspace, cx) =
 4746        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4747
 4748    let sidebar = setup_sidebar(&multi_workspace, cx);
 4749
 4750    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4751        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4752    });
 4753
 4754    // Activate main workspace so the sidebar tracks the main panel.
 4755    multi_workspace.update_in(cx, |mw, window, cx| {
 4756        let workspace = mw.workspaces().next().unwrap().clone();
 4757        mw.activate(workspace, None, window, cx);
 4758    });
 4759
 4760    let main_workspace =
 4761        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4762    let main_panel = add_agent_panel(&main_workspace, cx);
 4763    let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
 4764
 4765    // Open Thread 2 in the main panel and keep it running.
 4766    let connection = StubAgentConnection::new();
 4767    open_thread_with_connection(&main_panel, connection.clone(), cx);
 4768    send_message(&main_panel, cx);
 4769
 4770    let thread2_session_id = active_session_id(&main_panel, cx);
 4771
 4772    cx.update(|_, cx| {
 4773        connection.send_update(
 4774            thread2_session_id.clone(),
 4775            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 4776            cx,
 4777        );
 4778    });
 4779
 4780    // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
 4781    save_thread_metadata(
 4782        thread2_session_id.clone(),
 4783        Some("Thread 2".into()),
 4784        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 4785        None,
 4786        None,
 4787        &main_project,
 4788        cx,
 4789    );
 4790
 4791    // Save thread 1's metadata with the worktree path and an older timestamp so
 4792    // it sorts below thread 2. archive_thread will find it as the "next" candidate.
 4793    let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
 4794    save_thread_metadata(
 4795        thread1_session_id,
 4796        Some("Thread 1".into()),
 4797        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 4798        None,
 4799        None,
 4800        &worktree_project,
 4801        cx,
 4802    );
 4803
 4804    cx.run_until_parked();
 4805
 4806    // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
 4807    let entries_before = visible_entries_as_strings(&sidebar, cx);
 4808    assert!(
 4809        entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
 4810        "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
 4811        entries_before
 4812    );
 4813
 4814    // The sidebar should track T2 as the focused thread (derived from the
 4815    // main panel's active view).
 4816    sidebar.read_with(cx, |s, _| {
 4817        assert_active_thread(
 4818            s,
 4819            &thread2_session_id,
 4820            "focused thread should be Thread 2 before archiving",
 4821        );
 4822    });
 4823
 4824    // Archive thread 2.
 4825    sidebar.update_in(cx, |sidebar, window, cx| {
 4826        sidebar.archive_thread(&thread2_session_id, window, cx);
 4827    });
 4828
 4829    cx.run_until_parked();
 4830
 4831    // The main panel's active thread must still be thread 2.
 4832    let main_active = main_panel.read_with(cx, |panel, cx| {
 4833        panel
 4834            .active_agent_thread(cx)
 4835            .map(|t| t.read(cx).session_id().clone())
 4836    });
 4837    assert_eq!(
 4838        main_active,
 4839        Some(thread2_session_id.clone()),
 4840        "main panel should not have been taken over by loading the linked-worktree thread T1; \
 4841             before the fix, archive_thread used group_workspace instead of next.workspace, \
 4842             causing T1 to be loaded in the wrong panel"
 4843    );
 4844
 4845    // Thread 1 should still appear in the sidebar with its worktree chip
 4846    // (Thread 2 was archived so it is gone from the list).
 4847    let entries_after = visible_entries_as_strings(&sidebar, cx);
 4848    assert!(
 4849        entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
 4850        "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
 4851        entries_after
 4852    );
 4853}
 4854
 4855#[gpui::test]
 4856async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
 4857    // When the last non-archived thread for a linked worktree is archived,
 4858    // the linked worktree workspace should be removed from the multi-workspace.
 4859    // The main worktree workspace should remain (it's always reachable via
 4860    // the project header).
 4861    init_test(cx);
 4862    let fs = FakeFs::new(cx.executor());
 4863
 4864    fs.insert_tree(
 4865        "/project",
 4866        serde_json::json!({
 4867            ".git": {
 4868                "worktrees": {
 4869                    "feature-a": {
 4870                        "commondir": "../../",
 4871                        "HEAD": "ref: refs/heads/feature-a",
 4872                    },
 4873                },
 4874            },
 4875            "src": {},
 4876        }),
 4877    )
 4878    .await;
 4879
 4880    fs.insert_tree(
 4881        "/worktrees/project/feature-a/project",
 4882        serde_json::json!({
 4883            ".git": "gitdir: /project/.git/worktrees/feature-a",
 4884            "src": {},
 4885        }),
 4886    )
 4887    .await;
 4888
 4889    fs.add_linked_worktree_for_repo(
 4890        Path::new("/project/.git"),
 4891        false,
 4892        git::repository::Worktree {
 4893            path: PathBuf::from("/worktrees/project/feature-a/project"),
 4894            ref_name: Some("refs/heads/feature-a".into()),
 4895            sha: "abc".into(),
 4896            is_main: false,
 4897            is_bare: false,
 4898        },
 4899    )
 4900    .await;
 4901
 4902    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4903
 4904    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4905    let worktree_project = project::Project::test(
 4906        fs.clone(),
 4907        ["/worktrees/project/feature-a/project".as_ref()],
 4908        cx,
 4909    )
 4910    .await;
 4911
 4912    main_project
 4913        .update(cx, |p, cx| p.git_scans_complete(cx))
 4914        .await;
 4915    worktree_project
 4916        .update(cx, |p, cx| p.git_scans_complete(cx))
 4917        .await;
 4918
 4919    let (multi_workspace, cx) =
 4920        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4921    let sidebar = setup_sidebar(&multi_workspace, cx);
 4922
 4923    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4924        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4925    });
 4926
 4927    // Save a thread for the main project.
 4928    save_thread_metadata(
 4929        acp::SessionId::new(Arc::from("main-thread")),
 4930        Some("Main Thread".into()),
 4931        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 4932        None,
 4933        None,
 4934        &main_project,
 4935        cx,
 4936    );
 4937
 4938    // Save a thread for the linked worktree.
 4939    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 4940    save_thread_metadata(
 4941        wt_thread_id.clone(),
 4942        Some("Worktree Thread".into()),
 4943        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 4944        None,
 4945        None,
 4946        &worktree_project,
 4947        cx,
 4948    );
 4949    cx.run_until_parked();
 4950
 4951    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4952    cx.run_until_parked();
 4953
 4954    // Should have 2 workspaces.
 4955    assert_eq!(
 4956        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4957        2,
 4958        "should start with 2 workspaces (main + linked worktree)"
 4959    );
 4960
 4961    // Archive the worktree thread (the only thread for /wt-feature-a).
 4962    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 4963        sidebar.archive_thread(&wt_thread_id, window, cx);
 4964    });
 4965
 4966    // archive_thread spawns a multi-layered chain of tasks (workspace
 4967    // removal → git persist → disk removal), each of which may spawn
 4968    // further background work. Each run_until_parked() call drives one
 4969    // layer of pending work.
 4970
 4971    cx.run_until_parked();
 4972
 4973    // The linked worktree workspace should have been removed.
 4974    assert_eq!(
 4975        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4976        1,
 4977        "linked worktree workspace should be removed after archiving its last thread"
 4978    );
 4979
 4980    // The linked worktree checkout directory should also be removed from disk.
 4981    assert!(
 4982        !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
 4983            .await,
 4984        "linked worktree directory should be removed from disk after archiving its last thread"
 4985    );
 4986
 4987    // The main thread should still be visible.
 4988    let entries = visible_entries_as_strings(&sidebar, cx);
 4989    assert!(
 4990        entries.iter().any(|e| e.contains("Main Thread")),
 4991        "main thread should still be visible: {entries:?}"
 4992    );
 4993    assert!(
 4994        !entries.iter().any(|e| e.contains("Worktree Thread")),
 4995        "archived worktree thread should not be visible: {entries:?}"
 4996    );
 4997
 4998    // The archived thread must retain its folder_paths so it can be
 4999    // restored to the correct workspace later.
 5000    let wt_thread_id = cx.update(|_window, cx| {
 5001        ThreadMetadataStore::global(cx)
 5002            .read(cx)
 5003            .entry_by_session(&wt_thread_id)
 5004            .unwrap()
 5005            .thread_id
 5006    });
 5007    let archived_paths = cx.update(|_window, cx| {
 5008        ThreadMetadataStore::global(cx)
 5009            .read(cx)
 5010            .entry(wt_thread_id)
 5011            .unwrap()
 5012            .folder_paths()
 5013            .clone()
 5014    });
 5015    assert_eq!(
 5016        archived_paths.paths(),
 5017        &[PathBuf::from("/worktrees/project/feature-a/project")],
 5018        "archived thread must retain its folder_paths for restore"
 5019    );
 5020}
 5021
 5022#[gpui::test]
 5023async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
 5024    // restore_worktree_via_git should succeed when the branch has moved
 5025    // to a different SHA since archival. The worktree stays in detached
 5026    // HEAD and the moved branch is left untouched.
 5027    init_test(cx);
 5028    let fs = FakeFs::new(cx.executor());
 5029
 5030    fs.insert_tree(
 5031        "/project",
 5032        serde_json::json!({
 5033            ".git": {
 5034                "worktrees": {
 5035                    "feature-a": {
 5036                        "commondir": "../../",
 5037                        "HEAD": "ref: refs/heads/feature-a",
 5038                    },
 5039                },
 5040            },
 5041            "src": {},
 5042        }),
 5043    )
 5044    .await;
 5045    fs.insert_tree(
 5046        "/wt-feature-a",
 5047        serde_json::json!({
 5048            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5049            "src": {},
 5050        }),
 5051    )
 5052    .await;
 5053    fs.add_linked_worktree_for_repo(
 5054        Path::new("/project/.git"),
 5055        false,
 5056        git::repository::Worktree {
 5057            path: PathBuf::from("/wt-feature-a"),
 5058            ref_name: Some("refs/heads/feature-a".into()),
 5059            sha: "original-sha".into(),
 5060            is_main: false,
 5061            is_bare: false,
 5062        },
 5063    )
 5064    .await;
 5065    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5066
 5067    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5068    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5069    main_project
 5070        .update(cx, |p, cx| p.git_scans_complete(cx))
 5071        .await;
 5072    worktree_project
 5073        .update(cx, |p, cx| p.git_scans_complete(cx))
 5074        .await;
 5075
 5076    let (multi_workspace, _cx) =
 5077        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5078    multi_workspace.update_in(_cx, |mw, window, cx| {
 5079        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5080    });
 5081
 5082    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5083        project.repositories(cx).values().next().unwrap().clone()
 5084    });
 5085    let (staged_hash, unstaged_hash) = cx
 5086        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5087        .await
 5088        .unwrap()
 5089        .unwrap();
 5090
 5091    // Move the branch to a different SHA.
 5092    fs.with_git_state(Path::new("/project/.git"), false, |state| {
 5093        state
 5094            .refs
 5095            .insert("refs/heads/feature-a".into(), "moved-sha".into());
 5096    })
 5097    .unwrap();
 5098
 5099    let result = cx
 5100        .spawn(|mut cx| async move {
 5101            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5102                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5103                    id: 1,
 5104                    worktree_path: PathBuf::from("/wt-feature-a"),
 5105                    main_repo_path: PathBuf::from("/project"),
 5106                    branch_name: Some("feature-a".to_string()),
 5107                    staged_commit_hash: staged_hash,
 5108                    unstaged_commit_hash: unstaged_hash,
 5109                    original_commit_hash: "original-sha".to_string(),
 5110                },
 5111                None,
 5112                &mut cx,
 5113            )
 5114            .await
 5115        })
 5116        .await;
 5117
 5118    assert!(
 5119        result.is_ok(),
 5120        "restore should succeed even when branch has moved: {:?}",
 5121        result.err()
 5122    );
 5123
 5124    // The moved branch ref should be completely untouched.
 5125    let branch_sha = fs
 5126        .with_git_state(Path::new("/project/.git"), false, |state| {
 5127            state.refs.get("refs/heads/feature-a").cloned()
 5128        })
 5129        .unwrap();
 5130    assert_eq!(
 5131        branch_sha.as_deref(),
 5132        Some("moved-sha"),
 5133        "the moved branch ref should not be modified by the restore"
 5134    );
 5135}
 5136
 5137#[gpui::test]
 5138async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
 5139    // restore_worktree_via_git should succeed when the branch still
 5140    // points at the same SHA as at archive time.
 5141    init_test(cx);
 5142    let fs = FakeFs::new(cx.executor());
 5143
 5144    fs.insert_tree(
 5145        "/project",
 5146        serde_json::json!({
 5147            ".git": {
 5148                "worktrees": {
 5149                    "feature-b": {
 5150                        "commondir": "../../",
 5151                        "HEAD": "ref: refs/heads/feature-b",
 5152                    },
 5153                },
 5154            },
 5155            "src": {},
 5156        }),
 5157    )
 5158    .await;
 5159    fs.insert_tree(
 5160        "/wt-feature-b",
 5161        serde_json::json!({
 5162            ".git": "gitdir: /project/.git/worktrees/feature-b",
 5163            "src": {},
 5164        }),
 5165    )
 5166    .await;
 5167    fs.add_linked_worktree_for_repo(
 5168        Path::new("/project/.git"),
 5169        false,
 5170        git::repository::Worktree {
 5171            path: PathBuf::from("/wt-feature-b"),
 5172            ref_name: Some("refs/heads/feature-b".into()),
 5173            sha: "original-sha".into(),
 5174            is_main: false,
 5175            is_bare: false,
 5176        },
 5177    )
 5178    .await;
 5179    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5180
 5181    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5182    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 5183    main_project
 5184        .update(cx, |p, cx| p.git_scans_complete(cx))
 5185        .await;
 5186    worktree_project
 5187        .update(cx, |p, cx| p.git_scans_complete(cx))
 5188        .await;
 5189
 5190    let (multi_workspace, _cx) =
 5191        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5192    multi_workspace.update_in(_cx, |mw, window, cx| {
 5193        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5194    });
 5195
 5196    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5197        project.repositories(cx).values().next().unwrap().clone()
 5198    });
 5199    let (staged_hash, unstaged_hash) = cx
 5200        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5201        .await
 5202        .unwrap()
 5203        .unwrap();
 5204
 5205    // refs/heads/feature-b already points at "original-sha" (set by
 5206    // add_linked_worktree_for_repo), matching original_commit_hash.
 5207
 5208    let result = cx
 5209        .spawn(|mut cx| async move {
 5210            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5211                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5212                    id: 1,
 5213                    worktree_path: PathBuf::from("/wt-feature-b"),
 5214                    main_repo_path: PathBuf::from("/project"),
 5215                    branch_name: Some("feature-b".to_string()),
 5216                    staged_commit_hash: staged_hash,
 5217                    unstaged_commit_hash: unstaged_hash,
 5218                    original_commit_hash: "original-sha".to_string(),
 5219                },
 5220                None,
 5221                &mut cx,
 5222            )
 5223            .await
 5224        })
 5225        .await;
 5226
 5227    assert!(
 5228        result.is_ok(),
 5229        "restore should succeed when branch has not moved: {:?}",
 5230        result.err()
 5231    );
 5232}
 5233
 5234#[gpui::test]
 5235async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
 5236    // restore_worktree_via_git should succeed when the branch no longer
 5237    // exists (e.g. it was deleted while the thread was archived). The
 5238    // code should attempt to recreate the branch.
 5239    init_test(cx);
 5240    let fs = FakeFs::new(cx.executor());
 5241
 5242    fs.insert_tree(
 5243        "/project",
 5244        serde_json::json!({
 5245            ".git": {
 5246                "worktrees": {
 5247                    "feature-d": {
 5248                        "commondir": "../../",
 5249                        "HEAD": "ref: refs/heads/feature-d",
 5250                    },
 5251                },
 5252            },
 5253            "src": {},
 5254        }),
 5255    )
 5256    .await;
 5257    fs.insert_tree(
 5258        "/wt-feature-d",
 5259        serde_json::json!({
 5260            ".git": "gitdir: /project/.git/worktrees/feature-d",
 5261            "src": {},
 5262        }),
 5263    )
 5264    .await;
 5265    fs.add_linked_worktree_for_repo(
 5266        Path::new("/project/.git"),
 5267        false,
 5268        git::repository::Worktree {
 5269            path: PathBuf::from("/wt-feature-d"),
 5270            ref_name: Some("refs/heads/feature-d".into()),
 5271            sha: "original-sha".into(),
 5272            is_main: false,
 5273            is_bare: false,
 5274        },
 5275    )
 5276    .await;
 5277    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5278
 5279    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5280    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
 5281    main_project
 5282        .update(cx, |p, cx| p.git_scans_complete(cx))
 5283        .await;
 5284    worktree_project
 5285        .update(cx, |p, cx| p.git_scans_complete(cx))
 5286        .await;
 5287
 5288    let (multi_workspace, _cx) =
 5289        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5290    multi_workspace.update_in(_cx, |mw, window, cx| {
 5291        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5292    });
 5293
 5294    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5295        project.repositories(cx).values().next().unwrap().clone()
 5296    });
 5297    let (staged_hash, unstaged_hash) = cx
 5298        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5299        .await
 5300        .unwrap()
 5301        .unwrap();
 5302
 5303    // Remove the branch ref so change_branch will fail.
 5304    fs.with_git_state(Path::new("/project/.git"), false, |state| {
 5305        state.refs.remove("refs/heads/feature-d");
 5306    })
 5307    .unwrap();
 5308
 5309    let result = cx
 5310        .spawn(|mut cx| async move {
 5311            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5312                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5313                    id: 1,
 5314                    worktree_path: PathBuf::from("/wt-feature-d"),
 5315                    main_repo_path: PathBuf::from("/project"),
 5316                    branch_name: Some("feature-d".to_string()),
 5317                    staged_commit_hash: staged_hash,
 5318                    unstaged_commit_hash: unstaged_hash,
 5319                    original_commit_hash: "original-sha".to_string(),
 5320                },
 5321                None,
 5322                &mut cx,
 5323            )
 5324            .await
 5325        })
 5326        .await;
 5327
 5328    assert!(
 5329        result.is_ok(),
 5330        "restore should succeed when branch does not exist: {:?}",
 5331        result.err()
 5332    );
 5333}
 5334
 5335#[gpui::test]
 5336async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
 5337    // Activating an archived linked worktree thread whose directory has
 5338    // been deleted should reuse the existing main repo workspace, not
 5339    // create a new one. The provisional ProjectGroupKey must be derived
 5340    // from main_worktree_paths so that find_or_create_local_workspace
 5341    // matches the main repo workspace when the worktree path is absent.
 5342    init_test(cx);
 5343    let fs = FakeFs::new(cx.executor());
 5344
 5345    fs.insert_tree(
 5346        "/project",
 5347        serde_json::json!({
 5348            ".git": {
 5349                "worktrees": {
 5350                    "feature-c": {
 5351                        "commondir": "../../",
 5352                        "HEAD": "ref: refs/heads/feature-c",
 5353                    },
 5354                },
 5355            },
 5356            "src": {},
 5357        }),
 5358    )
 5359    .await;
 5360
 5361    fs.insert_tree(
 5362        "/wt-feature-c",
 5363        serde_json::json!({
 5364            ".git": "gitdir: /project/.git/worktrees/feature-c",
 5365            "src": {},
 5366        }),
 5367    )
 5368    .await;
 5369
 5370    fs.add_linked_worktree_for_repo(
 5371        Path::new("/project/.git"),
 5372        false,
 5373        git::repository::Worktree {
 5374            path: PathBuf::from("/wt-feature-c"),
 5375            ref_name: Some("refs/heads/feature-c".into()),
 5376            sha: "original-sha".into(),
 5377            is_main: false,
 5378            is_bare: false,
 5379        },
 5380    )
 5381    .await;
 5382
 5383    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5384
 5385    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5386    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
 5387
 5388    main_project
 5389        .update(cx, |p, cx| p.git_scans_complete(cx))
 5390        .await;
 5391    worktree_project
 5392        .update(cx, |p, cx| p.git_scans_complete(cx))
 5393        .await;
 5394
 5395    let (multi_workspace, cx) =
 5396        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5397    let sidebar = setup_sidebar(&multi_workspace, cx);
 5398
 5399    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5400        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5401    });
 5402
 5403    // Save thread metadata for the linked worktree.
 5404    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
 5405    save_thread_metadata(
 5406        wt_session_id.clone(),
 5407        Some("Worktree Thread C".into()),
 5408        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5409        None,
 5410        None,
 5411        &worktree_project,
 5412        cx,
 5413    );
 5414    cx.run_until_parked();
 5415
 5416    let thread_id = cx.update(|_window, cx| {
 5417        ThreadMetadataStore::global(cx)
 5418            .read(cx)
 5419            .entry_by_session(&wt_session_id)
 5420            .unwrap()
 5421            .thread_id
 5422    });
 5423
 5424    // Archive the thread without creating ArchivedGitWorktree records.
 5425    let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
 5426    cx.update(|_window, cx| {
 5427        store.update(cx, |store, cx| store.archive(thread_id, None, cx));
 5428    });
 5429    cx.run_until_parked();
 5430
 5431    // Remove the worktree workspace and delete the worktree from disk.
 5432    let main_workspace =
 5433        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 5434    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 5435        mw.remove(
 5436            vec![worktree_workspace],
 5437            move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
 5438            window,
 5439            cx,
 5440        )
 5441    });
 5442    remove_task.await.ok();
 5443    cx.run_until_parked();
 5444    cx.run_until_parked();
 5445    fs.remove_dir(
 5446        Path::new("/wt-feature-c"),
 5447        fs::RemoveOptions {
 5448            recursive: true,
 5449            ignore_if_not_exists: true,
 5450        },
 5451    )
 5452    .await
 5453    .unwrap();
 5454
 5455    let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 5456    assert_eq!(
 5457        workspace_count_before, 1,
 5458        "should have only the main workspace"
 5459    );
 5460
 5461    // Activate the archived thread. The worktree path is missing from
 5462    // disk, so find_or_create_local_workspace falls back to the
 5463    // provisional ProjectGroupKey to find a matching workspace.
 5464    let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
 5465    sidebar.update_in(cx, |sidebar, window, cx| {
 5466        sidebar.open_thread_from_archive(metadata, window, cx);
 5467    });
 5468    cx.run_until_parked();
 5469
 5470    // The provisional key should use [/project] (the main repo),
 5471    // which matches the existing main workspace. If it incorrectly
 5472    // used [/wt-feature-c] (the linked worktree path), no workspace
 5473    // would match and a spurious new one would be created.
 5474    let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 5475    assert_eq!(
 5476        workspace_count_after, 1,
 5477        "restoring a linked worktree thread should reuse the main repo workspace, \
 5478         not create a new one (workspace count went from {workspace_count_before} to \
 5479         {workspace_count_after})"
 5480    );
 5481}
 5482
 5483#[gpui::test]
 5484async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
 5485    cx: &mut TestAppContext,
 5486) {
 5487    // A remote thread at the same path as a local linked worktree thread
 5488    // should not prevent the local workspace from being removed when the
 5489    // local thread is archived (the last local thread for that worktree).
 5490    init_test(cx);
 5491    let fs = FakeFs::new(cx.executor());
 5492
 5493    fs.insert_tree(
 5494        "/project",
 5495        serde_json::json!({
 5496            ".git": {
 5497                "worktrees": {
 5498                    "feature-a": {
 5499                        "commondir": "../../",
 5500                        "HEAD": "ref: refs/heads/feature-a",
 5501                    },
 5502                },
 5503            },
 5504            "src": {},
 5505        }),
 5506    )
 5507    .await;
 5508
 5509    fs.insert_tree(
 5510        "/wt-feature-a",
 5511        serde_json::json!({
 5512            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5513            "src": {},
 5514        }),
 5515    )
 5516    .await;
 5517
 5518    fs.add_linked_worktree_for_repo(
 5519        Path::new("/project/.git"),
 5520        false,
 5521        git::repository::Worktree {
 5522            path: PathBuf::from("/wt-feature-a"),
 5523            ref_name: Some("refs/heads/feature-a".into()),
 5524            sha: "abc".into(),
 5525            is_main: false,
 5526            is_bare: false,
 5527        },
 5528    )
 5529    .await;
 5530
 5531    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5532
 5533    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5534    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5535
 5536    main_project
 5537        .update(cx, |p, cx| p.git_scans_complete(cx))
 5538        .await;
 5539    worktree_project
 5540        .update(cx, |p, cx| p.git_scans_complete(cx))
 5541        .await;
 5542
 5543    let (multi_workspace, cx) =
 5544        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5545    let sidebar = setup_sidebar(&multi_workspace, cx);
 5546
 5547    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5548        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5549    });
 5550
 5551    // Save a thread for the main project.
 5552    save_thread_metadata(
 5553        acp::SessionId::new(Arc::from("main-thread")),
 5554        Some("Main Thread".into()),
 5555        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5556        None,
 5557        None,
 5558        &main_project,
 5559        cx,
 5560    );
 5561
 5562    // Save a local thread for the linked worktree.
 5563    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 5564    save_thread_metadata(
 5565        wt_thread_id.clone(),
 5566        Some("Local Worktree Thread".into()),
 5567        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5568        None,
 5569        None,
 5570        &worktree_project,
 5571        cx,
 5572    );
 5573
 5574    // Save a remote thread at the same /wt-feature-a path but on a
 5575    // different host. This should NOT count as a remaining thread for
 5576    // the local linked worktree workspace.
 5577    let remote_host =
 5578        remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
 5579    cx.update(|_window, cx| {
 5580        let metadata = ThreadMetadata {
 5581            thread_id: ThreadId::new(),
 5582            session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
 5583            agent_id: agent::ZED_AGENT_ID.clone(),
 5584            title: Some("Remote Worktree Thread".into()),
 5585            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5586            created_at: None,
 5587            interacted_at: None,
 5588            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 5589                "/wt-feature-a",
 5590            )])),
 5591            archived: false,
 5592            remote_connection: Some(remote_host),
 5593        };
 5594        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 5595            store.save(metadata, cx);
 5596        });
 5597    });
 5598    cx.run_until_parked();
 5599
 5600    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5601    cx.run_until_parked();
 5602
 5603    assert_eq!(
 5604        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5605        2,
 5606        "should start with 2 workspaces (main + linked worktree)"
 5607    );
 5608
 5609    // The remote thread should NOT appear in the sidebar (it belongs
 5610    // to a different host and no matching remote project group exists).
 5611    let entries_before = visible_entries_as_strings(&sidebar, cx);
 5612    assert!(
 5613        !entries_before
 5614            .iter()
 5615            .any(|e| e.contains("Remote Worktree Thread")),
 5616        "remote thread should not appear in local sidebar: {entries_before:?}"
 5617    );
 5618
 5619    // Archive the local worktree thread.
 5620    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 5621        sidebar.archive_thread(&wt_thread_id, window, cx);
 5622    });
 5623
 5624    cx.run_until_parked();
 5625
 5626    // The linked worktree workspace should be removed because the
 5627    // only *local* thread for it was archived. The remote thread at
 5628    // the same path should not have prevented removal.
 5629    assert_eq!(
 5630        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5631        1,
 5632        "linked worktree workspace should be removed; the remote thread at the same path \
 5633         should not count as a remaining local thread"
 5634    );
 5635
 5636    let entries = visible_entries_as_strings(&sidebar, cx);
 5637    assert!(
 5638        entries.iter().any(|e| e.contains("Main Thread")),
 5639        "main thread should still be visible: {entries:?}"
 5640    );
 5641    assert!(
 5642        !entries.iter().any(|e| e.contains("Local Worktree Thread")),
 5643        "archived local worktree thread should not be visible: {entries:?}"
 5644    );
 5645    assert!(
 5646        !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
 5647        "remote thread should still not appear in local sidebar: {entries:?}"
 5648    );
 5649}
 5650
 5651#[gpui::test]
 5652async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
 5653    // When a multi-root workspace (e.g. [/other, /project]) shares a
 5654    // repo with a single-root workspace (e.g. [/project]), linked
 5655    // worktree threads from the shared repo should only appear under
 5656    // the dedicated group [project], not under [other, project].
 5657    agent_ui::test_support::init_test(cx);
 5658    cx.update(|cx| {
 5659        ThreadStore::init_global(cx);
 5660        ThreadMetadataStore::init_global(cx);
 5661        language_model::LanguageModelRegistry::test(cx);
 5662        prompt_store::init(cx);
 5663    });
 5664    let fs = FakeFs::new(cx.executor());
 5665
 5666    // Two independent repos, each with their own git history.
 5667    fs.insert_tree(
 5668        "/project",
 5669        serde_json::json!({
 5670            ".git": {},
 5671            "src": {},
 5672        }),
 5673    )
 5674    .await;
 5675    fs.insert_tree(
 5676        "/other",
 5677        serde_json::json!({
 5678            ".git": {},
 5679            "src": {},
 5680        }),
 5681    )
 5682    .await;
 5683
 5684    // Register the linked worktree in the main repo.
 5685    fs.add_linked_worktree_for_repo(
 5686        Path::new("/project/.git"),
 5687        false,
 5688        git::repository::Worktree {
 5689            path: std::path::PathBuf::from("/wt-feature-a"),
 5690            ref_name: Some("refs/heads/feature-a".into()),
 5691            sha: "aaa".into(),
 5692            is_main: false,
 5693            is_bare: false,
 5694        },
 5695    )
 5696    .await;
 5697
 5698    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5699
 5700    // Workspace 1: just /project.
 5701    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5702    project_only
 5703        .update(cx, |p, cx| p.git_scans_complete(cx))
 5704        .await;
 5705
 5706    // Workspace 2: /other and /project together (multi-root).
 5707    let multi_root =
 5708        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
 5709    multi_root
 5710        .update(cx, |p, cx| p.git_scans_complete(cx))
 5711        .await;
 5712
 5713    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5714    worktree_project
 5715        .update(cx, |p, cx| p.git_scans_complete(cx))
 5716        .await;
 5717
 5718    // Save a thread under the linked worktree path BEFORE setting up
 5719    // the sidebar and panels, so that reconciliation sees the [project]
 5720    // group as non-empty and doesn't create a spurious draft there.
 5721    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
 5722    save_thread_metadata(
 5723        wt_session_id,
 5724        Some("Worktree Thread".into()),
 5725        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5726        None,
 5727        None,
 5728        &worktree_project,
 5729        cx,
 5730    );
 5731
 5732    let (multi_workspace, cx) =
 5733        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
 5734    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5735    let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5736        mw.test_add_workspace(multi_root.clone(), window, cx)
 5737    });
 5738    add_agent_panel(&multi_root_workspace, cx);
 5739    cx.run_until_parked();
 5740
 5741    // The thread should appear only under [project] (the dedicated
 5742    // group for the /project repo), not under [other, project].
 5743    assert_eq!(
 5744        visible_entries_as_strings(&sidebar, cx),
 5745        vec![
 5746            //
 5747            "v [other, project]",
 5748            "v [project]",
 5749            "  Worktree Thread {wt-feature-a}",
 5750        ]
 5751    );
 5752}
 5753
 5754fn thread_id_for(session_id: &acp::SessionId, cx: &mut TestAppContext) -> ThreadId {
 5755    cx.read(|cx| {
 5756        ThreadMetadataStore::global(cx)
 5757            .read(cx)
 5758            .entry_by_session(session_id)
 5759            .map(|m| m.thread_id)
 5760            .expect("thread metadata should exist")
 5761    })
 5762}
 5763
 5764#[gpui::test]
 5765async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
 5766    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 5767    let (multi_workspace, cx) =
 5768        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 5769    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5770
 5771    let switcher_ids =
 5772        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<ThreadId> {
 5773            sidebar.read_with(cx, |sidebar, cx| {
 5774                let switcher = sidebar
 5775                    .thread_switcher
 5776                    .as_ref()
 5777                    .expect("switcher should be open");
 5778                switcher
 5779                    .read(cx)
 5780                    .entries()
 5781                    .iter()
 5782                    .map(|e| e.metadata.thread_id)
 5783                    .collect()
 5784            })
 5785        };
 5786
 5787    let switcher_selected_id =
 5788        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> ThreadId {
 5789            sidebar.read_with(cx, |sidebar, cx| {
 5790                let switcher = sidebar
 5791                    .thread_switcher
 5792                    .as_ref()
 5793                    .expect("switcher should be open");
 5794                let s = switcher.read(cx);
 5795                s.selected_entry()
 5796                    .expect("should have selection")
 5797                    .metadata
 5798                    .thread_id
 5799            })
 5800        };
 5801
 5802    // ── Setup: create three threads with distinct created_at times ──────
 5803    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
 5804    // We send messages in each so they also get last_message_sent_or_queued timestamps.
 5805    let connection_c = StubAgentConnection::new();
 5806    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5807        acp::ContentChunk::new("Done C".into()),
 5808    )]);
 5809    open_thread_with_connection(&panel, connection_c, cx);
 5810    send_message(&panel, cx);
 5811    let session_id_c = active_session_id(&panel, cx);
 5812    let thread_id_c = active_thread_id(&panel, cx);
 5813    save_thread_metadata(
 5814        session_id_c.clone(),
 5815        Some("Thread C".into()),
 5816        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5817        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
 5818        None,
 5819        &project,
 5820        cx,
 5821    );
 5822
 5823    let connection_b = StubAgentConnection::new();
 5824    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5825        acp::ContentChunk::new("Done B".into()),
 5826    )]);
 5827    open_thread_with_connection(&panel, connection_b, cx);
 5828    send_message(&panel, cx);
 5829    let session_id_b = active_session_id(&panel, cx);
 5830    let thread_id_b = active_thread_id(&panel, cx);
 5831    save_thread_metadata(
 5832        session_id_b.clone(),
 5833        Some("Thread B".into()),
 5834        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5835        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
 5836        None,
 5837        &project,
 5838        cx,
 5839    );
 5840
 5841    let connection_a = StubAgentConnection::new();
 5842    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5843        acp::ContentChunk::new("Done A".into()),
 5844    )]);
 5845    open_thread_with_connection(&panel, connection_a, cx);
 5846    send_message(&panel, cx);
 5847    let session_id_a = active_session_id(&panel, cx);
 5848    let thread_id_a = active_thread_id(&panel, cx);
 5849    save_thread_metadata(
 5850        session_id_a.clone(),
 5851        Some("Thread A".into()),
 5852        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
 5853        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
 5854        None,
 5855        &project,
 5856        cx,
 5857    );
 5858
 5859    // All three threads are now live. Thread A was opened last, so it's
 5860    // the one being viewed. Opening each thread called record_thread_access,
 5861    // so all three have last_accessed_at set.
 5862    // Access order is: A (most recent), B, C (oldest).
 5863
 5864    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
 5865    focus_sidebar(&sidebar, cx);
 5866    sidebar.update_in(cx, |sidebar, window, cx| {
 5867        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5868    });
 5869    cx.run_until_parked();
 5870
 5871    // All three have last_accessed_at, so they sort by access time.
 5872    // A was accessed most recently (it's the currently viewed thread),
 5873    // then B, then C.
 5874    assert_eq!(
 5875        switcher_ids(&sidebar, cx),
 5876        vec![thread_id_a, thread_id_b, thread_id_c,],
 5877    );
 5878    // First ctrl-tab selects the second entry (B).
 5879    assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_b);
 5880
 5881    // Dismiss the switcher without confirming.
 5882    sidebar.update_in(cx, |sidebar, _window, cx| {
 5883        sidebar.dismiss_thread_switcher(cx);
 5884    });
 5885    cx.run_until_parked();
 5886
 5887    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
 5888    sidebar.update_in(cx, |sidebar, window, cx| {
 5889        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5890    });
 5891    cx.run_until_parked();
 5892
 5893    // Cycle twice to land on Thread C (index 2).
 5894    sidebar.read_with(cx, |sidebar, cx| {
 5895        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5896        assert_eq!(switcher.read(cx).selected_index(), 1);
 5897    });
 5898    sidebar.update_in(cx, |sidebar, _window, cx| {
 5899        sidebar
 5900            .thread_switcher
 5901            .as_ref()
 5902            .unwrap()
 5903            .update(cx, |s, cx| s.cycle_selection(cx));
 5904    });
 5905    cx.run_until_parked();
 5906    assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_c);
 5907
 5908    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
 5909
 5910    // Confirm on Thread C.
 5911    sidebar.update_in(cx, |sidebar, window, cx| {
 5912        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5913        let focus = switcher.focus_handle(cx);
 5914        focus.dispatch_action(&menu::Confirm, window, cx);
 5915    });
 5916    cx.run_until_parked();
 5917
 5918    // Switcher should be dismissed after confirm.
 5919    sidebar.read_with(cx, |sidebar, _cx| {
 5920        assert!(
 5921            sidebar.thread_switcher.is_none(),
 5922            "switcher should be dismissed"
 5923        );
 5924    });
 5925
 5926    sidebar.update(cx, |sidebar, _cx| {
 5927        let last_accessed = sidebar
 5928            .thread_last_accessed
 5929            .keys()
 5930            .cloned()
 5931            .collect::<Vec<_>>();
 5932        assert_eq!(last_accessed.len(), 1);
 5933        assert!(last_accessed.contains(&thread_id_c));
 5934        assert!(
 5935            is_active_session(&sidebar, &session_id_c),
 5936            "active_entry should be Thread({session_id_c:?})"
 5937        );
 5938    });
 5939
 5940    sidebar.update_in(cx, |sidebar, window, cx| {
 5941        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5942    });
 5943    cx.run_until_parked();
 5944
 5945    assert_eq!(
 5946        switcher_ids(&sidebar, cx),
 5947        vec![thread_id_c, thread_id_a, thread_id_b],
 5948    );
 5949
 5950    // Confirm on Thread A.
 5951    sidebar.update_in(cx, |sidebar, window, cx| {
 5952        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5953        let focus = switcher.focus_handle(cx);
 5954        focus.dispatch_action(&menu::Confirm, window, cx);
 5955    });
 5956    cx.run_until_parked();
 5957
 5958    sidebar.update(cx, |sidebar, _cx| {
 5959        let last_accessed = sidebar
 5960            .thread_last_accessed
 5961            .keys()
 5962            .cloned()
 5963            .collect::<Vec<_>>();
 5964        assert_eq!(last_accessed.len(), 2);
 5965        assert!(last_accessed.contains(&thread_id_c));
 5966        assert!(last_accessed.contains(&thread_id_a));
 5967        assert!(
 5968            is_active_session(&sidebar, &session_id_a),
 5969            "active_entry should be Thread({session_id_a:?})"
 5970        );
 5971    });
 5972
 5973    sidebar.update_in(cx, |sidebar, window, cx| {
 5974        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 5975    });
 5976    cx.run_until_parked();
 5977
 5978    assert_eq!(
 5979        switcher_ids(&sidebar, cx),
 5980        vec![thread_id_a, thread_id_c, thread_id_b,],
 5981    );
 5982
 5983    sidebar.update_in(cx, |sidebar, _window, cx| {
 5984        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5985        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
 5986    });
 5987    cx.run_until_parked();
 5988
 5989    // Confirm on Thread B.
 5990    sidebar.update_in(cx, |sidebar, window, cx| {
 5991        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 5992        let focus = switcher.focus_handle(cx);
 5993        focus.dispatch_action(&menu::Confirm, window, cx);
 5994    });
 5995    cx.run_until_parked();
 5996
 5997    sidebar.update(cx, |sidebar, _cx| {
 5998        let last_accessed = sidebar
 5999            .thread_last_accessed
 6000            .keys()
 6001            .cloned()
 6002            .collect::<Vec<_>>();
 6003        assert_eq!(last_accessed.len(), 3);
 6004        assert!(last_accessed.contains(&thread_id_c));
 6005        assert!(last_accessed.contains(&thread_id_a));
 6006        assert!(last_accessed.contains(&thread_id_b));
 6007        assert!(
 6008            is_active_session(&sidebar, &session_id_b),
 6009            "active_entry should be Thread({session_id_b:?})"
 6010        );
 6011    });
 6012
 6013    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
 6014    // This thread was never opened in a panel — it only exists in metadata.
 6015    save_thread_metadata(
 6016        acp::SessionId::new(Arc::from("thread-historical")),
 6017        Some("Historical Thread".into()),
 6018        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 6019        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
 6020        None,
 6021        &project,
 6022        cx,
 6023    );
 6024
 6025    sidebar.update_in(cx, |sidebar, window, cx| {
 6026        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6027    });
 6028    cx.run_until_parked();
 6029
 6030    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
 6031    // so it falls to tier 3 (sorted by created_at). It should appear after all
 6032    // accessed threads, even though its created_at (June 2024) is much later
 6033    // than the others.
 6034    //
 6035    // But the live threads (A, B, C) each had send_message called which sets
 6036    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
 6037    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
 6038    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
 6039    let thread_id_hist = thread_id_for(&session_id_hist, cx);
 6040
 6041    let ids = switcher_ids(&sidebar, cx);
 6042    assert_eq!(
 6043        ids,
 6044        vec![thread_id_b, thread_id_a, thread_id_c, thread_id_hist],
 6045    );
 6046
 6047    sidebar.update_in(cx, |sidebar, _window, cx| {
 6048        sidebar.dismiss_thread_switcher(cx);
 6049    });
 6050    cx.run_until_parked();
 6051
 6052    // ── 4. Add another historical thread with older created_at ─────────
 6053    save_thread_metadata(
 6054        acp::SessionId::new(Arc::from("thread-old-historical")),
 6055        Some("Old Historical Thread".into()),
 6056        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
 6057        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
 6058        None,
 6059        &project,
 6060        cx,
 6061    );
 6062
 6063    sidebar.update_in(cx, |sidebar, window, cx| {
 6064        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6065    });
 6066    cx.run_until_parked();
 6067
 6068    // Both historical threads have no access or message times. They should
 6069    // appear after accessed threads, sorted by created_at (newest first).
 6070    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
 6071    let thread_id_old_hist = thread_id_for(&session_id_old_hist, cx);
 6072    let ids = switcher_ids(&sidebar, cx);
 6073    assert_eq!(
 6074        ids,
 6075        vec![
 6076            thread_id_b,
 6077            thread_id_a,
 6078            thread_id_c,
 6079            thread_id_hist,
 6080            thread_id_old_hist,
 6081        ],
 6082    );
 6083
 6084    sidebar.update_in(cx, |sidebar, _window, cx| {
 6085        sidebar.dismiss_thread_switcher(cx);
 6086    });
 6087    cx.run_until_parked();
 6088}
 6089
 6090#[gpui::test]
 6091async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
 6092    let project = init_test_project("/my-project", cx).await;
 6093    let (multi_workspace, cx) =
 6094        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6095    let sidebar = setup_sidebar(&multi_workspace, cx);
 6096
 6097    save_thread_metadata(
 6098        acp::SessionId::new(Arc::from("thread-to-archive")),
 6099        Some("Thread To Archive".into()),
 6100        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 6101        None,
 6102        None,
 6103        &project,
 6104        cx,
 6105    );
 6106    cx.run_until_parked();
 6107
 6108    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 6109    cx.run_until_parked();
 6110
 6111    let entries = visible_entries_as_strings(&sidebar, cx);
 6112    assert!(
 6113        entries.iter().any(|e| e.contains("Thread To Archive")),
 6114        "expected thread to be visible before archiving, got: {entries:?}"
 6115    );
 6116
 6117    sidebar.update_in(cx, |sidebar, window, cx| {
 6118        sidebar.archive_thread(
 6119            &acp::SessionId::new(Arc::from("thread-to-archive")),
 6120            window,
 6121            cx,
 6122        );
 6123    });
 6124    cx.run_until_parked();
 6125
 6126    let entries = visible_entries_as_strings(&sidebar, cx);
 6127    assert!(
 6128        !entries.iter().any(|e| e.contains("Thread To Archive")),
 6129        "expected thread to be hidden after archiving, got: {entries:?}"
 6130    );
 6131
 6132    cx.update(|_, cx| {
 6133        let store = ThreadMetadataStore::global(cx);
 6134        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 6135        assert_eq!(archived.len(), 1);
 6136        assert_eq!(
 6137            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 6138            "thread-to-archive"
 6139        );
 6140        assert!(archived[0].archived);
 6141    });
 6142}
 6143
 6144#[gpui::test]
 6145async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
 6146    // Tests two archive scenarios:
 6147    // 1. Archiving a thread in a non-active workspace leaves active_entry
 6148    //    as the current draft.
 6149    // 2. Archiving the thread the user is looking at falls back to a draft
 6150    //    on the same workspace.
 6151    agent_ui::test_support::init_test(cx);
 6152    cx.update(|cx| {
 6153        ThreadStore::init_global(cx);
 6154        ThreadMetadataStore::init_global(cx);
 6155        language_model::LanguageModelRegistry::test(cx);
 6156        prompt_store::init(cx);
 6157    });
 6158
 6159    let fs = FakeFs::new(cx.executor());
 6160    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6161        .await;
 6162    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6163        .await;
 6164    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6165
 6166    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6167    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6168
 6169    let (multi_workspace, cx) =
 6170        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6171    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6172
 6173    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6174        mw.test_add_workspace(project_b.clone(), window, cx)
 6175    });
 6176    let panel_b = add_agent_panel(&workspace_b, cx);
 6177    cx.run_until_parked();
 6178
 6179    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 6180    sidebar.update_in(cx, |sidebar, window, cx| {
 6181        sidebar.create_new_thread(&workspace_b, window, cx);
 6182    });
 6183    cx.run_until_parked();
 6184
 6185    // --- Scenario 1: archive a thread in the non-active workspace ---
 6186
 6187    // Create a thread in project-a (non-active — project-b is active).
 6188    let connection = acp_thread::StubAgentConnection::new();
 6189    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6190        acp::ContentChunk::new("Done".into()),
 6191    )]);
 6192    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 6193    agent_ui::test_support::send_message(&panel_a, cx);
 6194    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 6195    cx.run_until_parked();
 6196
 6197    sidebar.update_in(cx, |sidebar, window, cx| {
 6198        sidebar.archive_thread(&thread_a, window, cx);
 6199    });
 6200    cx.run_until_parked();
 6201
 6202    // active_entry should still be a draft on workspace_b (the active one).
 6203    sidebar.read_with(cx, |sidebar, _| {
 6204        assert!(
 6205            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
 6206            "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
 6207            sidebar.active_entry,
 6208        );
 6209    });
 6210
 6211    // --- Scenario 2: archive the thread the user is looking at ---
 6212
 6213    // Create a thread in project-b (the active workspace) and verify it
 6214    // becomes the active entry.
 6215    let connection = acp_thread::StubAgentConnection::new();
 6216    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6217        acp::ContentChunk::new("Done".into()),
 6218    )]);
 6219    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6220    agent_ui::test_support::send_message(&panel_b, cx);
 6221    let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
 6222    cx.run_until_parked();
 6223
 6224    sidebar.read_with(cx, |sidebar, _| {
 6225        assert!(
 6226            is_active_session(&sidebar, &thread_b),
 6227            "expected active_entry to be Thread({thread_b}), got: {:?}",
 6228            sidebar.active_entry,
 6229        );
 6230    });
 6231
 6232    sidebar.update_in(cx, |sidebar, window, cx| {
 6233        sidebar.archive_thread(&thread_b, window, cx);
 6234    });
 6235    cx.run_until_parked();
 6236
 6237    // Archiving the active thread activates a draft on the same workspace
 6238    // (via clear_base_view → activate_draft). The draft is not shown as a
 6239    // sidebar row but active_entry tracks it.
 6240    sidebar.read_with(cx, |sidebar, _| {
 6241        assert!(
 6242            matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
 6243            "expected draft on workspace_b after archiving active thread, got: {:?}",
 6244            sidebar.active_entry,
 6245        );
 6246    });
 6247}
 6248
 6249#[gpui::test]
 6250async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
 6251    // Full flow: create a thread, archive it (removing the workspace),
 6252    // then unarchive. Only the restored thread should appear — no
 6253    // leftover drafts or previously-serialized threads.
 6254    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 6255    let (multi_workspace, cx) =
 6256        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6257    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6258    cx.run_until_parked();
 6259
 6260    // Create a thread and send a message so it's a real thread.
 6261    let connection = acp_thread::StubAgentConnection::new();
 6262    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6263        acp::ContentChunk::new("Hello".into()),
 6264    )]);
 6265    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6266    agent_ui::test_support::send_message(&panel, cx);
 6267    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6268    cx.run_until_parked();
 6269
 6270    // Archive it.
 6271    sidebar.update_in(cx, |sidebar, window, cx| {
 6272        sidebar.archive_thread(&session_id, window, cx);
 6273    });
 6274    cx.run_until_parked();
 6275
 6276    // Grab metadata for unarchive.
 6277    let thread_id = cx.update(|_, cx| {
 6278        ThreadMetadataStore::global(cx)
 6279            .read(cx)
 6280            .entries()
 6281            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6282            .map(|e| e.thread_id)
 6283            .expect("thread should exist")
 6284    });
 6285    let metadata = cx.update(|_, cx| {
 6286        ThreadMetadataStore::global(cx)
 6287            .read(cx)
 6288            .entry(thread_id)
 6289            .cloned()
 6290            .expect("metadata should exist")
 6291    });
 6292
 6293    // Unarchive it — the draft should be replaced by the restored thread.
 6294    sidebar.update_in(cx, |sidebar, window, cx| {
 6295        sidebar.open_thread_from_archive(metadata, window, cx);
 6296    });
 6297    cx.run_until_parked();
 6298
 6299    // Only the unarchived thread should be visible — no drafts, no other threads.
 6300    let entries = visible_entries_as_strings(&sidebar, cx);
 6301    let thread_count = entries
 6302        .iter()
 6303        .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
 6304        .count();
 6305    assert_eq!(
 6306        thread_count, 1,
 6307        "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
 6308    );
 6309    assert!(
 6310        !entries.iter().any(|e| e.contains("Draft")),
 6311        "expected no drafts after restoring, got entries: {entries:?}"
 6312    );
 6313}
 6314
 6315#[gpui::test]
 6316async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
 6317    cx: &mut TestAppContext,
 6318) {
 6319    // When a thread is unarchived into a project group that has no open
 6320    // workspace, the sidebar opens a new workspace and loads the thread.
 6321    // No spurious draft should appear alongside the unarchived thread.
 6322    agent_ui::test_support::init_test(cx);
 6323    cx.update(|cx| {
 6324        ThreadStore::init_global(cx);
 6325        ThreadMetadataStore::init_global(cx);
 6326        language_model::LanguageModelRegistry::test(cx);
 6327        prompt_store::init(cx);
 6328    });
 6329
 6330    let fs = FakeFs::new(cx.executor());
 6331    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6332        .await;
 6333    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6334        .await;
 6335    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6336
 6337    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6338    let (multi_workspace, cx) =
 6339        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6340    let sidebar = setup_sidebar(&multi_workspace, cx);
 6341    cx.run_until_parked();
 6342
 6343    // Save an archived thread whose folder_paths point to project-b,
 6344    // which has no open workspace.
 6345    let session_id = acp::SessionId::new(Arc::from("archived-thread"));
 6346    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 6347    let thread_id = ThreadId::new();
 6348    cx.update(|_, cx| {
 6349        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6350            store.save(
 6351                ThreadMetadata {
 6352                    thread_id,
 6353                    session_id: Some(session_id.clone()),
 6354                    agent_id: agent::ZED_AGENT_ID.clone(),
 6355                    title: Some("Unarchived Thread".into()),
 6356                    updated_at: Utc::now(),
 6357                    created_at: None,
 6358                    interacted_at: None,
 6359                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6360                    archived: true,
 6361                    remote_connection: None,
 6362                },
 6363                cx,
 6364            )
 6365        });
 6366    });
 6367    cx.run_until_parked();
 6368
 6369    // Verify no workspace for project-b exists yet.
 6370    assert_eq!(
 6371        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6372        1,
 6373        "should start with only the project-a workspace"
 6374    );
 6375
 6376    // Un-archive the thread — should open project-b workspace and load it.
 6377    let metadata = cx.update(|_, cx| {
 6378        ThreadMetadataStore::global(cx)
 6379            .read(cx)
 6380            .entry(thread_id)
 6381            .cloned()
 6382            .expect("metadata should exist")
 6383    });
 6384
 6385    sidebar.update_in(cx, |sidebar, window, cx| {
 6386        sidebar.open_thread_from_archive(metadata, window, cx);
 6387    });
 6388    cx.run_until_parked();
 6389
 6390    // A second workspace should have been created for project-b.
 6391    assert_eq!(
 6392        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6393        2,
 6394        "should have opened a workspace for the unarchived thread"
 6395    );
 6396
 6397    // The sidebar should show the unarchived thread without a spurious draft
 6398    // in the project-b group.
 6399    let entries = visible_entries_as_strings(&sidebar, cx);
 6400    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6401    // project-a gets a draft (it's the active workspace with no threads),
 6402    // but project-b should NOT have one — only the unarchived thread.
 6403    assert!(
 6404        draft_count <= 1,
 6405        "expected at most one draft (for project-a), got entries: {entries:?}"
 6406    );
 6407    assert!(
 6408        entries.iter().any(|e| e.contains("Unarchived Thread")),
 6409        "expected unarchived thread to appear, got entries: {entries:?}"
 6410    );
 6411}
 6412
 6413#[gpui::test]
 6414async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
 6415    cx: &mut TestAppContext,
 6416) {
 6417    agent_ui::test_support::init_test(cx);
 6418    cx.update(|cx| {
 6419        ThreadStore::init_global(cx);
 6420        ThreadMetadataStore::init_global(cx);
 6421        language_model::LanguageModelRegistry::test(cx);
 6422        prompt_store::init(cx);
 6423    });
 6424
 6425    let fs = FakeFs::new(cx.executor());
 6426    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6427        .await;
 6428    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6429        .await;
 6430    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6431
 6432    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6433    let (multi_workspace, cx) =
 6434        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6435    let sidebar = setup_sidebar(&multi_workspace, cx);
 6436    cx.run_until_parked();
 6437
 6438    let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
 6439    let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
 6440    let original_thread_id = ThreadId::new();
 6441    cx.update(|_, cx| {
 6442        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6443            store.save(
 6444                ThreadMetadata {
 6445                    thread_id: original_thread_id,
 6446                    session_id: Some(session_id.clone()),
 6447                    agent_id: agent::ZED_AGENT_ID.clone(),
 6448                    title: Some("Unarchived Thread".into()),
 6449                    updated_at: Utc::now(),
 6450                    created_at: None,
 6451                    interacted_at: None,
 6452                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6453                    archived: true,
 6454                    remote_connection: None,
 6455                },
 6456                cx,
 6457            )
 6458        });
 6459    });
 6460    cx.run_until_parked();
 6461
 6462    let metadata = cx.update(|_, cx| {
 6463        ThreadMetadataStore::global(cx)
 6464            .read(cx)
 6465            .entry(original_thread_id)
 6466            .cloned()
 6467            .expect("metadata should exist before unarchive")
 6468    });
 6469
 6470    sidebar.update_in(cx, |sidebar, window, cx| {
 6471        sidebar.open_thread_from_archive(metadata, window, cx);
 6472    });
 6473
 6474    cx.run_until_parked();
 6475
 6476    assert_eq!(
 6477        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6478        2,
 6479        "expected unarchive to open the target workspace"
 6480    );
 6481
 6482    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6483        mw.workspaces()
 6484            .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
 6485            .cloned()
 6486            .expect("expected restored workspace for unarchived thread")
 6487    });
 6488    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6489        workspace
 6490            .panel::<AgentPanel>(cx)
 6491            .expect("expected unarchive to install an agent panel in the new workspace")
 6492    });
 6493
 6494    let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6495    assert_eq!(
 6496        restored_thread_id,
 6497        Some(original_thread_id),
 6498        "expected the new workspace's agent panel to target the restored archived thread id"
 6499    );
 6500
 6501    let session_entries = cx.update(|_, cx| {
 6502        ThreadMetadataStore::global(cx)
 6503            .read(cx)
 6504            .entries()
 6505            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6506            .cloned()
 6507            .collect::<Vec<_>>()
 6508    });
 6509    assert_eq!(
 6510        session_entries.len(),
 6511        1,
 6512        "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
 6513    );
 6514    assert_eq!(
 6515        session_entries[0].thread_id, original_thread_id,
 6516        "expected restore into a new workspace to reuse the original thread id"
 6517    );
 6518    assert!(
 6519        !session_entries[0].archived,
 6520        "expected restored thread metadata to be unarchived, got: {:?}",
 6521        session_entries[0]
 6522    );
 6523
 6524    let mapped_thread_id = cx.update(|_, cx| {
 6525        ThreadMetadataStore::global(cx)
 6526            .read(cx)
 6527            .entries()
 6528            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6529            .map(|e| e.thread_id)
 6530    });
 6531    assert_eq!(
 6532        mapped_thread_id,
 6533        Some(original_thread_id),
 6534        "expected session mapping to remain stable after opening the new workspace"
 6535    );
 6536
 6537    let entries = visible_entries_as_strings(&sidebar, cx);
 6538    let real_thread_rows = entries
 6539        .iter()
 6540        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6541        .filter(|entry| !entry.contains("Draft"))
 6542        .count();
 6543    assert_eq!(
 6544        real_thread_rows, 1,
 6545        "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
 6546    );
 6547    assert!(
 6548        entries
 6549            .iter()
 6550            .any(|entry| entry.contains("Unarchived Thread")),
 6551        "expected restored thread row to be visible, got entries: {entries:?}"
 6552    );
 6553}
 6554
 6555#[gpui::test]
 6556async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
 6557    // When a workspace already exists with an empty draft and a thread
 6558    // is unarchived into it, the draft should be replaced — not kept
 6559    // alongside the loaded thread.
 6560    agent_ui::test_support::init_test(cx);
 6561    cx.update(|cx| {
 6562        ThreadStore::init_global(cx);
 6563        ThreadMetadataStore::init_global(cx);
 6564        language_model::LanguageModelRegistry::test(cx);
 6565        prompt_store::init(cx);
 6566    });
 6567
 6568    let fs = FakeFs::new(cx.executor());
 6569    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6570        .await;
 6571    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6572
 6573    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6574    let (multi_workspace, cx) =
 6575        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6576    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6577    cx.run_until_parked();
 6578
 6579    // Create a thread and send a message so it's no longer a draft.
 6580    let connection = acp_thread::StubAgentConnection::new();
 6581    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6582        acp::ContentChunk::new("Done".into()),
 6583    )]);
 6584    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6585    agent_ui::test_support::send_message(&panel, cx);
 6586    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6587    cx.run_until_parked();
 6588
 6589    // Archive the thread — the group is left empty (no draft created).
 6590    sidebar.update_in(cx, |sidebar, window, cx| {
 6591        sidebar.archive_thread(&session_id, window, cx);
 6592    });
 6593    cx.run_until_parked();
 6594
 6595    // Un-archive the thread.
 6596    let thread_id = cx.update(|_, cx| {
 6597        ThreadMetadataStore::global(cx)
 6598            .read(cx)
 6599            .entries()
 6600            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6601            .map(|e| e.thread_id)
 6602            .expect("thread should exist in store")
 6603    });
 6604    let metadata = cx.update(|_, cx| {
 6605        ThreadMetadataStore::global(cx)
 6606            .read(cx)
 6607            .entry(thread_id)
 6608            .cloned()
 6609            .expect("metadata should exist")
 6610    });
 6611
 6612    sidebar.update_in(cx, |sidebar, window, cx| {
 6613        sidebar.open_thread_from_archive(metadata, window, cx);
 6614    });
 6615    cx.run_until_parked();
 6616
 6617    // The draft should be gone — only the unarchived thread remains.
 6618    let entries = visible_entries_as_strings(&sidebar, cx);
 6619    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6620    assert_eq!(
 6621        draft_count, 0,
 6622        "expected no drafts after unarchiving, got entries: {entries:?}"
 6623    );
 6624}
 6625
 6626#[gpui::test]
 6627async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
 6628    cx: &mut TestAppContext,
 6629) {
 6630    agent_ui::test_support::init_test(cx);
 6631    cx.update(|cx| {
 6632        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6633        ThreadStore::init_global(cx);
 6634        ThreadMetadataStore::init_global(cx);
 6635        language_model::LanguageModelRegistry::test(cx);
 6636        prompt_store::init(cx);
 6637    });
 6638
 6639    let fs = FakeFs::new(cx.executor());
 6640    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6641        .await;
 6642    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6643        .await;
 6644    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6645
 6646    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6647    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6648
 6649    let (multi_workspace, cx) =
 6650        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6651    let sidebar = setup_sidebar(&multi_workspace, cx);
 6652
 6653    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 6654    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6655        mw.test_add_workspace(project_b.clone(), window, cx)
 6656    });
 6657    let _panel_b = add_agent_panel(&workspace_b, cx);
 6658    cx.run_until_parked();
 6659
 6660    multi_workspace.update_in(cx, |mw, window, cx| {
 6661        mw.activate(workspace_a.clone(), None, window, cx);
 6662    });
 6663    cx.run_until_parked();
 6664
 6665    let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
 6666    let thread_id = ThreadId::new();
 6667    cx.update(|_, cx| {
 6668        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6669            store.save(
 6670                ThreadMetadata {
 6671                    thread_id,
 6672                    session_id: Some(session_id.clone()),
 6673                    agent_id: agent::ZED_AGENT_ID.clone(),
 6674                    title: Some("Restored In Inactive Workspace".into()),
 6675                    updated_at: Utc::now(),
 6676                    created_at: None,
 6677                    interacted_at: None,
 6678                    worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 6679                        PathBuf::from("/project-b"),
 6680                    ])),
 6681                    archived: true,
 6682                    remote_connection: None,
 6683                },
 6684                cx,
 6685            )
 6686        });
 6687    });
 6688    cx.run_until_parked();
 6689
 6690    let metadata = cx.update(|_, cx| {
 6691        ThreadMetadataStore::global(cx)
 6692            .read(cx)
 6693            .entry(thread_id)
 6694            .cloned()
 6695            .expect("archived metadata should exist before restore")
 6696    });
 6697
 6698    sidebar.update_in(cx, |sidebar, window, cx| {
 6699        sidebar.open_thread_from_archive(metadata, window, cx);
 6700    });
 6701
 6702    let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
 6703        workspace.panel::<AgentPanel>(cx).expect(
 6704            "target workspace should still have an agent panel immediately after activation",
 6705        )
 6706    });
 6707    let immediate_active_thread_id =
 6708        panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6709
 6710    cx.run_until_parked();
 6711
 6712    sidebar.read_with(cx, |sidebar, _cx| {
 6713        assert_active_thread(
 6714            sidebar,
 6715            &session_id,
 6716            "unarchiving into an inactive existing workspace should end on the restored thread",
 6717        );
 6718    });
 6719
 6720    let panel_b = workspace_b.read_with(cx, |workspace, cx| {
 6721        workspace
 6722            .panel::<AgentPanel>(cx)
 6723            .expect("target workspace should still have an agent panel")
 6724    });
 6725    assert_eq!(
 6726        panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6727        Some(thread_id),
 6728        "expected target panel to activate the restored thread id"
 6729    );
 6730    assert!(
 6731        immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
 6732        "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
 6733    );
 6734
 6735    let entries = visible_entries_as_strings(&sidebar, cx);
 6736    let target_rows: Vec<_> = entries
 6737        .iter()
 6738        .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
 6739        .cloned()
 6740        .collect();
 6741    assert_eq!(
 6742        target_rows.len(),
 6743        1,
 6744        "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
 6745    );
 6746    assert!(
 6747        target_rows[0].contains("Restored In Inactive Workspace"),
 6748        "expected the remaining row to be the restored thread, got entries: {entries:?}"
 6749    );
 6750    assert!(
 6751        !target_rows[0].contains("Draft"),
 6752        "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
 6753    );
 6754}
 6755
 6756#[gpui::test]
 6757async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
 6758    cx: &mut TestAppContext,
 6759) {
 6760    agent_ui::test_support::init_test(cx);
 6761    cx.update(|cx| {
 6762        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6763        ThreadStore::init_global(cx);
 6764        ThreadMetadataStore::init_global(cx);
 6765        language_model::LanguageModelRegistry::test(cx);
 6766        prompt_store::init(cx);
 6767    });
 6768
 6769    let fs = FakeFs::new(cx.executor());
 6770    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6771        .await;
 6772    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6773        .await;
 6774    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6775
 6776    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6777    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6778
 6779    let (multi_workspace, cx) =
 6780        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6781    let sidebar = setup_sidebar(&multi_workspace, cx);
 6782
 6783    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6784        mw.test_add_workspace(project_b.clone(), window, cx)
 6785    });
 6786    let panel_b = add_agent_panel(&workspace_b, cx);
 6787    cx.run_until_parked();
 6788
 6789    let connection = acp_thread::StubAgentConnection::new();
 6790    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6791        acp::ContentChunk::new("Done".into()),
 6792    )]);
 6793    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6794    agent_ui::test_support::send_message(&panel_b, cx);
 6795    let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
 6796    save_test_thread_metadata(&session_id, &project_b, cx).await;
 6797    cx.run_until_parked();
 6798
 6799    sidebar.update_in(cx, |sidebar, window, cx| {
 6800        sidebar.archive_thread(&session_id, window, cx);
 6801    });
 6802
 6803    cx.run_until_parked();
 6804
 6805    let archived_metadata = cx.update(|_, cx| {
 6806        let store = ThreadMetadataStore::global(cx).read(cx);
 6807        let thread_id = store
 6808            .entries()
 6809            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6810            .map(|e| e.thread_id)
 6811            .expect("archived thread should still exist in metadata store");
 6812        let metadata = store
 6813            .entry(thread_id)
 6814            .cloned()
 6815            .expect("archived metadata should still exist after archive");
 6816        assert!(
 6817            metadata.archived,
 6818            "thread should be archived before project removal"
 6819        );
 6820        metadata
 6821    });
 6822
 6823    let group_key_b =
 6824        project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
 6825    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 6826        mw.remove_project_group(&group_key_b, window, cx)
 6827    });
 6828    remove_task
 6829        .await
 6830        .expect("remove project group task should complete");
 6831    cx.run_until_parked();
 6832
 6833    assert_eq!(
 6834        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6835        1,
 6836        "removing the archived thread's parent project group should remove its workspace"
 6837    );
 6838
 6839    sidebar.update_in(cx, |sidebar, window, cx| {
 6840        sidebar.open_thread_from_archive(archived_metadata.clone(), window, cx);
 6841    });
 6842    cx.run_until_parked();
 6843
 6844    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6845        mw.workspaces()
 6846            .find(|workspace| {
 6847                PathList::new(&workspace.read(cx).root_paths(cx))
 6848                    == PathList::new(&[PathBuf::from("/project-b")])
 6849            })
 6850            .cloned()
 6851            .expect("expected unarchive to recreate the removed project workspace")
 6852    });
 6853    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6854        workspace
 6855            .panel::<AgentPanel>(cx)
 6856            .expect("expected restored workspace to bootstrap an agent panel")
 6857    });
 6858
 6859    let restored_thread_id = cx.update(|_, cx| {
 6860        ThreadMetadataStore::global(cx)
 6861            .read(cx)
 6862            .entries()
 6863            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6864            .map(|e| e.thread_id)
 6865            .expect("session should still map to restored thread id")
 6866    });
 6867    assert_eq!(
 6868        restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6869        Some(restored_thread_id),
 6870        "expected unarchive after project removal to activate the restored real thread"
 6871    );
 6872
 6873    sidebar.read_with(cx, |sidebar, _cx| {
 6874        assert_active_thread(
 6875            sidebar,
 6876            &session_id,
 6877            "expected sidebar active entry to track the restored thread after project removal",
 6878        );
 6879    });
 6880
 6881    let entries = visible_entries_as_strings(&sidebar, cx);
 6882    let restored_title = archived_metadata.display_title().to_string();
 6883    let matching_rows: Vec<_> = entries
 6884        .iter()
 6885        .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
 6886        .cloned()
 6887        .collect();
 6888    assert_eq!(
 6889        matching_rows.len(),
 6890        1,
 6891        "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
 6892    );
 6893    assert!(
 6894        !matching_rows[0].contains("Draft"),
 6895        "expected no draft row after unarchive following project removal, got entries: {entries:?}"
 6896    );
 6897}
 6898
 6899#[gpui::test]
 6900async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
 6901    agent_ui::test_support::init_test(cx);
 6902    cx.update(|cx| {
 6903        ThreadStore::init_global(cx);
 6904        ThreadMetadataStore::init_global(cx);
 6905        language_model::LanguageModelRegistry::test(cx);
 6906        prompt_store::init(cx);
 6907    });
 6908
 6909    let fs = FakeFs::new(cx.executor());
 6910    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6911        .await;
 6912    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6913
 6914    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6915    let (multi_workspace, cx) =
 6916        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6917    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6918    cx.run_until_parked();
 6919
 6920    let connection = acp_thread::StubAgentConnection::new();
 6921    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6922        acp::ContentChunk::new("Done".into()),
 6923    )]);
 6924    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6925    agent_ui::test_support::send_message(&panel, cx);
 6926    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6927    cx.run_until_parked();
 6928
 6929    let original_thread_id = cx.update(|_, cx| {
 6930        ThreadMetadataStore::global(cx)
 6931            .read(cx)
 6932            .entries()
 6933            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6934            .map(|e| e.thread_id)
 6935            .expect("thread should exist in store before archiving")
 6936    });
 6937
 6938    sidebar.update_in(cx, |sidebar, window, cx| {
 6939        sidebar.archive_thread(&session_id, window, cx);
 6940    });
 6941    cx.run_until_parked();
 6942
 6943    let metadata = cx.update(|_, cx| {
 6944        ThreadMetadataStore::global(cx)
 6945            .read(cx)
 6946            .entry(original_thread_id)
 6947            .cloned()
 6948            .expect("metadata should exist after archiving")
 6949    });
 6950
 6951    sidebar.update_in(cx, |sidebar, window, cx| {
 6952        sidebar.open_thread_from_archive(metadata, window, cx);
 6953    });
 6954    cx.run_until_parked();
 6955
 6956    let session_entries = cx.update(|_, cx| {
 6957        ThreadMetadataStore::global(cx)
 6958            .read(cx)
 6959            .entries()
 6960            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6961            .cloned()
 6962            .collect::<Vec<_>>()
 6963    });
 6964
 6965    assert_eq!(
 6966        session_entries.len(),
 6967        1,
 6968        "expected exactly one metadata row for the restored session, got: {session_entries:?}"
 6969    );
 6970    assert_eq!(
 6971        session_entries[0].thread_id, original_thread_id,
 6972        "expected unarchive to reuse the original thread id instead of creating a duplicate row"
 6973    );
 6974    assert!(
 6975        session_entries[0].session_id.is_some(),
 6976        "expected restored metadata to be a real thread, got: {:?}",
 6977        session_entries[0]
 6978    );
 6979
 6980    let entries = visible_entries_as_strings(&sidebar, cx);
 6981    let real_thread_rows = entries
 6982        .iter()
 6983        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6984        .filter(|entry| !entry.contains("Draft"))
 6985        .count();
 6986    assert_eq!(
 6987        real_thread_rows, 1,
 6988        "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
 6989    );
 6990    assert!(
 6991        !entries.iter().any(|entry| entry.contains("Draft")),
 6992        "expected no draft rows after restoring, got entries: {entries:?}"
 6993    );
 6994}
 6995
 6996#[gpui::test]
 6997async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
 6998    cx: &mut TestAppContext,
 6999) {
 7000    // When a thread is archived while the user is in a different workspace,
 7001    // clear_base_view creates a draft on the archived workspace's panel.
 7002    // Switching back to that workspace shows the draft as active_entry.
 7003    agent_ui::test_support::init_test(cx);
 7004    cx.update(|cx| {
 7005        ThreadStore::init_global(cx);
 7006        ThreadMetadataStore::init_global(cx);
 7007        language_model::LanguageModelRegistry::test(cx);
 7008        prompt_store::init(cx);
 7009    });
 7010
 7011    let fs = FakeFs::new(cx.executor());
 7012    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 7013        .await;
 7014    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 7015        .await;
 7016    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7017
 7018    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 7019    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 7020
 7021    let (multi_workspace, cx) =
 7022        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 7023    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 7024
 7025    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 7026        mw.test_add_workspace(project_b.clone(), window, cx)
 7027    });
 7028    let _panel_b = add_agent_panel(&workspace_b, cx);
 7029    cx.run_until_parked();
 7030
 7031    // Create a thread in project-a's panel (currently non-active).
 7032    let connection = acp_thread::StubAgentConnection::new();
 7033    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 7034        acp::ContentChunk::new("Done".into()),
 7035    )]);
 7036    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 7037    agent_ui::test_support::send_message(&panel_a, cx);
 7038    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 7039    cx.run_until_parked();
 7040
 7041    // Archive it while project-b is active.
 7042    sidebar.update_in(cx, |sidebar, window, cx| {
 7043        sidebar.archive_thread(&thread_a, window, cx);
 7044    });
 7045    cx.run_until_parked();
 7046
 7047    // Switch back to project-a. Its panel was cleared during archiving
 7048    // (clear_base_view activated a draft), so active_entry should point
 7049    // to the draft on workspace_a.
 7050    let workspace_a =
 7051        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7052    multi_workspace.update_in(cx, |mw, window, cx| {
 7053        mw.activate(workspace_a.clone(), None, window, cx);
 7054    });
 7055    cx.run_until_parked();
 7056
 7057    sidebar.update_in(cx, |sidebar, _window, cx| {
 7058        sidebar.update_entries(cx);
 7059    });
 7060    cx.run_until_parked();
 7061
 7062    sidebar.read_with(cx, |sidebar, _| {
 7063        assert_active_draft(
 7064            sidebar,
 7065            &workspace_a,
 7066            "after switching to workspace with archived thread, active_entry should be the draft",
 7067        );
 7068    });
 7069}
 7070
 7071#[gpui::test]
 7072async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
 7073    let project = init_test_project("/my-project", cx).await;
 7074    let (multi_workspace, cx) =
 7075        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 7076    let sidebar = setup_sidebar(&multi_workspace, cx);
 7077
 7078    save_thread_metadata(
 7079        acp::SessionId::new(Arc::from("visible-thread")),
 7080        Some("Visible Thread".into()),
 7081        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7082        None,
 7083        None,
 7084        &project,
 7085        cx,
 7086    );
 7087
 7088    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
 7089    save_thread_metadata(
 7090        archived_thread_session_id.clone(),
 7091        Some("Archived Thread".into()),
 7092        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7093        None,
 7094        None,
 7095        &project,
 7096        cx,
 7097    );
 7098
 7099    cx.update(|_, cx| {
 7100        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7101            let thread_id = store
 7102                .entries()
 7103                .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
 7104                .map(|e| e.thread_id)
 7105                .unwrap();
 7106            store.archive(thread_id, None, cx)
 7107        })
 7108    });
 7109    cx.run_until_parked();
 7110
 7111    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7112    cx.run_until_parked();
 7113
 7114    let entries = visible_entries_as_strings(&sidebar, cx);
 7115    assert!(
 7116        entries.iter().any(|e| e.contains("Visible Thread")),
 7117        "expected visible thread in sidebar, got: {entries:?}"
 7118    );
 7119    assert!(
 7120        !entries.iter().any(|e| e.contains("Archived Thread")),
 7121        "expected archived thread to be hidden from sidebar, got: {entries:?}"
 7122    );
 7123
 7124    cx.update(|_, cx| {
 7125        let store = ThreadMetadataStore::global(cx);
 7126        let all: Vec<_> = store.read(cx).entries().collect();
 7127        assert_eq!(
 7128            all.len(),
 7129            2,
 7130            "expected 2 total entries in the store, got: {}",
 7131            all.len()
 7132        );
 7133
 7134        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 7135        assert_eq!(archived.len(), 1);
 7136        assert_eq!(
 7137            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 7138            "archived-thread"
 7139        );
 7140    });
 7141}
 7142
 7143#[gpui::test]
 7144async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
 7145    cx: &mut TestAppContext,
 7146) {
 7147    // When a linked worktree has a single thread and that thread is archived,
 7148    // the sidebar must NOT create a new thread on the same worktree (which
 7149    // would prevent the worktree from being cleaned up on disk). Instead,
 7150    // archive_thread switches to a sibling thread on the main workspace (or
 7151    // creates a draft there) before archiving the metadata.
 7152    agent_ui::test_support::init_test(cx);
 7153    cx.update(|cx| {
 7154        ThreadStore::init_global(cx);
 7155        ThreadMetadataStore::init_global(cx);
 7156        language_model::LanguageModelRegistry::test(cx);
 7157        prompt_store::init(cx);
 7158    });
 7159
 7160    let fs = FakeFs::new(cx.executor());
 7161
 7162    fs.insert_tree(
 7163        "/project",
 7164        serde_json::json!({
 7165            ".git": {},
 7166            "src": {},
 7167        }),
 7168    )
 7169    .await;
 7170
 7171    fs.add_linked_worktree_for_repo(
 7172        Path::new("/project/.git"),
 7173        false,
 7174        git::repository::Worktree {
 7175            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7176            ref_name: Some("refs/heads/ochre-drift".into()),
 7177            sha: "aaa".into(),
 7178            is_main: false,
 7179            is_bare: false,
 7180        },
 7181    )
 7182    .await;
 7183
 7184    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7185
 7186    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7187    let worktree_project =
 7188        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7189
 7190    main_project
 7191        .update(cx, |p, cx| p.git_scans_complete(cx))
 7192        .await;
 7193    worktree_project
 7194        .update(cx, |p, cx| p.git_scans_complete(cx))
 7195        .await;
 7196
 7197    let (multi_workspace, cx) =
 7198        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7199
 7200    let sidebar = setup_sidebar(&multi_workspace, cx);
 7201
 7202    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7203        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7204    });
 7205
 7206    // Set up both workspaces with agent panels.
 7207    let main_workspace =
 7208        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7209    let _main_panel = add_agent_panel(&main_workspace, cx);
 7210    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7211
 7212    // Activate the linked worktree workspace so the sidebar tracks it.
 7213    multi_workspace.update_in(cx, |mw, window, cx| {
 7214        mw.activate(worktree_workspace.clone(), None, window, cx);
 7215    });
 7216
 7217    // Open a thread in the linked worktree panel and send a message
 7218    // so it becomes the active thread.
 7219    let connection = StubAgentConnection::new();
 7220    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7221    send_message(&worktree_panel, cx);
 7222
 7223    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7224
 7225    // Give the thread a response chunk so it has content.
 7226    cx.update(|_, cx| {
 7227        connection.send_update(
 7228            worktree_thread_id.clone(),
 7229            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7230            cx,
 7231        );
 7232    });
 7233
 7234    // Save the worktree thread's metadata.
 7235    save_thread_metadata(
 7236        worktree_thread_id.clone(),
 7237        Some("Ochre Drift Thread".into()),
 7238        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7239        None,
 7240        None,
 7241        &worktree_project,
 7242        cx,
 7243    );
 7244
 7245    // Also save a thread on the main project so there's a sibling in the
 7246    // group that can be selected after archiving.
 7247    save_thread_metadata(
 7248        acp::SessionId::new(Arc::from("main-project-thread")),
 7249        Some("Main Project Thread".into()),
 7250        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7251        None,
 7252        None,
 7253        &main_project,
 7254        cx,
 7255    );
 7256
 7257    cx.run_until_parked();
 7258
 7259    // Verify the linked worktree thread appears with its chip.
 7260    // The live thread title comes from the message text ("Hello"), not
 7261    // the metadata title we saved.
 7262    let entries_before = visible_entries_as_strings(&sidebar, cx);
 7263    assert!(
 7264        entries_before
 7265            .iter()
 7266            .any(|s| s.contains("{wt-ochre-drift}")),
 7267        "expected worktree thread with chip before archiving, got: {entries_before:?}"
 7268    );
 7269    assert!(
 7270        entries_before
 7271            .iter()
 7272            .any(|s| s.contains("Main Project Thread")),
 7273        "expected main project thread before archiving, got: {entries_before:?}"
 7274    );
 7275
 7276    // Confirm the worktree thread is the active entry.
 7277    sidebar.read_with(cx, |s, _| {
 7278        assert_active_thread(
 7279            s,
 7280            &worktree_thread_id,
 7281            "worktree thread should be active before archiving",
 7282        );
 7283    });
 7284
 7285    // Archive the worktree thread — it's the only thread using ochre-drift.
 7286    sidebar.update_in(cx, |sidebar, window, cx| {
 7287        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7288    });
 7289
 7290    cx.run_until_parked();
 7291
 7292    // The archived thread should no longer appear in the sidebar.
 7293    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7294    assert!(
 7295        !entries_after
 7296            .iter()
 7297            .any(|s| s.contains("Ochre Drift Thread")),
 7298        "archived thread should be hidden, got: {entries_after:?}"
 7299    );
 7300
 7301    // No "+ New Thread" entry should appear with the ochre-drift worktree
 7302    // chip — that would keep the worktree alive and prevent cleanup.
 7303    assert!(
 7304        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7305        "no entry should reference the archived worktree, got: {entries_after:?}"
 7306    );
 7307
 7308    // The main project thread should still be visible.
 7309    assert!(
 7310        entries_after
 7311            .iter()
 7312            .any(|s| s.contains("Main Project Thread")),
 7313        "main project thread should still be visible, got: {entries_after:?}"
 7314    );
 7315}
 7316
 7317#[gpui::test]
 7318async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
 7319    cx: &mut TestAppContext,
 7320) {
 7321    // When a linked worktree thread is the ONLY thread in the project group
 7322    // (no threads on the main repo either), archiving it should leave the
 7323    // group empty with no active entry.
 7324    agent_ui::test_support::init_test(cx);
 7325    cx.update(|cx| {
 7326        ThreadStore::init_global(cx);
 7327        ThreadMetadataStore::init_global(cx);
 7328        language_model::LanguageModelRegistry::test(cx);
 7329        prompt_store::init(cx);
 7330    });
 7331
 7332    let fs = FakeFs::new(cx.executor());
 7333
 7334    fs.insert_tree(
 7335        "/project",
 7336        serde_json::json!({
 7337            ".git": {},
 7338            "src": {},
 7339        }),
 7340    )
 7341    .await;
 7342
 7343    fs.add_linked_worktree_for_repo(
 7344        Path::new("/project/.git"),
 7345        false,
 7346        git::repository::Worktree {
 7347            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7348            ref_name: Some("refs/heads/ochre-drift".into()),
 7349            sha: "aaa".into(),
 7350            is_main: false,
 7351            is_bare: false,
 7352        },
 7353    )
 7354    .await;
 7355
 7356    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7357
 7358    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7359    let worktree_project =
 7360        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7361
 7362    main_project
 7363        .update(cx, |p, cx| p.git_scans_complete(cx))
 7364        .await;
 7365    worktree_project
 7366        .update(cx, |p, cx| p.git_scans_complete(cx))
 7367        .await;
 7368
 7369    let (multi_workspace, cx) =
 7370        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7371
 7372    let sidebar = setup_sidebar(&multi_workspace, cx);
 7373
 7374    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7375        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7376    });
 7377
 7378    let main_workspace =
 7379        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7380    let _main_panel = add_agent_panel(&main_workspace, cx);
 7381    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7382
 7383    // Activate the linked worktree workspace.
 7384    multi_workspace.update_in(cx, |mw, window, cx| {
 7385        mw.activate(worktree_workspace.clone(), None, window, cx);
 7386    });
 7387
 7388    // Open a thread on the linked worktree — this is the ONLY thread.
 7389    let connection = StubAgentConnection::new();
 7390    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7391    send_message(&worktree_panel, cx);
 7392
 7393    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7394
 7395    cx.update(|_, cx| {
 7396        connection.send_update(
 7397            worktree_thread_id.clone(),
 7398            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7399            cx,
 7400        );
 7401    });
 7402
 7403    save_thread_metadata(
 7404        worktree_thread_id.clone(),
 7405        Some("Ochre Drift Thread".into()),
 7406        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7407        None,
 7408        None,
 7409        &worktree_project,
 7410        cx,
 7411    );
 7412
 7413    cx.run_until_parked();
 7414
 7415    // Archive it — there are no other threads in the group.
 7416    sidebar.update_in(cx, |sidebar, window, cx| {
 7417        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7418    });
 7419
 7420    cx.run_until_parked();
 7421
 7422    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7423
 7424    // No entry should reference the linked worktree.
 7425    assert!(
 7426        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7427        "no entry should reference the archived worktree, got: {entries_after:?}"
 7428    );
 7429
 7430    // The active entry should be None — no draft is created.
 7431    sidebar.read_with(cx, |s, _| {
 7432        assert!(
 7433            s.active_entry.is_none(),
 7434            "expected no active entry after archiving the last thread, got: {:?}",
 7435            s.active_entry,
 7436        );
 7437    });
 7438}
 7439
 7440#[gpui::test]
 7441async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
 7442    cx: &mut TestAppContext,
 7443) {
 7444    // When an archived thread belongs to a linked worktree whose main repo is
 7445    // already open, unarchiving should reopen the linked workspace into the
 7446    // same project group and show only the restored real thread row.
 7447    agent_ui::test_support::init_test(cx);
 7448    cx.update(|cx| {
 7449        ThreadStore::init_global(cx);
 7450        ThreadMetadataStore::init_global(cx);
 7451        language_model::LanguageModelRegistry::test(cx);
 7452        prompt_store::init(cx);
 7453    });
 7454
 7455    let fs = FakeFs::new(cx.executor());
 7456
 7457    fs.insert_tree(
 7458        "/project",
 7459        serde_json::json!({
 7460            ".git": {},
 7461            "src": {},
 7462        }),
 7463    )
 7464    .await;
 7465
 7466    fs.insert_tree(
 7467        "/wt-ochre-drift",
 7468        serde_json::json!({
 7469            ".git": "gitdir: /project/.git/worktrees/ochre-drift",
 7470            "src": {},
 7471        }),
 7472    )
 7473    .await;
 7474
 7475    fs.add_linked_worktree_for_repo(
 7476        Path::new("/project/.git"),
 7477        false,
 7478        git::repository::Worktree {
 7479            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7480            ref_name: Some("refs/heads/ochre-drift".into()),
 7481            sha: "aaa".into(),
 7482            is_main: false,
 7483            is_bare: false,
 7484        },
 7485    )
 7486    .await;
 7487
 7488    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7489
 7490    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7491    let worktree_project =
 7492        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7493
 7494    main_project
 7495        .update(cx, |p, cx| p.git_scans_complete(cx))
 7496        .await;
 7497    worktree_project
 7498        .update(cx, |p, cx| p.git_scans_complete(cx))
 7499        .await;
 7500
 7501    let (multi_workspace, cx) =
 7502        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7503
 7504    let sidebar = setup_sidebar(&multi_workspace, cx);
 7505    let main_workspace =
 7506        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7507    let _main_panel = add_agent_panel(&main_workspace, cx);
 7508    cx.run_until_parked();
 7509
 7510    let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
 7511    let original_thread_id = ThreadId::new();
 7512    let main_paths = PathList::new(&[PathBuf::from("/project")]);
 7513    let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
 7514
 7515    cx.update(|_, cx| {
 7516        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7517            store.save(
 7518                ThreadMetadata {
 7519                    thread_id: original_thread_id,
 7520                    session_id: Some(session_id.clone()),
 7521                    agent_id: agent::ZED_AGENT_ID.clone(),
 7522                    title: Some("Unarchived Linked Thread".into()),
 7523                    updated_at: Utc::now(),
 7524                    created_at: None,
 7525                    interacted_at: None,
 7526                    worktree_paths: WorktreePaths::from_path_lists(
 7527                        main_paths.clone(),
 7528                        folder_paths.clone(),
 7529                    )
 7530                    .expect("main and folder paths should be well-formed"),
 7531                    archived: true,
 7532                    remote_connection: None,
 7533                },
 7534                cx,
 7535            )
 7536        });
 7537    });
 7538    cx.run_until_parked();
 7539
 7540    let metadata = cx.update(|_, cx| {
 7541        ThreadMetadataStore::global(cx)
 7542            .read(cx)
 7543            .entry(original_thread_id)
 7544            .cloned()
 7545            .expect("archived linked-worktree metadata should exist before restore")
 7546    });
 7547
 7548    sidebar.update_in(cx, |sidebar, window, cx| {
 7549        sidebar.open_thread_from_archive(metadata, window, cx);
 7550    });
 7551    cx.run_until_parked();
 7552
 7553    assert_eq!(
 7554        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7555        2,
 7556        "expected unarchive to open the linked worktree workspace into the project group"
 7557    );
 7558
 7559    let session_entries = cx.update(|_, cx| {
 7560        ThreadMetadataStore::global(cx)
 7561            .read(cx)
 7562            .entries()
 7563            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7564            .cloned()
 7565            .collect::<Vec<_>>()
 7566    });
 7567    assert_eq!(
 7568        session_entries.len(),
 7569        1,
 7570        "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
 7571    );
 7572    assert_eq!(
 7573        session_entries[0].thread_id, original_thread_id,
 7574        "expected unarchive to reuse the original linked worktree thread id"
 7575    );
 7576    assert!(
 7577        !session_entries[0].archived,
 7578        "expected restored linked worktree metadata to be unarchived, got: {:?}",
 7579        session_entries[0]
 7580    );
 7581
 7582    let assert_no_extra_rows = |entries: &[String]| {
 7583        let real_thread_rows = entries
 7584            .iter()
 7585            .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7586            .filter(|entry| !entry.contains("Draft"))
 7587            .count();
 7588        assert_eq!(
 7589            real_thread_rows, 1,
 7590            "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
 7591        );
 7592        assert!(
 7593            !entries.iter().any(|entry| entry.contains("Draft")),
 7594            "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
 7595        );
 7596        assert!(
 7597            !entries
 7598                .iter()
 7599                .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
 7600            "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
 7601        );
 7602        assert!(
 7603            entries
 7604                .iter()
 7605                .any(|entry| entry.contains("Unarchived Linked Thread")),
 7606            "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
 7607        );
 7608    };
 7609
 7610    let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
 7611    assert_no_extra_rows(&entries_after_restore);
 7612
 7613    // The reported bug may only appear after an extra scheduling turn.
 7614    cx.run_until_parked();
 7615
 7616    let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
 7617    assert_no_extra_rows(&entries_after_extra_turns);
 7618}
 7619
 7620#[gpui::test]
 7621async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
 7622    // When a linked worktree thread is archived but the group has other
 7623    // threads (e.g. on the main project), archive_thread should select
 7624    // the nearest sibling.
 7625    agent_ui::test_support::init_test(cx);
 7626    cx.update(|cx| {
 7627        ThreadStore::init_global(cx);
 7628        ThreadMetadataStore::init_global(cx);
 7629        language_model::LanguageModelRegistry::test(cx);
 7630        prompt_store::init(cx);
 7631    });
 7632
 7633    let fs = FakeFs::new(cx.executor());
 7634
 7635    fs.insert_tree(
 7636        "/project",
 7637        serde_json::json!({
 7638            ".git": {},
 7639            "src": {},
 7640        }),
 7641    )
 7642    .await;
 7643
 7644    fs.add_linked_worktree_for_repo(
 7645        Path::new("/project/.git"),
 7646        false,
 7647        git::repository::Worktree {
 7648            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7649            ref_name: Some("refs/heads/ochre-drift".into()),
 7650            sha: "aaa".into(),
 7651            is_main: false,
 7652            is_bare: false,
 7653        },
 7654    )
 7655    .await;
 7656
 7657    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7658
 7659    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7660    let worktree_project =
 7661        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7662
 7663    main_project
 7664        .update(cx, |p, cx| p.git_scans_complete(cx))
 7665        .await;
 7666    worktree_project
 7667        .update(cx, |p, cx| p.git_scans_complete(cx))
 7668        .await;
 7669
 7670    let (multi_workspace, cx) =
 7671        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7672
 7673    let sidebar = setup_sidebar(&multi_workspace, cx);
 7674
 7675    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7676        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7677    });
 7678
 7679    let main_workspace =
 7680        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7681    let _main_panel = add_agent_panel(&main_workspace, cx);
 7682    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7683
 7684    // Activate the linked worktree workspace.
 7685    multi_workspace.update_in(cx, |mw, window, cx| {
 7686        mw.activate(worktree_workspace.clone(), None, window, cx);
 7687    });
 7688
 7689    // Open a thread on the linked worktree.
 7690    let connection = StubAgentConnection::new();
 7691    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7692    send_message(&worktree_panel, cx);
 7693
 7694    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7695
 7696    cx.update(|_, cx| {
 7697        connection.send_update(
 7698            worktree_thread_id.clone(),
 7699            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7700            cx,
 7701        );
 7702    });
 7703
 7704    save_thread_metadata(
 7705        worktree_thread_id.clone(),
 7706        Some("Ochre Drift Thread".into()),
 7707        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7708        None,
 7709        None,
 7710        &worktree_project,
 7711        cx,
 7712    );
 7713
 7714    // Save a sibling thread on the main project.
 7715    let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
 7716    save_thread_metadata(
 7717        main_thread_id,
 7718        Some("Main Project Thread".into()),
 7719        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7720        None,
 7721        None,
 7722        &main_project,
 7723        cx,
 7724    );
 7725
 7726    cx.run_until_parked();
 7727
 7728    // Confirm the worktree thread is active.
 7729    sidebar.read_with(cx, |s, _| {
 7730        assert_active_thread(
 7731            s,
 7732            &worktree_thread_id,
 7733            "worktree thread should be active before archiving",
 7734        );
 7735    });
 7736
 7737    // Archive the worktree thread.
 7738    sidebar.update_in(cx, |sidebar, window, cx| {
 7739        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7740    });
 7741
 7742    cx.run_until_parked();
 7743
 7744    // The worktree workspace was removed and a draft was created on the
 7745    // main workspace. No entry should reference the linked worktree.
 7746    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7747    assert!(
 7748        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7749        "no entry should reference the archived worktree, got: {entries_after:?}"
 7750    );
 7751
 7752    // The main project thread should still be visible.
 7753    assert!(
 7754        entries_after
 7755            .iter()
 7756            .any(|s| s.contains("Main Project Thread")),
 7757        "main project thread should still be visible, got: {entries_after:?}"
 7758    );
 7759}
 7760
 7761// TODO: Restore this test once linked worktree draft entries are re-implemented.
 7762// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
 7763#[gpui::test]
 7764#[ignore = "linked worktree draft entries not yet implemented"]
 7765async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
 7766    init_test(cx);
 7767    let fs = FakeFs::new(cx.executor());
 7768
 7769    fs.insert_tree(
 7770        "/project",
 7771        serde_json::json!({
 7772            ".git": {
 7773                "worktrees": {
 7774                    "feature-a": {
 7775                        "commondir": "../../",
 7776                        "HEAD": "ref: refs/heads/feature-a",
 7777                    },
 7778                },
 7779            },
 7780            "src": {},
 7781        }),
 7782    )
 7783    .await;
 7784
 7785    fs.insert_tree(
 7786        "/wt-feature-a",
 7787        serde_json::json!({
 7788            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7789            "src": {},
 7790        }),
 7791    )
 7792    .await;
 7793
 7794    fs.add_linked_worktree_for_repo(
 7795        Path::new("/project/.git"),
 7796        false,
 7797        git::repository::Worktree {
 7798            path: PathBuf::from("/wt-feature-a"),
 7799            ref_name: Some("refs/heads/feature-a".into()),
 7800            sha: "aaa".into(),
 7801            is_main: false,
 7802            is_bare: false,
 7803        },
 7804    )
 7805    .await;
 7806
 7807    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7808
 7809    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7810    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7811
 7812    main_project
 7813        .update(cx, |p, cx| p.git_scans_complete(cx))
 7814        .await;
 7815    worktree_project
 7816        .update(cx, |p, cx| p.git_scans_complete(cx))
 7817        .await;
 7818
 7819    let (multi_workspace, cx) =
 7820        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7821    let sidebar = setup_sidebar(&multi_workspace, cx);
 7822
 7823    // Open the linked worktree as a separate workspace (simulates cmd-o).
 7824    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7825        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7826    });
 7827    add_agent_panel(&worktree_workspace, cx);
 7828    cx.run_until_parked();
 7829
 7830    // Explicitly create a draft thread from the linked worktree workspace.
 7831    // Auto-created drafts use the group's first workspace (the main one),
 7832    // so a user-created draft is needed to make the linked worktree reachable.
 7833    sidebar.update_in(cx, |sidebar, window, cx| {
 7834        sidebar.create_new_thread(&worktree_workspace, window, cx);
 7835    });
 7836    cx.run_until_parked();
 7837
 7838    // Switch back to the main workspace.
 7839    multi_workspace.update_in(cx, |mw, window, cx| {
 7840        let main_ws = mw.workspaces().next().unwrap().clone();
 7841        mw.activate(main_ws, None, window, cx);
 7842    });
 7843    cx.run_until_parked();
 7844
 7845    sidebar.update_in(cx, |sidebar, _window, cx| {
 7846        sidebar.update_entries(cx);
 7847    });
 7848    cx.run_until_parked();
 7849
 7850    // The linked worktree workspace must be reachable from some sidebar entry.
 7851    let worktree_ws_id = worktree_workspace.entity_id();
 7852    let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
 7853        let mw = multi_workspace.read(cx);
 7854        sidebar
 7855            .contents
 7856            .entries
 7857            .iter()
 7858            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 7859            .map(|ws| ws.entity_id())
 7860            .collect()
 7861    });
 7862    assert!(
 7863        reachable.contains(&worktree_ws_id),
 7864        "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
 7865    );
 7866
 7867    // Find the draft Thread entry whose workspace is the linked worktree.
 7868    let _ = (worktree_ws_id, sidebar, multi_workspace);
 7869    // todo("re-implement once linked worktree draft entries exist");
 7870}
 7871
 7872#[gpui::test]
 7873async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
 7874    // When only a linked worktree workspace is open (not the main repo),
 7875    // threads saved against the main repo should still appear in the sidebar.
 7876    init_test(cx);
 7877    let fs = FakeFs::new(cx.executor());
 7878
 7879    // Create the main repo with a linked worktree.
 7880    fs.insert_tree(
 7881        "/project",
 7882        serde_json::json!({
 7883            ".git": {
 7884                "worktrees": {
 7885                    "feature-a": {
 7886                        "commondir": "../../",
 7887                        "HEAD": "ref: refs/heads/feature-a",
 7888                    },
 7889                },
 7890            },
 7891            "src": {},
 7892        }),
 7893    )
 7894    .await;
 7895
 7896    fs.insert_tree(
 7897        "/wt-feature-a",
 7898        serde_json::json!({
 7899            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7900            "src": {},
 7901        }),
 7902    )
 7903    .await;
 7904
 7905    fs.add_linked_worktree_for_repo(
 7906        std::path::Path::new("/project/.git"),
 7907        false,
 7908        git::repository::Worktree {
 7909            path: std::path::PathBuf::from("/wt-feature-a"),
 7910            ref_name: Some("refs/heads/feature-a".into()),
 7911            sha: "abc".into(),
 7912            is_main: false,
 7913            is_bare: false,
 7914        },
 7915    )
 7916    .await;
 7917
 7918    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7919
 7920    // Only open the linked worktree as a workspace — NOT the main repo.
 7921    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 7922    worktree_project
 7923        .update(cx, |p, cx| p.git_scans_complete(cx))
 7924        .await;
 7925
 7926    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7927    main_project
 7928        .update(cx, |p, cx| p.git_scans_complete(cx))
 7929        .await;
 7930
 7931    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 7932        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 7933    });
 7934    let sidebar = setup_sidebar(&multi_workspace, cx);
 7935
 7936    // Save a thread against the MAIN repo path.
 7937    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
 7938
 7939    // Save a thread against the linked worktree path.
 7940    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
 7941
 7942    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7943    cx.run_until_parked();
 7944
 7945    // Both threads should be visible: the worktree thread by direct lookup,
 7946    // and the main repo thread because the workspace is a linked worktree
 7947    // and we also query the main repo path.
 7948    let entries = visible_entries_as_strings(&sidebar, cx);
 7949    assert!(
 7950        entries.iter().any(|e| e.contains("Main Repo Thread")),
 7951        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
 7952    );
 7953    assert!(
 7954        entries.iter().any(|e| e.contains("Worktree Thread")),
 7955        "expected worktree thread to be visible, got: {entries:?}"
 7956    );
 7957}
 7958
 7959async fn init_multi_project_test(
 7960    paths: &[&str],
 7961    cx: &mut TestAppContext,
 7962) -> (Arc<FakeFs>, Entity<project::Project>) {
 7963    agent_ui::test_support::init_test(cx);
 7964    cx.update(|cx| {
 7965        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 7966        ThreadStore::init_global(cx);
 7967        ThreadMetadataStore::init_global(cx);
 7968        language_model::LanguageModelRegistry::test(cx);
 7969        prompt_store::init(cx);
 7970    });
 7971    let fs = FakeFs::new(cx.executor());
 7972    for path in paths {
 7973        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
 7974            .await;
 7975    }
 7976    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7977    let project =
 7978        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
 7979    (fs, project)
 7980}
 7981
 7982async fn add_test_project(
 7983    path: &str,
 7984    fs: &Arc<FakeFs>,
 7985    multi_workspace: &Entity<MultiWorkspace>,
 7986    cx: &mut gpui::VisualTestContext,
 7987) -> Entity<Workspace> {
 7988    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
 7989    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7990        mw.test_add_workspace(project, window, cx)
 7991    });
 7992    cx.run_until_parked();
 7993    workspace
 7994}
 7995
 7996#[gpui::test]
 7997async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
 7998    let (fs, project_a) =
 7999        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8000    let (multi_workspace, cx) =
 8001        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8002    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
 8003
 8004    // Sidebar starts closed. Initial workspace A is transient.
 8005    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8006    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 8007    assert_eq!(
 8008        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8009        1
 8010    );
 8011    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
 8012
 8013    // Add B — replaces A as the transient workspace.
 8014    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8015    assert_eq!(
 8016        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8017        1
 8018    );
 8019    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8020
 8021    // Add C — replaces B as the transient workspace.
 8022    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8023    assert_eq!(
 8024        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8025        1
 8026    );
 8027    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8028}
 8029
 8030#[gpui::test]
 8031async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
 8032    let (fs, project_a) = init_multi_project_test(
 8033        &["/project-a", "/project-b", "/project-c", "/project-d"],
 8034        cx,
 8035    )
 8036    .await;
 8037    let (multi_workspace, cx) =
 8038        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8039    let _sidebar = setup_sidebar(&multi_workspace, cx);
 8040    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 8041
 8042    // Add B — retained since sidebar is open.
 8043    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8044    assert_eq!(
 8045        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8046        2
 8047    );
 8048
 8049    // Switch to A — B survives. (Switching from one internal workspace, to another)
 8050    multi_workspace.update_in(cx, |mw, window, cx| {
 8051        mw.activate(workspace_a, None, window, cx)
 8052    });
 8053    cx.run_until_parked();
 8054    assert_eq!(
 8055        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8056        2
 8057    );
 8058
 8059    // Close sidebar — both A and B remain retained.
 8060    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
 8061    cx.run_until_parked();
 8062    assert_eq!(
 8063        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8064        2
 8065    );
 8066
 8067    // Add C — added as new transient workspace. (switching from retained, to transient)
 8068    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8069    assert_eq!(
 8070        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8071        3
 8072    );
 8073    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8074
 8075    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
 8076    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
 8077    assert_eq!(
 8078        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8079        3
 8080    );
 8081    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
 8082}
 8083
 8084#[gpui::test]
 8085async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
 8086    let (fs, project_a) =
 8087        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8088    let (multi_workspace, cx) =
 8089        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8090    setup_sidebar_closed(&multi_workspace, cx);
 8091
 8092    // Add B — replaces A as the transient workspace (A is discarded).
 8093    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8094    assert_eq!(
 8095        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8096        1
 8097    );
 8098    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8099
 8100    // Open sidebar — promotes the transient B to retained.
 8101    multi_workspace.update_in(cx, |mw, window, cx| {
 8102        mw.toggle_sidebar(window, cx);
 8103    });
 8104    cx.run_until_parked();
 8105    assert_eq!(
 8106        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8107        1
 8108    );
 8109    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
 8110
 8111    // Close sidebar — the retained B remains.
 8112    multi_workspace.update_in(cx, |mw, window, cx| {
 8113        mw.toggle_sidebar(window, cx);
 8114    });
 8115
 8116    // Add C — added as new transient workspace.
 8117    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8118    assert_eq!(
 8119        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8120        2
 8121    );
 8122    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8123}
 8124
 8125#[gpui::test]
 8126async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
 8127    init_test(cx);
 8128    let fs = FakeFs::new(cx.executor());
 8129
 8130    fs.insert_tree(
 8131        "/project",
 8132        serde_json::json!({
 8133            ".git": {
 8134                "worktrees": {
 8135                    "feature-a": {
 8136                        "commondir": "../../",
 8137                        "HEAD": "ref: refs/heads/feature-a",
 8138                    },
 8139                },
 8140            },
 8141            "src": {},
 8142        }),
 8143    )
 8144    .await;
 8145
 8146    fs.insert_tree(
 8147        "/wt-feature-a",
 8148        serde_json::json!({
 8149            ".git": "gitdir: /project/.git/worktrees/feature-a",
 8150            "src": {},
 8151        }),
 8152    )
 8153    .await;
 8154
 8155    fs.add_linked_worktree_for_repo(
 8156        Path::new("/project/.git"),
 8157        false,
 8158        git::repository::Worktree {
 8159            path: PathBuf::from("/wt-feature-a"),
 8160            ref_name: Some("refs/heads/feature-a".into()),
 8161            sha: "abc".into(),
 8162            is_main: false,
 8163            is_bare: false,
 8164        },
 8165    )
 8166    .await;
 8167
 8168    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8169
 8170    // Only a linked worktree workspace is open — no workspace for /project.
 8171    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8172    worktree_project
 8173        .update(cx, |p, cx| p.git_scans_complete(cx))
 8174        .await;
 8175
 8176    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 8177        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 8178    });
 8179    let sidebar = setup_sidebar(&multi_workspace, cx);
 8180
 8181    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
 8182    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
 8183    cx.update(|_, cx| {
 8184        let metadata = ThreadMetadata {
 8185            thread_id: ThreadId::new(),
 8186            session_id: Some(legacy_session.clone()),
 8187            agent_id: agent::ZED_AGENT_ID.clone(),
 8188            title: Some("Legacy Main Thread".into()),
 8189            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8190            created_at: None,
 8191            interacted_at: None,
 8192            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 8193                "/project",
 8194            )])),
 8195            archived: false,
 8196            remote_connection: None,
 8197        };
 8198        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
 8199    });
 8200    cx.run_until_parked();
 8201
 8202    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 8203    cx.run_until_parked();
 8204
 8205    // The legacy thread should appear in the sidebar under the project group.
 8206    let entries = visible_entries_as_strings(&sidebar, cx);
 8207    assert!(
 8208        entries.iter().any(|e| e.contains("Legacy Main Thread")),
 8209        "legacy thread should be visible: {entries:?}",
 8210    );
 8211
 8212    // Verify only 1 workspace before clicking.
 8213    assert_eq!(
 8214        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8215        1,
 8216    );
 8217
 8218    // Focus and select the legacy thread, then confirm.
 8219    focus_sidebar(&sidebar, cx);
 8220    let thread_index = sidebar.read_with(cx, |sidebar, _| {
 8221        sidebar
 8222            .contents
 8223            .entries
 8224            .iter()
 8225            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
 8226            .expect("legacy thread should be in entries")
 8227    });
 8228    sidebar.update_in(cx, |sidebar, _window, _cx| {
 8229        sidebar.selection = Some(thread_index);
 8230    });
 8231    cx.dispatch_action(Confirm);
 8232    cx.run_until_parked();
 8233
 8234    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8235    let new_path_list =
 8236        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 8237    assert_eq!(
 8238        new_path_list,
 8239        PathList::new(&[PathBuf::from("/project")]),
 8240        "the new workspace should be for the main repo, not the linked worktree",
 8241    );
 8242}
 8243
 8244#[gpui::test]
 8245async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
 8246    cx: &mut TestAppContext,
 8247) {
 8248    // Regression test for a property-test finding:
 8249    //   AddLinkedWorktree { project_group_index: 0 }
 8250    //   AddProject { use_worktree: true }
 8251    //   AddProject { use_worktree: false }
 8252    // After these three steps, the linked-worktree workspace was not
 8253    // reachable from any sidebar entry.
 8254    agent_ui::test_support::init_test(cx);
 8255    cx.update(|cx| {
 8256        ThreadStore::init_global(cx);
 8257        ThreadMetadataStore::init_global(cx);
 8258        language_model::LanguageModelRegistry::test(cx);
 8259        prompt_store::init(cx);
 8260
 8261        cx.observe_new(
 8262            |workspace: &mut Workspace,
 8263             window: Option<&mut Window>,
 8264             cx: &mut gpui::Context<Workspace>| {
 8265                if let Some(window) = window {
 8266                    let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 8267                    workspace.add_panel(panel, window, cx);
 8268                }
 8269            },
 8270        )
 8271        .detach();
 8272    });
 8273
 8274    let fs = FakeFs::new(cx.executor());
 8275    fs.insert_tree(
 8276        "/my-project",
 8277        serde_json::json!({
 8278            ".git": {},
 8279            "src": {},
 8280        }),
 8281    )
 8282    .await;
 8283    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8284    let project =
 8285        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
 8286    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8287
 8288    let (multi_workspace, cx) =
 8289        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8290    let sidebar = setup_sidebar(&multi_workspace, cx);
 8291
 8292    // Step 1: Create a linked worktree for the main project.
 8293    let worktree_name = "wt-0";
 8294    let worktree_path = "/worktrees/wt-0";
 8295
 8296    fs.insert_tree(
 8297        worktree_path,
 8298        serde_json::json!({
 8299            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8300            "src": {},
 8301        }),
 8302    )
 8303    .await;
 8304    fs.insert_tree(
 8305        "/my-project/.git/worktrees/wt-0",
 8306        serde_json::json!({
 8307            "commondir": "../../",
 8308            "HEAD": "ref: refs/heads/wt-0",
 8309        }),
 8310    )
 8311    .await;
 8312    fs.add_linked_worktree_for_repo(
 8313        Path::new("/my-project/.git"),
 8314        false,
 8315        git::repository::Worktree {
 8316            path: PathBuf::from(worktree_path),
 8317            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 8318            sha: "aaa".into(),
 8319            is_main: false,
 8320            is_bare: false,
 8321        },
 8322    )
 8323    .await;
 8324
 8325    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8326    let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 8327    main_project
 8328        .update(cx, |p, cx| p.git_scans_complete(cx))
 8329        .await;
 8330    cx.run_until_parked();
 8331
 8332    // Step 2: Open the linked worktree as its own workspace.
 8333    let worktree_project =
 8334        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
 8335    worktree_project
 8336        .update(cx, |p, cx| p.git_scans_complete(cx))
 8337        .await;
 8338    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8339        mw.test_add_workspace(worktree_project.clone(), window, cx)
 8340    });
 8341    cx.run_until_parked();
 8342
 8343    // Step 3: Add an unrelated project.
 8344    fs.insert_tree(
 8345        "/other-project",
 8346        serde_json::json!({
 8347            ".git": {},
 8348            "src": {},
 8349        }),
 8350    )
 8351    .await;
 8352    let other_project = project::Project::test(
 8353        fs.clone() as Arc<dyn fs::Fs>,
 8354        ["/other-project".as_ref()],
 8355        cx,
 8356    )
 8357    .await;
 8358    other_project
 8359        .update(cx, |p, cx| p.git_scans_complete(cx))
 8360        .await;
 8361    multi_workspace.update_in(cx, |mw, window, cx| {
 8362        mw.test_add_workspace(other_project.clone(), window, cx);
 8363    });
 8364    cx.run_until_parked();
 8365
 8366    // Force a full sidebar rebuild with all groups expanded.
 8367    sidebar.update_in(cx, |sidebar, _window, cx| {
 8368        if let Some(mw) = sidebar.multi_workspace.upgrade() {
 8369            mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 8370        }
 8371        sidebar.update_entries(cx);
 8372    });
 8373    cx.run_until_parked();
 8374
 8375    // The linked-worktree workspace must be reachable from at least one
 8376    // sidebar entry — otherwise the user has no way to navigate to it.
 8377    let worktree_ws_id = worktree_workspace.entity_id();
 8378    let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
 8379        let mw = multi_workspace.read(cx);
 8380
 8381        let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
 8382        let reachable: HashSet<gpui::EntityId> = sidebar
 8383            .contents
 8384            .entries
 8385            .iter()
 8386            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 8387            .map(|ws| ws.entity_id())
 8388            .collect();
 8389        (all, reachable)
 8390    });
 8391
 8392    let unreachable = &all_ids - &reachable_ids;
 8393    eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
 8394
 8395    assert!(
 8396        unreachable.is_empty(),
 8397        "workspaces not reachable from any sidebar entry: {:?}\n\
 8398         (linked-worktree workspace id: {:?})",
 8399        unreachable,
 8400        worktree_ws_id,
 8401    );
 8402}
 8403
 8404#[gpui::test]
 8405async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
 8406    // Empty project groups no longer auto-create drafts via reconciliation.
 8407    // A fresh startup with no restorable thread should show only the header.
 8408    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8409    let (multi_workspace, cx) =
 8410        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8411    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8412
 8413    let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8414
 8415    let entries = visible_entries_as_strings(&sidebar, cx);
 8416    assert_eq!(
 8417        entries,
 8418        vec!["v [my-project]"],
 8419        "empty group should show only the header, no auto-created draft"
 8420    );
 8421}
 8422
 8423#[gpui::test]
 8424async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
 8425    // Rule 5: When the app starts and the AgentPanel successfully loads
 8426    // a thread, no spurious draft should appear.
 8427    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8428    let (multi_workspace, cx) =
 8429        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8430    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8431
 8432    // Create and send a message to make a real thread.
 8433    let connection = StubAgentConnection::new();
 8434    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8435        acp::ContentChunk::new("Done".into()),
 8436    )]);
 8437    open_thread_with_connection(&panel, connection, cx);
 8438    send_message(&panel, cx);
 8439    let session_id = active_session_id(&panel, cx);
 8440    save_test_thread_metadata(&session_id, &project, cx).await;
 8441    cx.run_until_parked();
 8442
 8443    // Should show the thread, NOT a spurious draft.
 8444    let entries = visible_entries_as_strings(&sidebar, cx);
 8445    assert_eq!(entries, vec!["v [my-project]", "  Hello *"]);
 8446
 8447    // active_entry should be Thread, not Draft.
 8448    sidebar.read_with(cx, |sidebar, _| {
 8449        assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
 8450    });
 8451}
 8452
 8453#[gpui::test]
 8454async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
 8455    // Rule 9: Clicking a project header should restore whatever the
 8456    // user was last looking at in that group, not create new drafts
 8457    // or jump to the first entry.
 8458    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 8459    let (multi_workspace, cx) =
 8460        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8461    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8462
 8463    // Create two threads in project-a.
 8464    let conn1 = StubAgentConnection::new();
 8465    conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8466        acp::ContentChunk::new("Done".into()),
 8467    )]);
 8468    open_thread_with_connection(&panel_a, conn1, cx);
 8469    send_message(&panel_a, cx);
 8470    let thread_a1 = active_session_id(&panel_a, cx);
 8471    save_test_thread_metadata(&thread_a1, &project_a, cx).await;
 8472
 8473    let conn2 = StubAgentConnection::new();
 8474    conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8475        acp::ContentChunk::new("Done".into()),
 8476    )]);
 8477    open_thread_with_connection(&panel_a, conn2, cx);
 8478    send_message(&panel_a, cx);
 8479    let thread_a2 = active_session_id(&panel_a, cx);
 8480    save_test_thread_metadata(&thread_a2, &project_a, cx).await;
 8481    cx.run_until_parked();
 8482
 8483    // The user is now looking at thread_a2.
 8484    sidebar.read_with(cx, |sidebar, _| {
 8485        assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
 8486    });
 8487
 8488    // Add project-b and switch to it.
 8489    let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
 8490    fs.as_fake()
 8491        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 8492        .await;
 8493    let project_b =
 8494        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8495    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8496        mw.test_add_workspace(project_b.clone(), window, cx)
 8497    });
 8498    let _panel_b = add_agent_panel(&workspace_b, cx);
 8499    cx.run_until_parked();
 8500
 8501    // Now switch BACK to project-a by activating its workspace.
 8502    let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
 8503        mw.workspaces()
 8504            .find(|ws| {
 8505                ws.read(cx)
 8506                    .project()
 8507                    .read(cx)
 8508                    .visible_worktrees(cx)
 8509                    .any(|wt| {
 8510                        wt.read(cx)
 8511                            .abs_path()
 8512                            .to_string_lossy()
 8513                            .contains("project-a")
 8514                    })
 8515            })
 8516            .unwrap()
 8517            .clone()
 8518    });
 8519    multi_workspace.update_in(cx, |mw, window, cx| {
 8520        mw.activate(workspace_a.clone(), None, window, cx);
 8521    });
 8522    cx.run_until_parked();
 8523
 8524    // The panel should still show thread_a2 (the last thing the user
 8525    // was viewing in project-a), not a draft or thread_a1.
 8526    sidebar.read_with(cx, |sidebar, _| {
 8527        assert_active_thread(
 8528            sidebar,
 8529            &thread_a2,
 8530            "switching back to project-a should restore thread_a2",
 8531        );
 8532    });
 8533
 8534    // No spurious draft entries should have been created in
 8535    // project-a's group (project-b may have a placeholder).
 8536    let entries = visible_entries_as_strings(&sidebar, cx);
 8537    // Find project-a's section and check it has no drafts.
 8538    let project_a_start = entries
 8539        .iter()
 8540        .position(|e| e.contains("project-a"))
 8541        .unwrap();
 8542    let project_a_end = entries[project_a_start + 1..]
 8543        .iter()
 8544        .position(|e| e.starts_with("v "))
 8545        .map(|i| i + project_a_start + 1)
 8546        .unwrap_or(entries.len());
 8547    let project_a_drafts = entries[project_a_start..project_a_end]
 8548        .iter()
 8549        .filter(|e| e.contains("Draft"))
 8550        .count();
 8551    assert_eq!(
 8552        project_a_drafts, 0,
 8553        "switching back to project-a should not create drafts in its group"
 8554    );
 8555}
 8556
 8557#[gpui::test]
 8558async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
 8559    // When a workspace has a draft (from the panel's load fallback)
 8560    // and the user activates it (e.g. by clicking the placeholder or
 8561    // the project header), no extra drafts should be created.
 8562    init_test(cx);
 8563    let fs = FakeFs::new(cx.executor());
 8564    fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
 8565        .await;
 8566    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8567        .await;
 8568    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8569
 8570    let project_a =
 8571        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
 8572    let (multi_workspace, cx) =
 8573        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8574    let sidebar = setup_sidebar(&multi_workspace, cx);
 8575    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8576    let _panel_a = add_agent_panel(&workspace_a, cx);
 8577    cx.run_until_parked();
 8578
 8579    // Add project-b with its own workspace and agent panel.
 8580    let project_b =
 8581        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8582    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8583        mw.test_add_workspace(project_b.clone(), window, cx)
 8584    });
 8585    let _panel_b = add_agent_panel(&workspace_b, cx);
 8586    cx.run_until_parked();
 8587
 8588    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 8589    sidebar.update_in(cx, |sidebar, window, cx| {
 8590        sidebar.create_new_thread(&workspace_b, window, cx);
 8591    });
 8592    cx.run_until_parked();
 8593
 8594    // Count project-b's drafts.
 8595    let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
 8596        let entries = visible_entries_as_strings(&sidebar, cx);
 8597        entries
 8598            .iter()
 8599            .skip_while(|e| !e.contains("project-b"))
 8600            .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
 8601            .filter(|e| e.contains("Draft"))
 8602            .count()
 8603    };
 8604    let drafts_before = count_b_drafts(cx);
 8605
 8606    // Switch away from project-b, then back.
 8607    multi_workspace.update_in(cx, |mw, window, cx| {
 8608        mw.activate(workspace_a.clone(), None, window, cx);
 8609    });
 8610    cx.run_until_parked();
 8611    multi_workspace.update_in(cx, |mw, window, cx| {
 8612        mw.activate(workspace_b.clone(), None, window, cx);
 8613    });
 8614    cx.run_until_parked();
 8615
 8616    let drafts_after = count_b_drafts(cx);
 8617    assert_eq!(
 8618        drafts_before, drafts_after,
 8619        "activating workspace should not create extra drafts"
 8620    );
 8621
 8622    // The draft should be highlighted as active after switching back.
 8623    sidebar.read_with(cx, |sidebar, _| {
 8624        assert_active_draft(
 8625            sidebar,
 8626            &workspace_b,
 8627            "draft should be active after switching back to its workspace",
 8628        );
 8629    });
 8630}
 8631
 8632#[gpui::test]
 8633async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
 8634    // Historical threads (not open in any agent panel) should have their
 8635    // worktree paths updated when a folder is added to or removed from the
 8636    // project.
 8637    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
 8638    let (multi_workspace, cx) =
 8639        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8640    let sidebar = setup_sidebar(&multi_workspace, cx);
 8641
 8642    // Save two threads directly into the metadata store (not via the agent
 8643    // panel), so they are purely historical — no open views hold them.
 8644    // Use different timestamps so sort order is deterministic.
 8645    save_thread_metadata(
 8646        acp::SessionId::new(Arc::from("hist-1")),
 8647        Some("Historical 1".into()),
 8648        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8649        None,
 8650        None,
 8651        &project,
 8652        cx,
 8653    );
 8654    save_thread_metadata(
 8655        acp::SessionId::new(Arc::from("hist-2")),
 8656        Some("Historical 2".into()),
 8657        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 8658        None,
 8659        None,
 8660        &project,
 8661        cx,
 8662    );
 8663    cx.run_until_parked();
 8664    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8665    cx.run_until_parked();
 8666
 8667    // Sanity-check: both threads exist under the initial key [/project-a].
 8668    let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
 8669    cx.update(|_window, cx| {
 8670        let store = ThreadMetadataStore::global(cx).read(cx);
 8671        assert_eq!(
 8672            store
 8673                .entries_for_main_worktree_path(&old_key_paths, None)
 8674                .count(),
 8675            2,
 8676            "should have 2 historical threads under old key before worktree add"
 8677        );
 8678    });
 8679
 8680    // Add a second worktree to the project.
 8681    project
 8682        .update(cx, |project, cx| {
 8683            project.find_or_create_worktree("/project-b", true, cx)
 8684        })
 8685        .await
 8686        .expect("should add worktree");
 8687    cx.run_until_parked();
 8688
 8689    // The historical threads should now be indexed under the new combined
 8690    // key [/project-a, /project-b].
 8691    let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
 8692    cx.update(|_window, cx| {
 8693        let store = ThreadMetadataStore::global(cx).read(cx);
 8694        assert_eq!(
 8695            store
 8696                .entries_for_main_worktree_path(&old_key_paths, None)
 8697                .count(),
 8698            0,
 8699            "should have 0 historical threads under old key after worktree add"
 8700        );
 8701        assert_eq!(
 8702            store
 8703                .entries_for_main_worktree_path(&new_key_paths, None)
 8704                .count(),
 8705            2,
 8706            "should have 2 historical threads under new key after worktree add"
 8707        );
 8708    });
 8709
 8710    // Sidebar should show threads under the new header.
 8711    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8712    cx.run_until_parked();
 8713    assert_eq!(
 8714        visible_entries_as_strings(&sidebar, cx),
 8715        vec![
 8716            "v [project-a, project-b]",
 8717            "  Historical 2",
 8718            "  Historical 1",
 8719        ]
 8720    );
 8721
 8722    // Now remove the second worktree.
 8723    let worktree_id = project.read_with(cx, |project, cx| {
 8724        project
 8725            .visible_worktrees(cx)
 8726            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
 8727            .map(|wt| wt.read(cx).id())
 8728            .expect("should find project-b worktree")
 8729    });
 8730    project.update(cx, |project, cx| {
 8731        project.remove_worktree(worktree_id, cx);
 8732    });
 8733    cx.run_until_parked();
 8734
 8735    // Historical threads should migrate back to the original key.
 8736    cx.update(|_window, cx| {
 8737        let store = ThreadMetadataStore::global(cx).read(cx);
 8738        assert_eq!(
 8739            store
 8740                .entries_for_main_worktree_path(&new_key_paths, None)
 8741                .count(),
 8742            0,
 8743            "should have 0 historical threads under new key after worktree remove"
 8744        );
 8745        assert_eq!(
 8746            store
 8747                .entries_for_main_worktree_path(&old_key_paths, None)
 8748                .count(),
 8749            2,
 8750            "should have 2 historical threads under old key after worktree remove"
 8751        );
 8752    });
 8753
 8754    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8755    cx.run_until_parked();
 8756    assert_eq!(
 8757        visible_entries_as_strings(&sidebar, cx),
 8758        vec!["v [project-a]", "  Historical 2", "  Historical 1",]
 8759    );
 8760}
 8761
 8762#[gpui::test]
 8763async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
 8764    // When two workspaces share the same project group (same main path)
 8765    // but have different folder paths (main repo vs linked worktree),
 8766    // adding a worktree to the main workspace should regroup only that
 8767    // workspace and its threads into the new project group. Threads for the
 8768    // linked worktree workspace should remain under the original group.
 8769    agent_ui::test_support::init_test(cx);
 8770    cx.update(|cx| {
 8771        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 8772        ThreadStore::init_global(cx);
 8773        ThreadMetadataStore::init_global(cx);
 8774        language_model::LanguageModelRegistry::test(cx);
 8775        prompt_store::init(cx);
 8776    });
 8777
 8778    let fs = FakeFs::new(cx.executor());
 8779    fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
 8780        .await;
 8781    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8782        .await;
 8783    fs.add_linked_worktree_for_repo(
 8784        Path::new("/project/.git"),
 8785        false,
 8786        git::repository::Worktree {
 8787            path: std::path::PathBuf::from("/wt-feature"),
 8788            ref_name: Some("refs/heads/feature".into()),
 8789            sha: "aaa".into(),
 8790            is_main: false,
 8791            is_bare: false,
 8792        },
 8793    )
 8794    .await;
 8795    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8796
 8797    // Workspace A: main repo at /project.
 8798    let main_project =
 8799        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
 8800    // Workspace B: linked worktree of the same repo (same group, different folder).
 8801    let worktree_project =
 8802        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
 8803
 8804    main_project
 8805        .update(cx, |p, cx| p.git_scans_complete(cx))
 8806        .await;
 8807    worktree_project
 8808        .update(cx, |p, cx| p.git_scans_complete(cx))
 8809        .await;
 8810
 8811    let (multi_workspace, cx) =
 8812        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 8813    let sidebar = setup_sidebar(&multi_workspace, cx);
 8814    multi_workspace.update_in(cx, |mw, window, cx| {
 8815        mw.test_add_workspace(worktree_project.clone(), window, cx);
 8816    });
 8817    cx.run_until_parked();
 8818
 8819    // Save a thread for each workspace's folder paths.
 8820    let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
 8821    let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
 8822    save_thread_metadata(
 8823        acp::SessionId::new(Arc::from("thread-main")),
 8824        Some("Main Thread".into()),
 8825        time_main,
 8826        Some(time_main),
 8827        None,
 8828        &main_project,
 8829        cx,
 8830    );
 8831    save_thread_metadata(
 8832        acp::SessionId::new(Arc::from("thread-wt")),
 8833        Some("Worktree Thread".into()),
 8834        time_wt,
 8835        Some(time_wt),
 8836        None,
 8837        &worktree_project,
 8838        cx,
 8839    );
 8840    cx.run_until_parked();
 8841
 8842    let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
 8843    let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
 8844
 8845    // Sanity-check: each thread is indexed under its own folder paths, but
 8846    // both appear under the shared sidebar group keyed by the main worktree.
 8847    cx.update(|_window, cx| {
 8848        let store = ThreadMetadataStore::global(cx).read(cx);
 8849        assert_eq!(
 8850            store.entries_for_path(&folder_paths_main, None).count(),
 8851            1,
 8852            "one thread under [/project]"
 8853        );
 8854        assert_eq!(
 8855            store.entries_for_path(&folder_paths_wt, None).count(),
 8856            1,
 8857            "one thread under [/wt-feature]"
 8858        );
 8859    });
 8860    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8861    cx.run_until_parked();
 8862    assert_eq!(
 8863        visible_entries_as_strings(&sidebar, cx),
 8864        vec![
 8865            "v [project]",
 8866            "  Worktree Thread {wt-feature}",
 8867            "  Main Thread",
 8868        ]
 8869    );
 8870
 8871    // Add /project-b to the main project only.
 8872    main_project
 8873        .update(cx, |project, cx| {
 8874            project.find_or_create_worktree("/project-b", true, cx)
 8875        })
 8876        .await
 8877        .expect("should add worktree");
 8878    cx.run_until_parked();
 8879
 8880    // Main Thread (folder paths [/project]) should be regrouped to
 8881    // [/project, /project-b]. Worktree Thread should remain under the
 8882    // original [/project] group.
 8883    let folder_paths_main_b =
 8884        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
 8885    cx.update(|_window, cx| {
 8886        let store = ThreadMetadataStore::global(cx).read(cx);
 8887        assert_eq!(
 8888            store.entries_for_path(&folder_paths_main, None).count(),
 8889            0,
 8890            "main thread should no longer be under old folder paths [/project]"
 8891        );
 8892        assert_eq!(
 8893            store.entries_for_path(&folder_paths_main_b, None).count(),
 8894            1,
 8895            "main thread should now be under [/project, /project-b]"
 8896        );
 8897        assert_eq!(
 8898            store.entries_for_path(&folder_paths_wt, None).count(),
 8899            1,
 8900            "worktree thread should remain unchanged under [/wt-feature]"
 8901        );
 8902    });
 8903
 8904    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8905    cx.run_until_parked();
 8906    assert_eq!(
 8907        visible_entries_as_strings(&sidebar, cx),
 8908        vec![
 8909            "v [project]",
 8910            "  Worktree Thread {wt-feature}",
 8911            "v [project, project-b]",
 8912            "  Main Thread",
 8913        ]
 8914    );
 8915}
 8916
 8917#[gpui::test]
 8918async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
 8919    cx: &mut TestAppContext,
 8920) {
 8921    // When a linked worktree is opened as its own workspace and then a new
 8922    // folder is added to the main project group, the linked worktree
 8923    // workspace must still be reachable from some sidebar entry.
 8924    let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
 8925    let fs = _fs.clone();
 8926
 8927    // Set up git worktree infrastructure.
 8928    fs.insert_tree(
 8929        "/my-project/.git/worktrees/wt-0",
 8930        serde_json::json!({
 8931            "commondir": "../../",
 8932            "HEAD": "ref: refs/heads/wt-0",
 8933        }),
 8934    )
 8935    .await;
 8936    fs.insert_tree(
 8937        "/worktrees/wt-0",
 8938        serde_json::json!({
 8939            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8940            "src": {},
 8941        }),
 8942    )
 8943    .await;
 8944    fs.add_linked_worktree_for_repo(
 8945        Path::new("/my-project/.git"),
 8946        false,
 8947        git::repository::Worktree {
 8948            path: PathBuf::from("/worktrees/wt-0"),
 8949            ref_name: Some("refs/heads/wt-0".into()),
 8950            sha: "aaa".into(),
 8951            is_main: false,
 8952            is_bare: false,
 8953        },
 8954    )
 8955    .await;
 8956
 8957    // Re-scan so the main project discovers the linked worktree.
 8958    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8959
 8960    let (multi_workspace, cx) =
 8961        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8962    let sidebar = setup_sidebar(&multi_workspace, cx);
 8963
 8964    // Open the linked worktree as its own workspace.
 8965    let worktree_project = project::Project::test(
 8966        fs.clone() as Arc<dyn fs::Fs>,
 8967        ["/worktrees/wt-0".as_ref()],
 8968        cx,
 8969    )
 8970    .await;
 8971    worktree_project
 8972        .update(cx, |p, cx| p.git_scans_complete(cx))
 8973        .await;
 8974    multi_workspace.update_in(cx, |mw, window, cx| {
 8975        mw.test_add_workspace(worktree_project.clone(), window, cx);
 8976    });
 8977    cx.run_until_parked();
 8978
 8979    // Both workspaces should be reachable.
 8980    let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 8981    assert_eq!(workspace_count, 2, "should have 2 workspaces");
 8982
 8983    // Add a new folder to the main project, changing the project group key.
 8984    fs.insert_tree(
 8985        "/other-project",
 8986        serde_json::json!({ ".git": {}, "src": {} }),
 8987    )
 8988    .await;
 8989    project
 8990        .update(cx, |project, cx| {
 8991            project.find_or_create_worktree("/other-project", true, cx)
 8992        })
 8993        .await
 8994        .expect("should add worktree");
 8995    cx.run_until_parked();
 8996
 8997    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8998    cx.run_until_parked();
 8999
 9000    // The linked worktree workspace must still be reachable.
 9001    let entries = visible_entries_as_strings(&sidebar, cx);
 9002    let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
 9003        mw.workspaces().map(|ws| ws.entity_id()).collect()
 9004    });
 9005    sidebar.read_with(cx, |sidebar, cx| {
 9006        let multi_workspace = multi_workspace.read(cx);
 9007        let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
 9008            .contents
 9009            .entries
 9010            .iter()
 9011            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 9012            .map(|ws| ws.entity_id())
 9013            .collect();
 9014        let all: std::collections::HashSet<gpui::EntityId> =
 9015            mw_workspaces.iter().copied().collect();
 9016        let unreachable = &all - &reachable;
 9017        assert!(
 9018            unreachable.is_empty(),
 9019            "all workspaces should be reachable after adding folder; \
 9020             unreachable: {:?}, entries: {:?}",
 9021            unreachable,
 9022            entries,
 9023        );
 9024    });
 9025}
 9026
 9027mod property_test {
 9028    use super::*;
 9029    use gpui::proptest::prelude::*;
 9030
 9031    struct UnopenedWorktree {
 9032        path: String,
 9033        main_workspace_path: String,
 9034    }
 9035
 9036    struct TestState {
 9037        fs: Arc<FakeFs>,
 9038        thread_counter: u32,
 9039        workspace_counter: u32,
 9040        worktree_counter: u32,
 9041        saved_thread_ids: Vec<acp::SessionId>,
 9042        unopened_worktrees: Vec<UnopenedWorktree>,
 9043    }
 9044
 9045    impl TestState {
 9046        fn new(fs: Arc<FakeFs>) -> Self {
 9047            Self {
 9048                fs,
 9049                thread_counter: 0,
 9050                workspace_counter: 1,
 9051                worktree_counter: 0,
 9052                saved_thread_ids: Vec::new(),
 9053                unopened_worktrees: Vec::new(),
 9054            }
 9055        }
 9056
 9057        fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
 9058            let id = self.thread_counter;
 9059            self.thread_counter += 1;
 9060            acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
 9061        }
 9062
 9063        fn next_workspace_path(&mut self) -> String {
 9064            let id = self.workspace_counter;
 9065            self.workspace_counter += 1;
 9066            format!("/prop-project-{id}")
 9067        }
 9068
 9069        fn next_worktree_name(&mut self) -> String {
 9070            let id = self.worktree_counter;
 9071            self.worktree_counter += 1;
 9072            format!("wt-{id}")
 9073        }
 9074    }
 9075
 9076    #[derive(Debug)]
 9077    enum Operation {
 9078        SaveThread { project_group_index: usize },
 9079        SaveWorktreeThread { worktree_index: usize },
 9080        ToggleAgentPanel,
 9081        CreateDraftThread,
 9082        AddProject { use_worktree: bool },
 9083        ArchiveThread { index: usize },
 9084        SwitchToThread { index: usize },
 9085        SwitchToProjectGroup { index: usize },
 9086        AddLinkedWorktree { project_group_index: usize },
 9087        AddWorktreeToProject { project_group_index: usize },
 9088        RemoveWorktreeFromProject { project_group_index: usize },
 9089    }
 9090
 9091    // Distribution (out of 24 slots):
 9092    //   SaveThread:                5 slots (~21%)
 9093    //   SaveWorktreeThread:        2 slots (~8%)
 9094    //   ToggleAgentPanel:          1 slot  (~4%)
 9095    //   CreateDraftThread:         1 slot  (~4%)
 9096    //   AddProject:                1 slot  (~4%)
 9097    //   ArchiveThread:             2 slots (~8%)
 9098    //   SwitchToThread:            2 slots (~8%)
 9099    //   SwitchToProjectGroup:      2 slots (~8%)
 9100    //   AddLinkedWorktree:         4 slots (~17%)
 9101    //   AddWorktreeToProject:      2 slots (~8%)
 9102    //   RemoveWorktreeFromProject: 2 slots (~8%)
 9103    const DISTRIBUTION_SLOTS: u32 = 24;
 9104
 9105    impl TestState {
 9106        fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
 9107            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
 9108
 9109            match raw % DISTRIBUTION_SLOTS {
 9110                0..=4 => Operation::SaveThread {
 9111                    project_group_index: extra % project_group_count,
 9112                },
 9113                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
 9114                    worktree_index: extra % self.unopened_worktrees.len(),
 9115                },
 9116                5..=6 => Operation::SaveThread {
 9117                    project_group_index: extra % project_group_count,
 9118                },
 9119                7 => Operation::ToggleAgentPanel,
 9120                8 => Operation::CreateDraftThread,
 9121                9 => Operation::AddProject {
 9122                    use_worktree: !self.unopened_worktrees.is_empty(),
 9123                },
 9124                10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
 9125                    index: extra % self.saved_thread_ids.len(),
 9126                },
 9127                10..=11 => Operation::AddProject {
 9128                    use_worktree: !self.unopened_worktrees.is_empty(),
 9129                },
 9130                12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
 9131                    index: extra % self.saved_thread_ids.len(),
 9132                },
 9133                12..=13 => Operation::SwitchToProjectGroup {
 9134                    index: extra % project_group_count,
 9135                },
 9136                14..=15 => Operation::SwitchToProjectGroup {
 9137                    index: extra % project_group_count,
 9138                },
 9139                16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
 9140                    project_group_index: extra % project_group_count,
 9141                },
 9142                16..=19 => Operation::SaveThread {
 9143                    project_group_index: extra % project_group_count,
 9144                },
 9145                20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
 9146                    project_group_index: extra % project_group_count,
 9147                },
 9148                20..=21 => Operation::SaveThread {
 9149                    project_group_index: extra % project_group_count,
 9150                },
 9151                22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
 9152                    project_group_index: extra % project_group_count,
 9153                },
 9154                22..=23 => Operation::SaveThread {
 9155                    project_group_index: extra % project_group_count,
 9156                },
 9157                _ => unreachable!(),
 9158            }
 9159        }
 9160    }
 9161
 9162    fn save_thread_to_path_with_main(
 9163        state: &mut TestState,
 9164        path_list: PathList,
 9165        main_worktree_paths: PathList,
 9166        cx: &mut gpui::VisualTestContext,
 9167    ) {
 9168        let session_id = state.next_metadata_only_thread_id();
 9169        let title: SharedString = format!("Thread {}", session_id).into();
 9170        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9171            .unwrap()
 9172            + chrono::Duration::seconds(state.thread_counter as i64);
 9173        let metadata = ThreadMetadata {
 9174            thread_id: ThreadId::new(),
 9175            session_id: Some(session_id),
 9176            agent_id: agent::ZED_AGENT_ID.clone(),
 9177            title: Some(title),
 9178            updated_at,
 9179            created_at: None,
 9180            interacted_at: None,
 9181            worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
 9182            archived: false,
 9183            remote_connection: None,
 9184        };
 9185        cx.update(|_, cx| {
 9186            ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
 9187        });
 9188        cx.run_until_parked();
 9189    }
 9190
 9191    async fn perform_operation(
 9192        operation: Operation,
 9193        state: &mut TestState,
 9194        multi_workspace: &Entity<MultiWorkspace>,
 9195        sidebar: &Entity<Sidebar>,
 9196        cx: &mut gpui::VisualTestContext,
 9197    ) {
 9198        match operation {
 9199            Operation::SaveThread {
 9200                project_group_index,
 9201            } => {
 9202                // Find a workspace for this project group and create a real
 9203                // thread via its agent panel.
 9204                let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
 9205                    let keys = mw.project_group_keys();
 9206                    let key = &keys[project_group_index];
 9207                    let ws = mw
 9208                        .workspaces_for_project_group(key, cx)
 9209                        .and_then(|ws| ws.first().cloned())
 9210                        .unwrap_or_else(|| mw.workspace().clone());
 9211                    let project = ws.read(cx).project().clone();
 9212                    (ws, project)
 9213                });
 9214
 9215                let panel =
 9216                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9217                if let Some(panel) = panel {
 9218                    let connection = StubAgentConnection::new();
 9219                    connection.set_next_prompt_updates(vec![
 9220                        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
 9221                            "Done".into(),
 9222                        )),
 9223                    ]);
 9224                    open_thread_with_connection(&panel, connection, cx);
 9225                    send_message(&panel, cx);
 9226                    let session_id = active_session_id(&panel, cx);
 9227                    state.saved_thread_ids.push(session_id.clone());
 9228
 9229                    let title: SharedString = format!("Thread {}", state.thread_counter).into();
 9230                    state.thread_counter += 1;
 9231                    let updated_at =
 9232                        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9233                            .unwrap()
 9234                            + chrono::Duration::seconds(state.thread_counter as i64);
 9235                    save_thread_metadata(
 9236                        session_id,
 9237                        Some(title),
 9238                        updated_at,
 9239                        None,
 9240                        None,
 9241                        &project,
 9242                        cx,
 9243                    );
 9244                }
 9245            }
 9246            Operation::SaveWorktreeThread { worktree_index } => {
 9247                let worktree = &state.unopened_worktrees[worktree_index];
 9248                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
 9249                let main_worktree_paths =
 9250                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
 9251                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
 9252            }
 9253
 9254            Operation::ToggleAgentPanel => {
 9255                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9256                let panel_open =
 9257                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
 9258                workspace.update_in(cx, |workspace, window, cx| {
 9259                    if panel_open {
 9260                        workspace.close_panel::<AgentPanel>(window, cx);
 9261                    } else {
 9262                        workspace.open_panel::<AgentPanel>(window, cx);
 9263                    }
 9264                });
 9265            }
 9266            Operation::CreateDraftThread => {
 9267                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9268                let panel =
 9269                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9270                if let Some(panel) = panel {
 9271                    panel.update_in(cx, |panel, window, cx| {
 9272                        panel.new_thread(&NewThread, window, cx);
 9273                    });
 9274                    cx.run_until_parked();
 9275                }
 9276                workspace.update_in(cx, |workspace, window, cx| {
 9277                    workspace.focus_panel::<AgentPanel>(window, cx);
 9278                });
 9279            }
 9280            Operation::AddProject { use_worktree } => {
 9281                let path = if use_worktree {
 9282                    // Open an existing linked worktree as a project (simulates Cmd+O
 9283                    // on a worktree directory).
 9284                    state.unopened_worktrees.remove(0).path
 9285                } else {
 9286                    // Create a brand new project.
 9287                    let path = state.next_workspace_path();
 9288                    state
 9289                        .fs
 9290                        .insert_tree(
 9291                            &path,
 9292                            serde_json::json!({
 9293                                ".git": {},
 9294                                "src": {},
 9295                            }),
 9296                        )
 9297                        .await;
 9298                    path
 9299                };
 9300                let project = project::Project::test(
 9301                    state.fs.clone() as Arc<dyn fs::Fs>,
 9302                    [path.as_ref()],
 9303                    cx,
 9304                )
 9305                .await;
 9306                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9307                multi_workspace.update_in(cx, |mw, window, cx| {
 9308                    mw.test_add_workspace(project.clone(), window, cx)
 9309                });
 9310            }
 9311
 9312            Operation::ArchiveThread { index } => {
 9313                let session_id = state.saved_thread_ids[index].clone();
 9314                sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 9315                    sidebar.archive_thread(&session_id, window, cx);
 9316                });
 9317                cx.run_until_parked();
 9318                state.saved_thread_ids.remove(index);
 9319            }
 9320            Operation::SwitchToThread { index } => {
 9321                let session_id = state.saved_thread_ids[index].clone();
 9322                // Find the thread's position in the sidebar entries and select it.
 9323                let thread_index = sidebar.read_with(cx, |sidebar, _| {
 9324                    sidebar.contents.entries.iter().position(|entry| {
 9325                        matches!(
 9326                            entry,
 9327                            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
 9328                        )
 9329                    })
 9330                });
 9331                if let Some(ix) = thread_index {
 9332                    sidebar.update_in(cx, |sidebar, window, cx| {
 9333                        sidebar.selection = Some(ix);
 9334                        sidebar.confirm(&Confirm, window, cx);
 9335                    });
 9336                    cx.run_until_parked();
 9337                }
 9338            }
 9339            Operation::SwitchToProjectGroup { index } => {
 9340                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9341                    let keys = mw.project_group_keys();
 9342                    let key = &keys[index];
 9343                    mw.workspaces_for_project_group(key, cx)
 9344                        .and_then(|ws| ws.first().cloned())
 9345                        .unwrap_or_else(|| mw.workspace().clone())
 9346                });
 9347                multi_workspace.update_in(cx, |mw, window, cx| {
 9348                    mw.activate(workspace, None, window, cx);
 9349                });
 9350            }
 9351            Operation::AddLinkedWorktree {
 9352                project_group_index,
 9353            } => {
 9354                // Get the main worktree path from the project group key.
 9355                let main_path = multi_workspace.read_with(cx, |mw, _| {
 9356                    let keys = mw.project_group_keys();
 9357                    let key = &keys[project_group_index];
 9358                    key.path_list()
 9359                        .paths()
 9360                        .first()
 9361                        .unwrap()
 9362                        .to_string_lossy()
 9363                        .to_string()
 9364                });
 9365                let dot_git = format!("{}/.git", main_path);
 9366                let worktree_name = state.next_worktree_name();
 9367                let worktree_path = format!("/worktrees/{}", worktree_name);
 9368
 9369                state.fs
 9370                    .insert_tree(
 9371                        &worktree_path,
 9372                        serde_json::json!({
 9373                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
 9374                            "src": {},
 9375                        }),
 9376                    )
 9377                    .await;
 9378
 9379                // Also create the worktree metadata dir inside the main repo's .git
 9380                state
 9381                    .fs
 9382                    .insert_tree(
 9383                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
 9384                        serde_json::json!({
 9385                            "commondir": "../../",
 9386                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
 9387                        }),
 9388                    )
 9389                    .await;
 9390
 9391                let dot_git_path = std::path::Path::new(&dot_git);
 9392                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
 9393                state
 9394                    .fs
 9395                    .add_linked_worktree_for_repo(
 9396                        dot_git_path,
 9397                        false,
 9398                        git::repository::Worktree {
 9399                            path: worktree_pathbuf,
 9400                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 9401                            sha: "aaa".into(),
 9402                            is_main: false,
 9403                            is_bare: false,
 9404                        },
 9405                    )
 9406                    .await;
 9407
 9408                // Re-scan the main workspace's project so it discovers the new worktree.
 9409                let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
 9410                    let keys = mw.project_group_keys();
 9411                    let key = &keys[project_group_index];
 9412                    mw.workspaces_for_project_group(key, cx)
 9413                        .and_then(|ws| ws.first().cloned())
 9414                        .unwrap()
 9415                });
 9416                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 9417                main_project
 9418                    .update(cx, |p, cx| p.git_scans_complete(cx))
 9419                    .await;
 9420
 9421                state.unopened_worktrees.push(UnopenedWorktree {
 9422                    path: worktree_path,
 9423                    main_workspace_path: main_path.clone(),
 9424                });
 9425            }
 9426            Operation::AddWorktreeToProject {
 9427                project_group_index,
 9428            } => {
 9429                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9430                    let keys = mw.project_group_keys();
 9431                    let key = &keys[project_group_index];
 9432                    mw.workspaces_for_project_group(key, cx)
 9433                        .and_then(|ws| ws.first().cloned())
 9434                });
 9435                let Some(workspace) = workspace else { return };
 9436                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9437
 9438                let new_path = state.next_workspace_path();
 9439                state
 9440                    .fs
 9441                    .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
 9442                    .await;
 9443
 9444                let result = project
 9445                    .update(cx, |project, cx| {
 9446                        project.find_or_create_worktree(&new_path, true, cx)
 9447                    })
 9448                    .await;
 9449                if result.is_err() {
 9450                    return;
 9451                }
 9452                cx.run_until_parked();
 9453            }
 9454            Operation::RemoveWorktreeFromProject {
 9455                project_group_index,
 9456            } => {
 9457                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9458                    let keys = mw.project_group_keys();
 9459                    let key = &keys[project_group_index];
 9460                    mw.workspaces_for_project_group(key, cx)
 9461                        .and_then(|ws| ws.first().cloned())
 9462                });
 9463                let Some(workspace) = workspace else { return };
 9464                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9465
 9466                let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
 9467                if worktree_count <= 1 {
 9468                    return;
 9469                }
 9470
 9471                let worktree_id = project.read_with(cx, |p, cx| {
 9472                    p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
 9473                });
 9474                if let Some(worktree_id) = worktree_id {
 9475                    project.update(cx, |project, cx| {
 9476                        project.remove_worktree(worktree_id, cx);
 9477                    });
 9478                    cx.run_until_parked();
 9479                }
 9480            }
 9481        }
 9482    }
 9483
 9484    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
 9485        sidebar.update_in(cx, |sidebar, _window, cx| {
 9486            if let Some(mw) = sidebar.multi_workspace.upgrade() {
 9487                mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 9488            }
 9489            sidebar.update_entries(cx);
 9490        });
 9491    }
 9492
 9493    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9494        verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
 9495        verify_no_duplicate_threads(sidebar)?;
 9496        verify_all_threads_are_shown(sidebar, cx)?;
 9497        verify_active_state_matches_current_workspace(sidebar, cx)?;
 9498        verify_all_workspaces_are_reachable(sidebar, cx)?;
 9499        verify_workspace_group_key_integrity(sidebar, cx)?;
 9500        Ok(())
 9501    }
 9502
 9503    fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
 9504        let mut seen: HashSet<acp::SessionId> = HashSet::default();
 9505        let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
 9506
 9507        for entry in &sidebar.contents.entries {
 9508            if let Some(session_id) = entry.session_id() {
 9509                if !seen.insert(session_id.clone()) {
 9510                    let title = match entry {
 9511                        ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
 9512                        _ => "<unknown>".to_string(),
 9513                    };
 9514                    duplicates.push((session_id.clone(), title));
 9515                }
 9516            }
 9517        }
 9518
 9519        anyhow::ensure!(
 9520            duplicates.is_empty(),
 9521            "threads appear more than once in sidebar: {:?}",
 9522            duplicates,
 9523        );
 9524        Ok(())
 9525    }
 9526
 9527    fn verify_every_group_in_multiworkspace_is_shown(
 9528        sidebar: &Sidebar,
 9529        cx: &App,
 9530    ) -> anyhow::Result<()> {
 9531        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9532            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9533        };
 9534
 9535        let mw = multi_workspace.read(cx);
 9536
 9537        // Every project group key in the multi-workspace that has a
 9538        // non-empty path list should appear as a ProjectHeader in the
 9539        // sidebar.
 9540        let all_keys = mw.project_group_keys();
 9541        let expected_keys: HashSet<&ProjectGroupKey> = all_keys
 9542            .iter()
 9543            .filter(|k| !k.path_list().paths().is_empty())
 9544            .collect();
 9545
 9546        let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
 9547            .contents
 9548            .entries
 9549            .iter()
 9550            .filter_map(|entry| match entry {
 9551                ListEntry::ProjectHeader { key, .. } => Some(key),
 9552                _ => None,
 9553            })
 9554            .collect();
 9555
 9556        let missing = &expected_keys - &sidebar_keys;
 9557        let stray = &sidebar_keys - &expected_keys;
 9558
 9559        anyhow::ensure!(
 9560            missing.is_empty() && stray.is_empty(),
 9561            "sidebar project groups don't match multi-workspace.\n\
 9562             Only in multi-workspace (missing): {:?}\n\
 9563             Only in sidebar (stray): {:?}",
 9564            missing,
 9565            stray,
 9566        );
 9567
 9568        Ok(())
 9569    }
 9570
 9571    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9572        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9573            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9574        };
 9575        let workspaces = multi_workspace
 9576            .read(cx)
 9577            .workspaces()
 9578            .cloned()
 9579            .collect::<Vec<_>>();
 9580        let thread_store = ThreadMetadataStore::global(cx);
 9581
 9582        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
 9583            .contents
 9584            .entries
 9585            .iter()
 9586            .filter_map(|entry| entry.session_id().cloned())
 9587            .collect();
 9588
 9589        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
 9590
 9591        // Query using the same approach as the sidebar: iterate project
 9592        // group keys, then do main + legacy queries per group.
 9593        let mw = multi_workspace.read(cx);
 9594        let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
 9595            HashMap::default();
 9596        for workspace in &workspaces {
 9597            let key = workspace.read(cx).project_group_key(cx);
 9598            workspaces_by_group
 9599                .entry(key)
 9600                .or_default()
 9601                .push(workspace.clone());
 9602        }
 9603
 9604        for group_key in mw.project_group_keys() {
 9605            let path_list = group_key.path_list().clone();
 9606            if path_list.paths().is_empty() {
 9607                continue;
 9608            }
 9609
 9610            let group_workspaces = workspaces_by_group
 9611                .get(&group_key)
 9612                .map(|ws| ws.as_slice())
 9613                .unwrap_or_default();
 9614
 9615            // Main code path queries (run for all groups, even without workspaces).
 9616            // Skip drafts (session_id: None) — they are not shown in the
 9617            // sidebar entries.
 9618            for metadata in thread_store
 9619                .read(cx)
 9620                .entries_for_main_worktree_path(&path_list, None)
 9621            {
 9622                if let Some(sid) = metadata.session_id.clone() {
 9623                    metadata_thread_ids.insert(sid);
 9624                }
 9625            }
 9626            for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
 9627                if let Some(sid) = metadata.session_id.clone() {
 9628                    metadata_thread_ids.insert(sid);
 9629                }
 9630            }
 9631
 9632            // Legacy: per-workspace queries for different root paths.
 9633            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
 9634                .iter()
 9635                .flat_map(|ws| {
 9636                    ws.read(cx)
 9637                        .root_paths(cx)
 9638                        .into_iter()
 9639                        .map(|p| p.to_path_buf())
 9640                })
 9641                .collect();
 9642
 9643            for workspace in group_workspaces {
 9644                let ws_path_list = workspace_path_list(workspace, cx);
 9645                if ws_path_list != path_list {
 9646                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
 9647                        if let Some(sid) = metadata.session_id.clone() {
 9648                            metadata_thread_ids.insert(sid);
 9649                        }
 9650                    }
 9651                }
 9652            }
 9653
 9654            for workspace in group_workspaces {
 9655                for snapshot in root_repository_snapshots(workspace, cx) {
 9656                    let repo_path_list =
 9657                        PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
 9658                    if repo_path_list != path_list {
 9659                        continue;
 9660                    }
 9661                    for linked_worktree in snapshot.linked_worktrees() {
 9662                        if covered_paths.contains(&*linked_worktree.path) {
 9663                            continue;
 9664                        }
 9665                        let worktree_path_list =
 9666                            PathList::new(std::slice::from_ref(&linked_worktree.path));
 9667                        for metadata in thread_store
 9668                            .read(cx)
 9669                            .entries_for_path(&worktree_path_list, None)
 9670                        {
 9671                            if let Some(sid) = metadata.session_id.clone() {
 9672                                metadata_thread_ids.insert(sid);
 9673                            }
 9674                        }
 9675                    }
 9676                }
 9677            }
 9678        }
 9679
 9680        anyhow::ensure!(
 9681            sidebar_thread_ids == metadata_thread_ids,
 9682            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
 9683            sidebar_thread_ids,
 9684            metadata_thread_ids,
 9685        );
 9686        Ok(())
 9687    }
 9688
 9689    fn verify_active_state_matches_current_workspace(
 9690        sidebar: &Sidebar,
 9691        cx: &App,
 9692    ) -> anyhow::Result<()> {
 9693        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9694            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9695        };
 9696
 9697        let active_workspace = multi_workspace.read(cx).workspace();
 9698
 9699        // 1. active_entry should be Some when the panel has content.
 9700        //    It may be None when the panel is uninitialized (no drafts,
 9701        //    no threads), which is fine.
 9702        //    It may also temporarily point at a different workspace
 9703        //    when the workspace just changed and the new panel has no
 9704        //    content yet.
 9705        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
 9706        let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
 9707            || panel.read(cx).active_conversation_view().is_some();
 9708
 9709        let Some(entry) = sidebar.active_entry.as_ref() else {
 9710            if panel_has_content {
 9711                anyhow::bail!("active_entry is None but panel has content (draft or thread)");
 9712            }
 9713            return Ok(());
 9714        };
 9715
 9716        // If the entry workspace doesn't match the active workspace
 9717        // and the panel has no content, this is a transient state that
 9718        // will resolve when the panel gets content.
 9719        if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
 9720            return Ok(());
 9721        }
 9722
 9723        // 2. The entry's workspace must agree with the multi-workspace's
 9724        //    active workspace.
 9725        anyhow::ensure!(
 9726            entry.workspace().entity_id() == active_workspace.entity_id(),
 9727            "active_entry workspace ({:?}) != active workspace ({:?})",
 9728            entry.workspace().entity_id(),
 9729            active_workspace.entity_id(),
 9730        );
 9731
 9732        // 3. The entry must match the agent panel's current state.
 9733        if panel.read(cx).active_thread_id(cx).is_some() {
 9734            anyhow::ensure!(
 9735                matches!(entry, ActiveEntry { .. }),
 9736                "panel shows a tracked draft but active_entry is {:?}",
 9737                entry,
 9738            );
 9739        } else if let Some(thread_id) = panel
 9740            .read(cx)
 9741            .active_conversation_view()
 9742            .map(|cv| cv.read(cx).parent_id())
 9743        {
 9744            anyhow::ensure!(
 9745                matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
 9746                "panel has thread {:?} but active_entry is {:?}",
 9747                thread_id,
 9748                entry,
 9749            );
 9750        }
 9751
 9752        // 4. Exactly one entry in sidebar contents must be uniquely
 9753        //    identified by the active_entry — unless the panel is showing
 9754        //    a draft, which is represented by the + button's active state
 9755        //    rather than a sidebar row.
 9756        // TODO: Make this check more complete
 9757        let is_draft = panel.read(cx).active_thread_is_draft(cx)
 9758            || panel.read(cx).active_conversation_view().is_none();
 9759        if is_draft {
 9760            return Ok(());
 9761        }
 9762        let matching_count = sidebar
 9763            .contents
 9764            .entries
 9765            .iter()
 9766            .filter(|e| entry.matches_entry(e))
 9767            .count();
 9768        if matching_count != 1 {
 9769            let thread_entries: Vec<_> = sidebar
 9770                .contents
 9771                .entries
 9772                .iter()
 9773                .filter_map(|e| match e {
 9774                    ListEntry::Thread(t) => Some(format!(
 9775                        "tid={:?} sid={:?}",
 9776                        t.metadata.thread_id, t.metadata.session_id
 9777                    )),
 9778                    _ => None,
 9779                })
 9780                .collect();
 9781            let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
 9782            let store_entries: Vec<_> = store
 9783                .entries()
 9784                .map(|m| {
 9785                    format!(
 9786                        "tid={:?} sid={:?} archived={} paths={:?}",
 9787                        m.thread_id,
 9788                        m.session_id,
 9789                        m.archived,
 9790                        m.folder_paths()
 9791                    )
 9792                })
 9793                .collect();
 9794            anyhow::bail!(
 9795                "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
 9796                entry,
 9797                matching_count,
 9798                thread_entries,
 9799                store_entries,
 9800            );
 9801        }
 9802
 9803        Ok(())
 9804    }
 9805
 9806    /// Every workspace in the multi-workspace should be "reachable" from
 9807    /// the sidebar — meaning there is at least one entry (thread, draft,
 9808    /// new-thread, or project header) that, when clicked, would activate
 9809    /// that workspace.
 9810    fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9811        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9812            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9813        };
 9814
 9815        let multi_workspace = multi_workspace.read(cx);
 9816
 9817        let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
 9818            .contents
 9819            .entries
 9820            .iter()
 9821            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 9822            .map(|ws| ws.entity_id())
 9823            .collect();
 9824
 9825        let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
 9826            .workspaces()
 9827            .map(|ws| ws.entity_id())
 9828            .collect();
 9829
 9830        let unreachable = &all_workspace_ids - &reachable_workspaces;
 9831
 9832        anyhow::ensure!(
 9833            unreachable.is_empty(),
 9834            "The following workspaces are not reachable from any sidebar entry: {:?}",
 9835            unreachable,
 9836        );
 9837
 9838        Ok(())
 9839    }
 9840
 9841    fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9842        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9843            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9844        };
 9845        multi_workspace
 9846            .read(cx)
 9847            .assert_project_group_key_integrity(cx)
 9848    }
 9849
 9850    #[gpui::property_test(config = ProptestConfig {
 9851        cases: 20,
 9852        ..Default::default()
 9853    })]
 9854    async fn test_sidebar_invariants(
 9855        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
 9856        raw_operations: Vec<u32>,
 9857        cx: &mut TestAppContext,
 9858    ) {
 9859        use std::sync::atomic::{AtomicUsize, Ordering};
 9860        static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
 9861
 9862        agent_ui::test_support::init_test(cx);
 9863        cx.update(|cx| {
 9864            cx.set_global(db::AppDatabase::test_new());
 9865            cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 9866            cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
 9867                format!(
 9868                    "PROPTEST_THREAD_METADATA_{}",
 9869                    NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
 9870                ),
 9871            ));
 9872
 9873            ThreadStore::init_global(cx);
 9874            ThreadMetadataStore::init_global(cx);
 9875            language_model::LanguageModelRegistry::test(cx);
 9876            prompt_store::init(cx);
 9877
 9878            // Auto-add an AgentPanel to every workspace so that implicitly
 9879            // created workspaces (e.g. from thread activation) also have one.
 9880            cx.observe_new(
 9881                |workspace: &mut Workspace,
 9882                 window: Option<&mut Window>,
 9883                 cx: &mut gpui::Context<Workspace>| {
 9884                    if let Some(window) = window {
 9885                        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 9886                        workspace.add_panel(panel, window, cx);
 9887                    }
 9888                },
 9889            )
 9890            .detach();
 9891        });
 9892
 9893        let fs = FakeFs::new(cx.executor());
 9894        fs.insert_tree(
 9895            "/my-project",
 9896            serde_json::json!({
 9897                ".git": {},
 9898                "src": {},
 9899            }),
 9900        )
 9901        .await;
 9902        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 9903        let project =
 9904            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
 9905                .await;
 9906        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9907
 9908        let (multi_workspace, cx) =
 9909            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9910        let sidebar = setup_sidebar(&multi_workspace, cx);
 9911
 9912        let mut state = TestState::new(fs);
 9913        let mut executed: Vec<String> = Vec::new();
 9914
 9915        for &raw_op in &raw_operations {
 9916            let project_group_count =
 9917                multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
 9918            let operation = state.generate_operation(raw_op, project_group_count);
 9919            executed.push(format!("{:?}", operation));
 9920            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
 9921            cx.run_until_parked();
 9922
 9923            update_sidebar(&sidebar, cx);
 9924            cx.run_until_parked();
 9925
 9926            let result =
 9927                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
 9928            if let Err(err) = result {
 9929                let log = executed.join("\n  ");
 9930                panic!(
 9931                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
 9932                    executed.len(),
 9933                );
 9934            }
 9935        }
 9936    }
 9937}
 9938
 9939#[gpui::test]
 9940async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
 9941    cx: &mut TestAppContext,
 9942    server_cx: &mut TestAppContext,
 9943) {
 9944    init_test(cx);
 9945
 9946    cx.update(|cx| {
 9947        release_channel::init(semver::Version::new(0, 0, 0), cx);
 9948    });
 9949
 9950    let app_state = cx.update(|cx| {
 9951        let app_state = workspace::AppState::test(cx);
 9952        workspace::init(app_state.clone(), cx);
 9953        app_state
 9954    });
 9955
 9956    // Set up the remote server side.
 9957    let server_fs = FakeFs::new(server_cx.executor());
 9958    server_fs
 9959        .insert_tree(
 9960            "/project",
 9961            serde_json::json!({
 9962                ".git": {},
 9963                "src": { "main.rs": "fn main() {}" }
 9964            }),
 9965        )
 9966        .await;
 9967    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
 9968
 9969    // Create the linked worktree checkout path on the remote server,
 9970    // but do not yet register it as a git-linked worktree. The real
 9971    // regrouping update in this test should happen only after the
 9972    // sidebar opens the closed remote thread.
 9973    server_fs
 9974        .insert_tree(
 9975            "/project-wt-1",
 9976            serde_json::json!({
 9977                "src": { "main.rs": "fn main() {}" }
 9978            }),
 9979        )
 9980        .await;
 9981
 9982    server_cx.update(|cx| {
 9983        release_channel::init(semver::Version::new(0, 0, 0), cx);
 9984    });
 9985
 9986    let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
 9987
 9988    server_cx.update(remote_server::HeadlessProject::init);
 9989    let server_executor = server_cx.executor();
 9990    let _headless = server_cx.new(|cx| {
 9991        remote_server::HeadlessProject::new(
 9992            remote_server::HeadlessAppState {
 9993                session: server_session,
 9994                fs: server_fs.clone(),
 9995                http_client: Arc::new(http_client::BlockedHttpClient),
 9996                node_runtime: node_runtime::NodeRuntime::unavailable(),
 9997                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
 9998                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
 9999                startup_time: std::time::Instant::now(),
10000            },
10001            false,
10002            cx,
10003        )
10004    });
10005
10006    // Connect the client side and build a remote project.
10007    let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
10008    let project = cx.update(|cx| {
10009        let project_client = client::Client::new(
10010            Arc::new(clock::FakeSystemClock::new()),
10011            http_client::FakeHttpClient::with_404_response(),
10012            cx,
10013        );
10014        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
10015        project::Project::remote(
10016            remote_client,
10017            project_client,
10018            node_runtime::NodeRuntime::unavailable(),
10019            user_store,
10020            app_state.languages.clone(),
10021            app_state.fs.clone(),
10022            false,
10023            cx,
10024        )
10025    });
10026
10027    // Open the remote worktree.
10028    project
10029        .update(cx, |project, cx| {
10030            project.find_or_create_worktree(Path::new("/project"), true, cx)
10031        })
10032        .await
10033        .expect("should open remote worktree");
10034    cx.run_until_parked();
10035
10036    // Verify the project is remote.
10037    project.read_with(cx, |project, cx| {
10038        assert!(!project.is_local(), "project should be remote");
10039        assert!(
10040            project.remote_connection_options(cx).is_some(),
10041            "project should have remote connection options"
10042        );
10043    });
10044
10045    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10046
10047    // Create MultiWorkspace with the remote project.
10048    let (multi_workspace, cx) =
10049        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10050    let sidebar = setup_sidebar(&multi_workspace, cx);
10051
10052    cx.run_until_parked();
10053
10054    // Save a thread for the main remote workspace (folder_paths match
10055    // the open workspace, so it will be classified as Open).
10056    let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
10057    save_thread_metadata(
10058        main_thread_id.clone(),
10059        Some("Main Thread".into()),
10060        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10061        None,
10062        None,
10063        &project,
10064        cx,
10065    );
10066    cx.run_until_parked();
10067
10068    // Save a thread whose folder_paths point to a linked worktree path
10069    // that doesn't have an open workspace ("/project-wt-1"), but whose
10070    // main_worktree_paths match the project group key so it appears
10071    // in the sidebar under the same remote group. This simulates a
10072    // linked worktree workspace that was closed.
10073    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10074    let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
10075        (
10076            p.project_group_key(cx).path_list().clone(),
10077            p.remote_connection_options(cx),
10078        )
10079    });
10080    cx.update(|_window, cx| {
10081        let metadata = ThreadMetadata {
10082            thread_id: ThreadId::new(),
10083            session_id: Some(remote_thread_id.clone()),
10084            agent_id: agent::ZED_AGENT_ID.clone(),
10085            title: Some("Worktree Thread".into()),
10086            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10087            created_at: None,
10088            interacted_at: None,
10089            worktree_paths: WorktreePaths::from_path_lists(
10090                main_worktree_paths,
10091                PathList::new(&[PathBuf::from("/project-wt-1")]),
10092            )
10093            .unwrap(),
10094            archived: false,
10095            remote_connection,
10096        };
10097        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10098    });
10099    cx.run_until_parked();
10100
10101    focus_sidebar(&sidebar, cx);
10102    sidebar.update_in(cx, |sidebar, _window, _cx| {
10103        sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10104            matches!(
10105                entry,
10106                ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10107            )
10108        });
10109    });
10110
10111    let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10112    let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10113
10114    sidebar
10115        .update(cx, |_, cx| {
10116            cx.observe_self(move |sidebar, _cx| {
10117                let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10118                    if let ListEntry::ProjectHeader { label, .. } = entry {
10119                        Some(label.as_ref())
10120                    } else {
10121                        None
10122                    }
10123                });
10124
10125                let Some(project_header) = project_headers.next() else {
10126                    saw_separate_project_header_for_observer
10127                        .store(true, std::sync::atomic::Ordering::SeqCst);
10128                    return;
10129                };
10130
10131                if project_header != "project" || project_headers.next().is_some() {
10132                    saw_separate_project_header_for_observer
10133                        .store(true, std::sync::atomic::Ordering::SeqCst);
10134                }
10135            })
10136        })
10137        .detach();
10138
10139    multi_workspace.update(cx, |multi_workspace, cx| {
10140        let workspace = multi_workspace.workspace().clone();
10141        workspace.update(cx, |workspace: &mut Workspace, cx| {
10142            let remote_client = workspace
10143                .project()
10144                .read(cx)
10145                .remote_client()
10146                .expect("main remote project should have a remote client");
10147            remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10148                remote_client.force_server_not_running(cx);
10149            });
10150        });
10151    });
10152    cx.run_until_parked();
10153
10154    let (server_session_2, connect_guard_2) =
10155        remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10156    let _headless_2 = server_cx.new(|cx| {
10157        remote_server::HeadlessProject::new(
10158            remote_server::HeadlessAppState {
10159                session: server_session_2,
10160                fs: server_fs.clone(),
10161                http_client: Arc::new(http_client::BlockedHttpClient),
10162                node_runtime: node_runtime::NodeRuntime::unavailable(),
10163                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10164                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10165                startup_time: std::time::Instant::now(),
10166            },
10167            false,
10168            cx,
10169        )
10170    });
10171    drop(connect_guard_2);
10172
10173    let window = cx.windows()[0];
10174    cx.update_window(window, |_, window, cx| {
10175        window.dispatch_action(Confirm.boxed_clone(), cx);
10176    })
10177    .unwrap();
10178
10179    cx.run_until_parked();
10180
10181    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10182        assert_eq!(
10183            mw.workspaces().count(),
10184            2,
10185            "confirming a closed remote thread should open a second workspace"
10186        );
10187        mw.workspaces()
10188            .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10189            .unwrap()
10190            .clone()
10191    });
10192
10193    server_fs
10194        .add_linked_worktree_for_repo(
10195            Path::new("/project/.git"),
10196            true,
10197            git::repository::Worktree {
10198                path: PathBuf::from("/project-wt-1"),
10199                ref_name: Some("refs/heads/feature-wt".into()),
10200                sha: "abc123".into(),
10201                is_main: false,
10202                is_bare: false,
10203            },
10204        )
10205        .await;
10206
10207    server_cx.run_until_parked();
10208    cx.run_until_parked();
10209    server_cx.run_until_parked();
10210    cx.run_until_parked();
10211
10212    let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10213    let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10214        workspace.project().read(cx).project_group_key(cx)
10215    });
10216
10217    assert_eq!(
10218        group_after_update,
10219        project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10220        "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10221         final sidebar entries: {:?}",
10222        entries_after_update,
10223    );
10224
10225    sidebar.update(cx, |sidebar, _cx| {
10226        assert_remote_project_integration_sidebar_state(
10227            sidebar,
10228            &main_thread_id,
10229            &remote_thread_id,
10230        );
10231    });
10232
10233    assert!(
10234        !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10235        "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10236         final group: {:?}; final sidebar entries: {:?}",
10237        group_after_update,
10238        entries_after_update,
10239    );
10240}
10241
10242#[gpui::test]
10243async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10244    // When the thread's folder_paths don't exactly match any workspace's
10245    // root paths (e.g. because a folder was added to the workspace after
10246    // the thread was created), workspace_to_remove is None. But the linked
10247    // worktree workspace still needs to be removed so that its worktree
10248    // entities are released, allowing git worktree removal to proceed.
10249    //
10250    // With the fix, archive_thread scans roots_to_archive for any linked
10251    // worktree workspaces and includes them in the removal set, even when
10252    // the thread's folder_paths don't match the workspace's root paths.
10253    init_test(cx);
10254    let fs = FakeFs::new(cx.executor());
10255
10256    fs.insert_tree(
10257        "/project",
10258        serde_json::json!({
10259            ".git": {
10260                "worktrees": {
10261                    "feature-a": {
10262                        "commondir": "../../",
10263                        "HEAD": "ref: refs/heads/feature-a",
10264                    },
10265                },
10266            },
10267            "src": {},
10268        }),
10269    )
10270    .await;
10271
10272    fs.insert_tree(
10273        "/worktrees/project/feature-a/project",
10274        serde_json::json!({
10275            ".git": "gitdir: /project/.git/worktrees/feature-a",
10276            "src": {
10277                "main.rs": "fn main() {}",
10278            },
10279        }),
10280    )
10281    .await;
10282
10283    fs.add_linked_worktree_for_repo(
10284        Path::new("/project/.git"),
10285        false,
10286        git::repository::Worktree {
10287            path: PathBuf::from("/worktrees/project/feature-a/project"),
10288            ref_name: Some("refs/heads/feature-a".into()),
10289            sha: "abc".into(),
10290            is_main: false,
10291            is_bare: false,
10292        },
10293    )
10294    .await;
10295
10296    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10297
10298    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10299    let worktree_project = project::Project::test(
10300        fs.clone(),
10301        ["/worktrees/project/feature-a/project".as_ref()],
10302        cx,
10303    )
10304    .await;
10305
10306    main_project
10307        .update(cx, |p, cx| p.git_scans_complete(cx))
10308        .await;
10309    worktree_project
10310        .update(cx, |p, cx| p.git_scans_complete(cx))
10311        .await;
10312
10313    let (multi_workspace, cx) =
10314        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10315    let sidebar = setup_sidebar(&multi_workspace, cx);
10316
10317    multi_workspace.update_in(cx, |mw, window, cx| {
10318        mw.test_add_workspace(worktree_project.clone(), window, cx)
10319    });
10320
10321    // Save thread metadata using folder_paths that DON'T match the
10322    // workspace's root paths. This simulates the case where the workspace's
10323    // paths diverged (e.g. a folder was added after thread creation).
10324    // This causes workspace_to_remove to be None because
10325    // workspace_for_paths can't find a workspace with these exact paths.
10326    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10327    save_thread_metadata_with_main_paths(
10328        "worktree-thread",
10329        "Worktree Thread",
10330        PathList::new(&[
10331            PathBuf::from("/worktrees/project/feature-a/project"),
10332            PathBuf::from("/nonexistent"),
10333        ]),
10334        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10335        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10336        cx,
10337    );
10338
10339    // Also save a main thread so the sidebar has something to show.
10340    save_thread_metadata(
10341        acp::SessionId::new(Arc::from("main-thread")),
10342        Some("Main Thread".into()),
10343        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10344        None,
10345        None,
10346        &main_project,
10347        cx,
10348    );
10349    cx.run_until_parked();
10350
10351    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10352    cx.run_until_parked();
10353
10354    assert_eq!(
10355        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10356        2,
10357        "should start with 2 workspaces (main + linked worktree)"
10358    );
10359
10360    // Archive the worktree thread.
10361    sidebar.update_in(cx, |sidebar, window, cx| {
10362        sidebar.archive_thread(&wt_thread_id, window, cx);
10363    });
10364
10365    cx.run_until_parked();
10366
10367    // The linked worktree workspace should have been removed, even though
10368    // workspace_to_remove was None (paths didn't match).
10369    assert_eq!(
10370        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10371        1,
10372        "linked worktree workspace should be removed after archiving, \
10373         even when folder_paths don't match workspace root paths"
10374    );
10375
10376    // The thread should still be archived (not unarchived due to an error).
10377    let still_archived = cx.update(|_, cx| {
10378        ThreadMetadataStore::global(cx)
10379            .read(cx)
10380            .entry_by_session(&wt_thread_id)
10381            .map(|t| t.archived)
10382    });
10383    assert_eq!(
10384        still_archived,
10385        Some(true),
10386        "thread should still be archived (not rolled back due to error)"
10387    );
10388
10389    // The linked worktree directory should be removed from disk.
10390    assert!(
10391        !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
10392            .await,
10393        "linked worktree directory should be removed from disk"
10394    );
10395}
10396
10397#[gpui::test]
10398async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10399    // When a workspace contains both a worktree being archived and other
10400    // worktrees that should remain, only the editor items referencing the
10401    // archived worktree should be closed — the workspace itself must be
10402    // preserved.
10403    init_test(cx);
10404    let fs = FakeFs::new(cx.executor());
10405
10406    fs.insert_tree(
10407        "/main-repo",
10408        serde_json::json!({
10409            ".git": {
10410                "worktrees": {
10411                    "feature-b": {
10412                        "commondir": "../../",
10413                        "HEAD": "ref: refs/heads/feature-b",
10414                    },
10415                },
10416            },
10417            "src": {
10418                "lib.rs": "pub fn hello() {}",
10419            },
10420        }),
10421    )
10422    .await;
10423
10424    fs.insert_tree(
10425        "/worktrees/main-repo/feature-b/main-repo",
10426        serde_json::json!({
10427            ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10428            "src": {
10429                "main.rs": "fn main() { hello(); }",
10430            },
10431        }),
10432    )
10433    .await;
10434
10435    fs.add_linked_worktree_for_repo(
10436        Path::new("/main-repo/.git"),
10437        false,
10438        git::repository::Worktree {
10439            path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10440            ref_name: Some("refs/heads/feature-b".into()),
10441            sha: "def".into(),
10442            is_main: false,
10443            is_bare: false,
10444        },
10445    )
10446    .await;
10447
10448    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10449
10450    // Create a single project that contains BOTH the main repo and the
10451    // linked worktree — this makes it a "mixed" workspace.
10452    let mixed_project = project::Project::test(
10453        fs.clone(),
10454        [
10455            "/main-repo".as_ref(),
10456            "/worktrees/main-repo/feature-b/main-repo".as_ref(),
10457        ],
10458        cx,
10459    )
10460    .await;
10461
10462    mixed_project
10463        .update(cx, |p, cx| p.git_scans_complete(cx))
10464        .await;
10465
10466    let (multi_workspace, cx) = cx
10467        .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10468    let sidebar = setup_sidebar(&multi_workspace, cx);
10469
10470    // Open editor items in both worktrees so we can verify which ones
10471    // get closed.
10472    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10473
10474    let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10475        ws.project()
10476            .read(cx)
10477            .visible_worktrees(cx)
10478            .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10479            .collect()
10480    });
10481
10482    let main_repo_wt_id = worktree_ids
10483        .iter()
10484        .find(|(_, path)| path.as_ref() == Path::new("/main-repo"))
10485        .map(|(id, _)| *id)
10486        .expect("should find main-repo worktree");
10487
10488    let feature_b_wt_id = worktree_ids
10489        .iter()
10490        .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo"))
10491        .map(|(id, _)| *id)
10492        .expect("should find feature-b worktree");
10493
10494    // Open files from both worktrees.
10495    let main_repo_path = project::ProjectPath {
10496        worktree_id: main_repo_wt_id,
10497        path: Arc::from(rel_path("src/lib.rs")),
10498    };
10499    let feature_b_path = project::ProjectPath {
10500        worktree_id: feature_b_wt_id,
10501        path: Arc::from(rel_path("src/main.rs")),
10502    };
10503
10504    workspace
10505        .update_in(cx, |ws, window, cx| {
10506            ws.open_path(main_repo_path.clone(), None, true, window, cx)
10507        })
10508        .await
10509        .expect("should open main-repo file");
10510    workspace
10511        .update_in(cx, |ws, window, cx| {
10512            ws.open_path(feature_b_path.clone(), None, true, window, cx)
10513        })
10514        .await
10515        .expect("should open feature-b file");
10516
10517    cx.run_until_parked();
10518
10519    // Verify both items are open.
10520    let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10521        ws.panes()
10522            .iter()
10523            .flat_map(|pane| {
10524                pane.read(cx)
10525                    .items()
10526                    .filter_map(|item| item.project_path(cx))
10527            })
10528            .collect()
10529    });
10530    assert!(
10531        open_paths_before
10532            .iter()
10533            .any(|pp| pp.worktree_id == main_repo_wt_id),
10534        "main-repo file should be open"
10535    );
10536    assert!(
10537        open_paths_before
10538            .iter()
10539            .any(|pp| pp.worktree_id == feature_b_wt_id),
10540        "feature-b file should be open"
10541    );
10542
10543    // Save thread metadata for the linked worktree with deliberately
10544    // mismatched folder_paths to trigger the scan-based detection.
10545    save_thread_metadata_with_main_paths(
10546        "feature-b-thread",
10547        "Feature B Thread",
10548        PathList::new(&[
10549            PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10550            PathBuf::from("/nonexistent"),
10551        ]),
10552        PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10553        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10554        cx,
10555    );
10556
10557    // Save another thread that references only the main repo (not the
10558    // linked worktree) so archiving the feature-b thread's worktree isn't
10559    // blocked by another unarchived thread referencing the same path.
10560    save_thread_metadata_with_main_paths(
10561        "other-thread",
10562        "Other Thread",
10563        PathList::new(&[PathBuf::from("/main-repo")]),
10564        PathList::new(&[PathBuf::from("/main-repo")]),
10565        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10566        cx,
10567    );
10568    cx.run_until_parked();
10569
10570    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10571    cx.run_until_parked();
10572
10573    // There should still be exactly 1 workspace.
10574    assert_eq!(
10575        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10576        1,
10577        "should have 1 workspace (the mixed workspace)"
10578    );
10579
10580    // Archive the feature-b thread.
10581    let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10582    sidebar.update_in(cx, |sidebar, window, cx| {
10583        sidebar.archive_thread(&fb_session_id, window, cx);
10584    });
10585
10586    cx.run_until_parked();
10587
10588    // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10589    assert_eq!(
10590        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10591        1,
10592        "mixed workspace should be preserved"
10593    );
10594
10595    // Only the feature-b editor item should have been closed.
10596    let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10597        ws.panes()
10598            .iter()
10599            .flat_map(|pane| {
10600                pane.read(cx)
10601                    .items()
10602                    .filter_map(|item| item.project_path(cx))
10603            })
10604            .collect()
10605    });
10606    assert!(
10607        open_paths_after
10608            .iter()
10609            .any(|pp| pp.worktree_id == main_repo_wt_id),
10610        "main-repo file should still be open"
10611    );
10612    assert!(
10613        !open_paths_after
10614            .iter()
10615            .any(|pp| pp.worktree_id == feature_b_wt_id),
10616        "feature-b file should have been closed"
10617    );
10618}
10619
10620#[test]
10621fn test_worktree_info_branch_names_for_main_worktrees() {
10622    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10623    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10624
10625    let branch_by_path: HashMap<PathBuf, SharedString> =
10626        [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10627            .into_iter()
10628            .collect();
10629
10630    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10631    assert_eq!(infos.len(), 1);
10632    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10633    assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10634    assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10635}
10636
10637#[test]
10638fn test_worktree_info_branch_names_for_linked_worktrees() {
10639    let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10640    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10641    let worktree_paths =
10642        WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10643
10644    let branch_by_path: HashMap<PathBuf, SharedString> = [(
10645        PathBuf::from("/projects/myapp-feature"),
10646        "feature-branch".into(),
10647    )]
10648    .into_iter()
10649    .collect();
10650
10651    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10652    assert_eq!(infos.len(), 1);
10653    assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10654    assert_eq!(
10655        infos[0].branch_name,
10656        Some(SharedString::from("feature-branch"))
10657    );
10658}
10659
10660#[test]
10661fn test_worktree_info_missing_branch_returns_none() {
10662    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10663    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10664
10665    let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10666
10667    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10668    assert_eq!(infos.len(), 1);
10669    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10670    assert_eq!(infos[0].branch_name, None);
10671    assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10672}
10673
10674#[gpui::test]
10675async fn test_remote_archive_thread_with_active_connection(
10676    cx: &mut TestAppContext,
10677    server_cx: &mut TestAppContext,
10678) {
10679    // End-to-end test of archiving a remote thread tied to a linked git
10680    // worktree. Archival should:
10681    //  1. Persist the worktree's git state via the remote repository RPCs
10682    //     (head_sha / create_archive_checkpoint / update_ref).
10683    //  2. Remove the linked worktree directory from the *remote* filesystem
10684    //     via the GitRemoveWorktree RPC.
10685    //  3. Mark the thread metadata archived and hide it from the sidebar.
10686    //
10687    // The mock remote transport only supports one live `RemoteClient` per
10688    // connection at a time (each client's `start_proxy` replaces the
10689    // previous server channel), so we can't split the main repo and the
10690    // linked worktree across two remote projects the way Zed does in
10691    // production. Opening both as visible worktrees of a single remote
10692    // project still exercises every interesting path of the archive flow
10693    // while staying within the mock's multiplexing limits.
10694    init_test(cx);
10695
10696    cx.update(|cx| {
10697        release_channel::init(semver::Version::new(0, 0, 0), cx);
10698    });
10699
10700    let app_state = cx.update(|cx| {
10701        let app_state = workspace::AppState::test(cx);
10702        workspace::init(app_state.clone(), cx);
10703        app_state
10704    });
10705
10706    server_cx.update(|cx| {
10707        release_channel::init(semver::Version::new(0, 0, 0), cx);
10708    });
10709
10710    // Set up the remote filesystem with a main repo and one linked worktree.
10711    let server_fs = FakeFs::new(server_cx.executor());
10712    server_fs
10713        .insert_tree(
10714            "/project",
10715            serde_json::json!({
10716                ".git": {
10717                    "worktrees": {
10718                        "feature-a": {
10719                            "commondir": "../../",
10720                            "HEAD": "ref: refs/heads/feature-a",
10721                        },
10722                    },
10723                },
10724                "src": { "main.rs": "fn main() {}" },
10725            }),
10726        )
10727        .await;
10728    server_fs
10729        .insert_tree(
10730            "/worktrees/project/feature-a/project",
10731            serde_json::json!({
10732                ".git": "gitdir: /project/.git/worktrees/feature-a",
10733                "src": { "lib.rs": "// feature" },
10734            }),
10735        )
10736        .await;
10737    server_fs
10738        .add_linked_worktree_for_repo(
10739            Path::new("/project/.git"),
10740            false,
10741            git::repository::Worktree {
10742                path: PathBuf::from("/worktrees/project/feature-a/project"),
10743                ref_name: Some("refs/heads/feature-a".into()),
10744                sha: "abc".into(),
10745                is_main: false,
10746                is_bare: false,
10747            },
10748        )
10749        .await;
10750    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10751    server_fs.set_head_for_repo(
10752        Path::new("/project/.git"),
10753        &[("src/main.rs", "fn main() {}".into())],
10754        "head-sha",
10755    );
10756
10757    // Open a single remote project with both the main repo and the linked
10758    // worktree as visible worktrees. The mock transport doesn't multiplex
10759    // multiple `RemoteClient`s over one pooled connection cleanly (each
10760    // client's `start_proxy` clobbers the previous one's server channel),
10761    // so we can't build two separate `Project::remote` instances in this
10762    // test. Folding both worktrees into one project still exercises the
10763    // archive flow's interesting paths: `build_root_plan` classifies the
10764    // linked worktree correctly, and `find_or_create_repository` finds
10765    // the main repo live on that same project — avoiding the temp-project
10766    // fallback that would also run into the multiplexing limitation.
10767    let (project, _headless, _opts) = start_remote_project(
10768        &server_fs,
10769        Path::new("/project"),
10770        &app_state,
10771        None,
10772        cx,
10773        server_cx,
10774    )
10775    .await;
10776    project
10777        .update(cx, |project, cx| {
10778            project.find_or_create_worktree(
10779                Path::new("/worktrees/project/feature-a/project"),
10780                true,
10781                cx,
10782            )
10783        })
10784        .await
10785        .expect("should open linked worktree on remote");
10786    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10787    cx.run_until_parked();
10788
10789    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10790
10791    let (multi_workspace, cx) =
10792        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10793    let sidebar = setup_sidebar(&multi_workspace, cx);
10794
10795    // The worktree thread's (main_worktree_path, folder_path) pair points
10796    // the folder at the linked worktree checkout and the main at the
10797    // parent repo, so `build_root_plan` targets the linked worktree
10798    // specifically and knows which main repo owns it.
10799    let remote_connection = project.read_with(cx, |p, cx| p.remote_connection_options(cx));
10800    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10801    cx.update(|_window, cx| {
10802        let metadata = ThreadMetadata {
10803            thread_id: ThreadId::new(),
10804            session_id: Some(wt_thread_id.clone()),
10805            agent_id: agent::ZED_AGENT_ID.clone(),
10806            title: Some("Worktree Thread".into()),
10807            updated_at: chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
10808                .unwrap(),
10809            created_at: None,
10810            interacted_at: None,
10811            worktree_paths: WorktreePaths::from_path_lists(
10812                PathList::new(&[PathBuf::from("/project")]),
10813                PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]),
10814            )
10815            .unwrap(),
10816            archived: false,
10817            remote_connection,
10818        };
10819        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10820    });
10821    cx.run_until_parked();
10822
10823    assert!(
10824        server_fs
10825            .is_dir(Path::new("/worktrees/project/feature-a/project"))
10826            .await,
10827        "linked worktree directory should exist on remote before archiving"
10828    );
10829
10830    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
10831        sidebar.archive_thread(&wt_thread_id, window, cx);
10832    });
10833    cx.run_until_parked();
10834    server_cx.run_until_parked();
10835
10836    let is_archived = cx.update(|_window, cx| {
10837        ThreadMetadataStore::global(cx)
10838            .read(cx)
10839            .entry_by_session(&wt_thread_id)
10840            .map(|t| t.archived)
10841            .unwrap_or(false)
10842    });
10843    assert!(is_archived, "worktree thread should be archived");
10844
10845    assert!(
10846        !server_fs
10847            .is_dir(Path::new("/worktrees/project/feature-a/project"))
10848            .await,
10849        "linked worktree directory should be removed from remote fs \
10850         (the GitRemoveWorktree RPC runs `Repository::remove_worktree` \
10851         on the headless server, which deletes the directory via `Fs::remove_dir` \
10852         before running `git worktree remove --force`)"
10853    );
10854
10855    let entries = visible_entries_as_strings(&sidebar, cx);
10856    assert!(
10857        !entries.iter().any(|e| e.contains("Worktree Thread")),
10858        "archived worktree thread should be hidden from sidebar: {entries:?}"
10859    );
10860}
10861
10862#[gpui::test]
10863async fn test_remote_archive_thread_with_disconnected_remote(
10864    cx: &mut TestAppContext,
10865    server_cx: &mut TestAppContext,
10866) {
10867    // When a remote thread has no linked-worktree state to archive (only
10868    // a main worktree), archival is a pure metadata operation: no RPCs
10869    // are issued against the remote server. This must succeed even when
10870    // the connection has dropped out, because losing connectivity should
10871    // not block users from cleaning up their thread list.
10872    //
10873    // Threads that *do* have linked-worktree state require a live
10874    // connection to run the git worktree removal on the server; that
10875    // path is covered by `test_remote_archive_thread_with_active_connection`.
10876    init_test(cx);
10877
10878    cx.update(|cx| {
10879        release_channel::init(semver::Version::new(0, 0, 0), cx);
10880    });
10881
10882    let app_state = cx.update(|cx| {
10883        let app_state = workspace::AppState::test(cx);
10884        workspace::init(app_state.clone(), cx);
10885        app_state
10886    });
10887
10888    server_cx.update(|cx| {
10889        release_channel::init(semver::Version::new(0, 0, 0), cx);
10890    });
10891
10892    let server_fs = FakeFs::new(server_cx.executor());
10893    server_fs
10894        .insert_tree(
10895            "/project",
10896            serde_json::json!({
10897                ".git": {},
10898                "src": { "main.rs": "fn main() {}" },
10899            }),
10900        )
10901        .await;
10902    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10903
10904    let (project, _headless, _opts) = start_remote_project(
10905        &server_fs,
10906        Path::new("/project"),
10907        &app_state,
10908        None,
10909        cx,
10910        server_cx,
10911    )
10912    .await;
10913    let remote_client = project
10914        .read_with(cx, |project, _cx| project.remote_client())
10915        .expect("remote project should expose its client");
10916
10917    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10918
10919    let (multi_workspace, cx) =
10920        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10921    let sidebar = setup_sidebar(&multi_workspace, cx);
10922
10923    let thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10924    save_thread_metadata(
10925        thread_id.clone(),
10926        Some("Remote Thread".into()),
10927        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10928        None,
10929        None,
10930        &project,
10931        cx,
10932    );
10933    cx.run_until_parked();
10934
10935    // Sanity-check: there is nothing on the remote fs outside the main
10936    // repo, so archival should not need to touch the server.
10937    assert!(
10938        !server_fs.is_dir(Path::new("/worktrees")).await,
10939        "no linked worktrees on the server before archiving"
10940    );
10941
10942    // Disconnect the remote connection before archiving. We don't
10943    // `run_until_parked` here because the disconnect itself triggers
10944    // reconnection work that can't complete in the test environment.
10945    remote_client.update_in(cx, |client, _window, cx| {
10946        client.simulate_disconnect(cx).detach();
10947    });
10948
10949    sidebar.update_in(cx, |sidebar, window, cx| {
10950        sidebar.archive_thread(&thread_id, window, cx);
10951    });
10952    cx.run_until_parked();
10953
10954    let is_archived = cx.update(|_window, cx| {
10955        ThreadMetadataStore::global(cx)
10956            .read(cx)
10957            .entry_by_session(&thread_id)
10958            .map(|t| t.archived)
10959            .unwrap_or(false)
10960    });
10961    assert!(
10962        is_archived,
10963        "thread should be archived even when remote is disconnected"
10964    );
10965
10966    let entries = visible_entries_as_strings(&sidebar, cx);
10967    assert!(
10968        !entries.iter().any(|e| e.contains("Remote Thread")),
10969        "archived thread should be hidden from sidebar: {entries:?}"
10970    );
10971}
10972
10973#[gpui::test]
10974async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) {
10975    init_test(cx);
10976    let fs = FakeFs::new(cx.executor());
10977    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
10978        .await;
10979    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
10980        .await;
10981    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10982    let project = project::Project::test(fs, ["/project-a".as_ref()], cx).await;
10983
10984    let (multi_workspace, cx) =
10985        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10986
10987    // Set up the sidebar while the project is local. This registers the
10988    // WorktreePathsChanged subscription for the project.
10989    let _sidebar = setup_sidebar(&multi_workspace, cx);
10990
10991    let session_id = acp::SessionId::new(Arc::from("test-thread"));
10992    save_named_thread_metadata("test-thread", "My Thread", &project, cx).await;
10993
10994    let thread_id = cx.update(|_window, cx| {
10995        ThreadMetadataStore::global(cx)
10996            .read(cx)
10997            .entry_by_session(&session_id)
10998            .map(|e| e.thread_id)
10999            .expect("thread must be in the store")
11000    });
11001
11002    cx.update(|_window, cx| {
11003        let store = ThreadMetadataStore::global(cx);
11004        let entry = store.read(cx).entry(thread_id).unwrap();
11005        assert_eq!(
11006            entry.folder_paths().paths(),
11007            &[PathBuf::from("/project-a")],
11008            "thread must be saved with /project-a before collab"
11009        );
11010    });
11011
11012    // Transition the project into collab mode. The sidebar's subscription is
11013    // still active from when the project was local.
11014    project.update(cx, |project, _cx| {
11015        project.mark_as_collab_for_testing();
11016    });
11017
11018    // Adding a worktree fires WorktreePathsChanged with old_paths = {/project-a}.
11019    // The sidebar's subscription is still active, so move_thread_paths is called.
11020    // Without the is_via_collab() guard inside move_thread_paths, this would
11021    // update the stored thread paths from {/project-a} to {/project-a, /project-b}.
11022    project
11023        .update(cx, |project, cx| {
11024            project.find_or_create_worktree("/project-b", true, cx)
11025        })
11026        .await
11027        .expect("should add worktree");
11028    cx.run_until_parked();
11029
11030    cx.update(|_window, cx| {
11031        let store = ThreadMetadataStore::global(cx);
11032        let entry = store
11033            .read(cx)
11034            .entry(thread_id)
11035            .expect("thread must still exist");
11036        assert_eq!(
11037            entry.folder_paths().paths(),
11038            &[PathBuf::from("/project-a")],
11039            "thread path must not change when project is via collab"
11040        );
11041    });
11042}
11043
11044#[gpui::test]
11045async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace(
11046    cx: &mut TestAppContext,
11047) {
11048    // Regression test for: cmd-clicking a project group header should return
11049    // the user to the workspace they most recently had active in that group,
11050    // including workspaces rooted at a linked worktree.
11051    init_test(cx);
11052    let fs = FakeFs::new(cx.executor());
11053
11054    fs.insert_tree(
11055        "/project-a",
11056        serde_json::json!({
11057            ".git": {},
11058            "src": {},
11059        }),
11060    )
11061    .await;
11062    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11063        .await;
11064
11065    fs.add_linked_worktree_for_repo(
11066        Path::new("/project-a/.git"),
11067        false,
11068        git::repository::Worktree {
11069            path: std::path::PathBuf::from("/wt-feature-a"),
11070            ref_name: Some("refs/heads/feature-a".into()),
11071            sha: "aaa".into(),
11072            is_main: false,
11073            is_bare: false,
11074        },
11075    )
11076    .await;
11077
11078    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11079
11080    let main_project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
11081    let worktree_project_a =
11082        project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
11083    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
11084
11085    main_project_a
11086        .update(cx, |p, cx| p.git_scans_complete(cx))
11087        .await;
11088    worktree_project_a
11089        .update(cx, |p, cx| p.git_scans_complete(cx))
11090        .await;
11091
11092    // The multi-workspace starts with the main-paths workspace of group A
11093    // as the initially active workspace.
11094    let (multi_workspace, cx) = cx
11095        .add_window_view(|window, cx| MultiWorkspace::test_new(main_project_a.clone(), window, cx));
11096
11097    let sidebar = setup_sidebar(&multi_workspace, cx);
11098
11099    // Capture the initially active workspace (group A's main-paths workspace)
11100    // *before* registering additional workspaces, since `workspaces()` returns
11101    // retained workspaces in registration order — not activation order — and
11102    // the multi-workspace's starting workspace may not be retained yet.
11103    let main_workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11104
11105    // Register the linked-worktree workspace (group A) and the group-B
11106    // workspace. Both get retained by the multi-workspace.
11107    let worktree_workspace_a = multi_workspace.update_in(cx, |mw, window, cx| {
11108        mw.test_add_workspace(worktree_project_a.clone(), window, cx)
11109    });
11110    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
11111        mw.test_add_workspace(project_b.clone(), window, cx)
11112    });
11113
11114    cx.run_until_parked();
11115
11116    // Step 1: activate the linked-worktree workspace. The MultiWorkspace
11117    // records this as the last-active workspace for group A on its
11118    // ProjectGroupState. (We don't assert on the initial active workspace
11119    // because `test_add_workspace` may auto-activate newly registered
11120    // workspaces — what matters for this test is the explicit sequence of
11121    // activations below.)
11122    multi_workspace.update_in(cx, |mw, window, cx| {
11123        mw.activate(worktree_workspace_a.clone(), None, window, cx);
11124    });
11125    cx.run_until_parked();
11126    assert_eq!(
11127        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11128        worktree_workspace_a,
11129        "linked-worktree workspace should be active after step 1"
11130    );
11131
11132    // Step 2: switch to the workspace for group B. Group A's last-active
11133    // workspace remains the linked-worktree one (group B getting activated
11134    // records *its own* last-active workspace, not group A's).
11135    multi_workspace.update_in(cx, |mw, window, cx| {
11136        mw.activate(workspace_b.clone(), None, window, cx);
11137    });
11138    cx.run_until_parked();
11139    assert_eq!(
11140        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11141        workspace_b,
11142        "group B's workspace should be active after step 2"
11143    );
11144
11145    // Step 3: simulate cmd-click on group A's header. The project group key
11146    // for group A is derived from the *main-paths* workspace (linked-worktree
11147    // workspaces share the same key because it normalizes to main-worktree
11148    // paths).
11149    let group_a_key = main_workspace_a.read_with(cx, |ws, cx| ws.project_group_key(cx));
11150    sidebar.update_in(cx, |sidebar, window, cx| {
11151        sidebar.activate_or_open_workspace_for_group(&group_a_key, window, cx);
11152    });
11153    cx.run_until_parked();
11154
11155    // Expected: we're back in the linked-worktree workspace, not the
11156    // main-paths one.
11157    let active_after_cmd_click = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11158    assert_eq!(
11159        active_after_cmd_click, worktree_workspace_a,
11160        "cmd-click on group A's header should return to the last-active \
11161         linked-worktree workspace, not the main-paths workspace"
11162    );
11163    assert_ne!(
11164        active_after_cmd_click, main_workspace_a,
11165        "cmd-click must not fall back to the main-paths workspace when a \
11166         linked-worktree workspace was the last-active one for the group"
11167    );
11168}