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
   36fn enable_agent_panel_terminal(cx: &mut TestAppContext) {
   37    cx.update(|cx| {
   38        cx.update_flags(true, vec!["agent-panel-terminal".to_string()]);
   39    });
   40}
   41
   42#[track_caller]
   43fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
   44    let active = sidebar.active_entry.as_ref();
   45    let matches = active.is_some_and(|entry| {
   46        matches!(entry, ActiveEntry::Thread { session_id: Some(active_session_id), .. } if active_session_id == session_id)
   47            || sidebar.contents.entries.iter().any(|list_entry| {
   48                matches!(list_entry, ListEntry::Thread(t)
   49                    if t.metadata.session_id.as_ref() == Some(session_id)
   50                        && entry.matches_entry(list_entry))
   51            })
   52    });
   53    assert!(
   54        matches,
   55        "{msg}: expected active_entry for session {session_id:?}, got {:?}",
   56        active,
   57    );
   58}
   59
   60#[track_caller]
   61fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   62    let thread_id = sidebar
   63        .contents
   64        .entries
   65        .iter()
   66        .find_map(|entry| match entry {
   67            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) => {
   68                Some(t.metadata.thread_id)
   69            }
   70            _ => None,
   71        });
   72    match thread_id {
   73        Some(tid) => {
   74            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { thread_id, .. }) if *thread_id == tid)
   75        }
   76        // Thread not in sidebar entries — can't confirm it's active.
   77        None => false,
   78    }
   79}
   80
   81#[track_caller]
   82fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
   83    assert!(
   84        matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == workspace),
   85        "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
   86        workspace.entity_id(),
   87        sidebar.active_entry,
   88    );
   89}
   90
   91fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
   92    sidebar
   93        .contents
   94        .entries
   95        .iter()
   96        .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id)))
   97}
   98
   99#[track_caller]
  100fn assert_remote_project_integration_sidebar_state(
  101    sidebar: &mut Sidebar,
  102    main_thread_id: &acp::SessionId,
  103    remote_thread_id: &acp::SessionId,
  104) {
  105    let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
  106        if let ListEntry::ProjectHeader { label, .. } = entry {
  107            Some(label.as_ref())
  108        } else {
  109            None
  110        }
  111    });
  112
  113    let Some(project_header) = project_headers.next() else {
  114        panic!("expected exactly one sidebar project header named `project`, found none");
  115    };
  116    assert_eq!(
  117        project_header, "project",
  118        "expected the only sidebar project header to be `project`"
  119    );
  120    if let Some(unexpected_header) = project_headers.next() {
  121        panic!(
  122            "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
  123        );
  124    }
  125
  126    let mut saw_main_thread = false;
  127    let mut saw_remote_thread = false;
  128    for entry in &sidebar.contents.entries {
  129        match entry {
  130            ListEntry::ProjectHeader { label, .. } => {
  131                assert_eq!(
  132                    label.as_ref(),
  133                    "project",
  134                    "expected the only sidebar project header to be `project`"
  135                );
  136            }
  137            ListEntry::Thread(thread)
  138                if thread.metadata.session_id.as_ref() == Some(main_thread_id) =>
  139            {
  140                saw_main_thread = true;
  141            }
  142            ListEntry::Thread(thread)
  143                if thread.metadata.session_id.as_ref() == Some(remote_thread_id) =>
  144            {
  145                saw_remote_thread = true;
  146            }
  147            ListEntry::Thread(thread) => {
  148                let title = thread.metadata.display_title();
  149                panic!(
  150                    "unexpected sidebar thread while simulating remote project integration flicker: title=`{}`",
  151                    title
  152                );
  153            }
  154            ListEntry::Terminal(terminal) => {
  155                panic!(
  156                    "unexpected sidebar terminal while simulating remote project integration flicker: title=`{}`",
  157                    terminal.title
  158                );
  159            }
  160        }
  161    }
  162
  163    assert!(
  164        saw_main_thread,
  165        "expected the sidebar to keep showing `Main Thread` under `project`"
  166    );
  167    assert!(
  168        saw_remote_thread,
  169        "expected the sidebar to keep showing `Worktree Thread` under `project`"
  170    );
  171}
  172
  173async fn init_test_project(
  174    worktree_path: &str,
  175    cx: &mut TestAppContext,
  176) -> Entity<project::Project> {
  177    init_test(cx);
  178    let fs = FakeFs::new(cx.executor());
  179    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
  180        .await;
  181    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
  182    project::Project::test(fs, [worktree_path.as_ref()], cx).await
  183}
  184
  185fn setup_sidebar(
  186    multi_workspace: &Entity<MultiWorkspace>,
  187    cx: &mut gpui::VisualTestContext,
  188) -> Entity<Sidebar> {
  189    let sidebar = setup_sidebar_closed(multi_workspace, cx);
  190    multi_workspace.update_in(cx, |mw, window, cx| {
  191        mw.toggle_sidebar(window, cx);
  192    });
  193    cx.run_until_parked();
  194    sidebar
  195}
  196
  197fn setup_sidebar_closed(
  198    multi_workspace: &Entity<MultiWorkspace>,
  199    cx: &mut gpui::VisualTestContext,
  200) -> Entity<Sidebar> {
  201    let multi_workspace = multi_workspace.clone();
  202    let sidebar =
  203        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  204    multi_workspace.update(cx, |mw, cx| {
  205        mw.register_sidebar(sidebar.clone(), cx);
  206    });
  207    cx.run_until_parked();
  208    sidebar
  209}
  210
  211async fn save_n_test_threads(
  212    count: u32,
  213    project: &Entity<project::Project>,
  214    cx: &mut gpui::VisualTestContext,
  215) {
  216    for i in 0..count {
  217        save_thread_metadata(
  218            acp::SessionId::new(Arc::from(format!("thread-{}", i))),
  219            Some(format!("Thread {}", i + 1).into()),
  220            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
  221            None,
  222            None,
  223            project,
  224            cx,
  225        )
  226    }
  227    cx.run_until_parked();
  228}
  229
  230async fn save_test_thread_metadata(
  231    session_id: &acp::SessionId,
  232    project: &Entity<project::Project>,
  233    cx: &mut TestAppContext,
  234) {
  235    save_thread_metadata(
  236        session_id.clone(),
  237        Some("Test".into()),
  238        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  239        None,
  240        None,
  241        project,
  242        cx,
  243    )
  244}
  245
  246async fn save_named_thread_metadata(
  247    session_id: &str,
  248    title: &str,
  249    project: &Entity<project::Project>,
  250    cx: &mut gpui::VisualTestContext,
  251) {
  252    save_thread_metadata(
  253        acp::SessionId::new(Arc::from(session_id)),
  254        Some(SharedString::from(title.to_string())),
  255        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  256        None,
  257        None,
  258        project,
  259        cx,
  260    );
  261    cx.run_until_parked();
  262}
  263
  264/// Spins up a fresh remote project backed by a headless server sharing
  265/// `server_fs`, opens the given worktree path on it, and returns the
  266/// project together with the headless entity (which the caller must keep
  267/// alive for the duration of the test) and the `RemoteConnectionOptions`
  268/// used for the fake server. Passing those options back into
  269/// `reuse_opts` on a subsequent call makes the new project share the
  270/// same `RemoteConnectionIdentity`, matching how Zed treats multiple
  271/// projects on the same SSH host.
  272async fn start_remote_project(
  273    server_fs: &Arc<FakeFs>,
  274    worktree_path: &Path,
  275    app_state: &Arc<workspace::AppState>,
  276    reuse_opts: Option<&remote::RemoteConnectionOptions>,
  277    cx: &mut TestAppContext,
  278    server_cx: &mut TestAppContext,
  279) -> (
  280    Entity<project::Project>,
  281    Entity<remote_server::HeadlessProject>,
  282    remote::RemoteConnectionOptions,
  283) {
  284    // Bare `_` on the guard so it's dropped immediately; holding onto it
  285    // would deadlock `connect_mock` below since the client waits on the
  286    // guard before completing the mock handshake.
  287    let (opts, server_session) = match reuse_opts {
  288        Some(existing) => {
  289            let (session, _) = remote::RemoteClient::fake_server_with_opts(existing, cx, server_cx);
  290            (existing.clone(), session)
  291        }
  292        None => {
  293            let (opts, session, _) = remote::RemoteClient::fake_server(cx, server_cx);
  294            (opts, session)
  295        }
  296    };
  297
  298    server_cx.update(remote_server::HeadlessProject::init);
  299    let server_executor = server_cx.executor();
  300    let fs = server_fs.clone();
  301    let headless = server_cx.new(|cx| {
  302        remote_server::HeadlessProject::new(
  303            remote_server::HeadlessAppState {
  304                session: server_session,
  305                fs,
  306                http_client: Arc::new(http_client::BlockedHttpClient),
  307                node_runtime: node_runtime::NodeRuntime::unavailable(),
  308                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
  309                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
  310                startup_time: std::time::Instant::now(),
  311            },
  312            false,
  313            cx,
  314        )
  315    });
  316
  317    let remote_client = remote::RemoteClient::connect_mock(opts.clone(), cx).await;
  318    let project = cx.update(|cx| {
  319        let project_client = client::Client::new(
  320            Arc::new(clock::FakeSystemClock::new()),
  321            http_client::FakeHttpClient::with_404_response(),
  322            cx,
  323        );
  324        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
  325        project::Project::remote(
  326            remote_client,
  327            project_client,
  328            node_runtime::NodeRuntime::unavailable(),
  329            user_store,
  330            app_state.languages.clone(),
  331            app_state.fs.clone(),
  332            false,
  333            cx,
  334        )
  335    });
  336
  337    project
  338        .update(cx, |project, cx| {
  339            project.find_or_create_worktree(worktree_path, true, cx)
  340        })
  341        .await
  342        .expect("should open remote worktree");
  343    cx.run_until_parked();
  344
  345    (project, headless, opts)
  346}
  347
  348fn save_thread_metadata(
  349    session_id: acp::SessionId,
  350    title: Option<SharedString>,
  351    updated_at: DateTime<Utc>,
  352    created_at: Option<DateTime<Utc>>,
  353    interacted_at: Option<DateTime<Utc>>,
  354    project: &Entity<project::Project>,
  355    cx: &mut TestAppContext,
  356) {
  357    cx.update(|cx| {
  358        let worktree_paths = project.read(cx).worktree_paths(cx);
  359        let remote_connection = project.read(cx).remote_connection_options(cx);
  360        let thread_id = ThreadMetadataStore::global(cx)
  361            .read(cx)
  362            .entries()
  363            .find(|e| e.session_id.as_ref() == Some(&session_id))
  364            .map(|e| e.thread_id)
  365            .unwrap_or_else(ThreadId::new);
  366        let metadata = ThreadMetadata {
  367            thread_id,
  368            session_id: Some(session_id),
  369            agent_id: agent::ZED_AGENT_ID.clone(),
  370            title,
  371            updated_at,
  372            created_at,
  373            interacted_at,
  374            worktree_paths,
  375            archived: false,
  376            remote_connection,
  377        };
  378        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  379    });
  380    cx.run_until_parked();
  381}
  382
  383fn save_thread_metadata_with_main_paths(
  384    session_id: &str,
  385    title: &str,
  386    folder_paths: PathList,
  387    main_worktree_paths: PathList,
  388    updated_at: DateTime<Utc>,
  389    cx: &mut TestAppContext,
  390) {
  391    let session_id = acp::SessionId::new(Arc::from(session_id));
  392    let title = SharedString::from(title.to_string());
  393    let thread_id = cx.update(|cx| {
  394        ThreadMetadataStore::global(cx)
  395            .read(cx)
  396            .entries()
  397            .find(|e| e.session_id.as_ref() == Some(&session_id))
  398            .map(|e| e.thread_id)
  399            .unwrap_or_else(ThreadId::new)
  400    });
  401    let metadata = ThreadMetadata {
  402        thread_id,
  403        session_id: Some(session_id),
  404        agent_id: agent::ZED_AGENT_ID.clone(),
  405        title: Some(title),
  406        updated_at,
  407        created_at: None,
  408        interacted_at: None,
  409        worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(),
  410        archived: false,
  411        remote_connection: None,
  412    };
  413    cx.update(|cx| {
  414        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
  415    });
  416    cx.run_until_parked();
  417}
  418
  419fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
  420    sidebar.update_in(cx, |_, window, cx| {
  421        cx.focus_self(window);
  422    });
  423    cx.run_until_parked();
  424}
  425
  426fn request_test_tool_authorization(
  427    thread: &Entity<AcpThread>,
  428    tool_call_id: &str,
  429    option_id: &str,
  430    cx: &mut gpui::VisualTestContext,
  431) {
  432    let tool_call_id = acp::ToolCallId::new(tool_call_id);
  433    let label = format!("Tool {tool_call_id}");
  434    let option_id = acp::PermissionOptionId::new(option_id);
  435    let _authorization_task = cx.update(|_, cx| {
  436        thread.update(cx, |thread, cx| {
  437            thread
  438                .request_tool_call_authorization(
  439                    acp::ToolCall::new(tool_call_id, label)
  440                        .kind(acp::ToolKind::Edit)
  441                        .into(),
  442                    PermissionOptions::Flat(vec![acp::PermissionOption::new(
  443                        option_id,
  444                        "Allow",
  445                        acp::PermissionOptionKind::AllowOnce,
  446                    )]),
  447                    acp_thread::AuthorizationKind::PermissionGrant,
  448                    cx,
  449                )
  450                .unwrap()
  451        })
  452    });
  453    cx.run_until_parked();
  454}
  455
  456fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
  457    let mut seen = Vec::new();
  458    let mut chips = Vec::new();
  459    for wt in worktrees {
  460        if wt.kind == ui::WorktreeKind::Main {
  461            continue;
  462        }
  463        let Some(name) = wt.worktree_name.as_ref() else {
  464            continue;
  465        };
  466        if !seen.contains(name) {
  467            seen.push(name.clone());
  468            chips.push(format!("{{{}}}", name));
  469        }
  470    }
  471    if chips.is_empty() {
  472        String::new()
  473    } else {
  474        format!(" {}", chips.join(", "))
  475    }
  476}
  477
  478fn visible_entries_as_strings(
  479    sidebar: &Entity<Sidebar>,
  480    cx: &mut gpui::VisualTestContext,
  481) -> Vec<String> {
  482    sidebar.read_with(cx, |sidebar, cx| {
  483        sidebar
  484            .contents
  485            .entries
  486            .iter()
  487            .enumerate()
  488            .map(|(ix, entry)| {
  489                let selected = if sidebar.selection == Some(ix) {
  490                    "  <== selected"
  491                } else {
  492                    ""
  493                };
  494                match entry {
  495                    ListEntry::ProjectHeader {
  496                        label,
  497                        key,
  498                        highlight_positions: _,
  499                        ..
  500                    } => {
  501                        let icon = if sidebar.is_group_collapsed(key, cx) {
  502                            ">"
  503                        } else {
  504                            "v"
  505                        };
  506                        format!("{} [{}]{}", icon, label, selected)
  507                    }
  508                    ListEntry::Thread(thread) => {
  509                        let title = thread.metadata.display_title();
  510                        let worktree = format_linked_worktree_chips(&thread.worktrees);
  511
  512                        {
  513                            let live = if thread.is_live { " *" } else { "" };
  514                            let status_str = match thread.status {
  515                                AgentThreadStatus::Running => " (running)",
  516                                AgentThreadStatus::Error => " (error)",
  517                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
  518                                _ => "",
  519                            };
  520                            let notified = if sidebar
  521                                .contents
  522                                .is_thread_notified(&thread.metadata.thread_id)
  523                            {
  524                                " (!)"
  525                            } else {
  526                                ""
  527                            };
  528                            format!("  {title}{worktree}{live}{status_str}{notified}{selected}")
  529                        }
  530                    }
  531                    ListEntry::Terminal(terminal) => {
  532                        let title = &terminal.title;
  533                        format!("  {title}{selected}")
  534                    }
  535                }
  536            })
  537            .collect()
  538    })
  539}
  540
  541#[gpui::test]
  542async fn test_serialization_round_trip(cx: &mut TestAppContext) {
  543    let project = init_test_project("/my-project", cx).await;
  544    let (multi_workspace, cx) =
  545        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  546    let sidebar = setup_sidebar(&multi_workspace, cx);
  547
  548    save_n_test_threads(3, &project, cx).await;
  549
  550    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  551
  552    // Set a custom width and collapse the group.
  553    sidebar.update_in(cx, |sidebar, window, cx| {
  554        sidebar.set_width(Some(px(420.0)), cx);
  555        sidebar.toggle_collapse(&project_group_key, window, cx);
  556    });
  557    cx.run_until_parked();
  558
  559    // Capture the serialized state from the first sidebar.
  560    let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
  561    let serialized = serialized.expect("serialized_state should return Some");
  562
  563    // Create a fresh sidebar and restore into it.
  564    let sidebar2 =
  565        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
  566    cx.run_until_parked();
  567
  568    sidebar2.update_in(cx, |sidebar, window, cx| {
  569        sidebar.restore_serialized_state(&serialized, window, cx);
  570    });
  571    cx.run_until_parked();
  572
  573    // Assert all serialized fields match.
  574    let width1 = sidebar.read_with(cx, |s, _| s.width);
  575    let width2 = sidebar2.read_with(cx, |s, _| s.width);
  576
  577    assert_eq!(width1, width2);
  578    assert_eq!(width1, px(420.0));
  579}
  580
  581#[gpui::test]
  582async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) {
  583    // A regression test to ensure that restoring a serialized archive view does not panic.
  584    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  585    let (multi_workspace, cx) =
  586        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  587    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  588    cx.update(|_window, cx| {
  589        AgentRegistryStore::init_test_global(cx, vec![]);
  590    });
  591
  592    let serialized = serde_json::to_string(&SerializedSidebar {
  593        width: Some(400.0),
  594        active_view: SerializedSidebarView::History,
  595    })
  596    .expect("serialization should succeed");
  597
  598    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
  599        if let Some(sidebar) = multi_workspace.sidebar() {
  600            sidebar.restore_serialized_state(&serialized, window, cx);
  601        }
  602    });
  603    cx.run_until_parked();
  604
  605    // After the deferred `show_archive` runs, the view should be Archive.
  606    sidebar.read_with(cx, |sidebar, _cx| {
  607        assert!(
  608            matches!(sidebar.view, SidebarView::Archive(_)),
  609            "expected sidebar view to be Archive after restore, got ThreadList"
  610        );
  611    });
  612}
  613
  614#[gpui::test]
  615async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
  616    let project = init_test_project("/my-project", cx).await;
  617    let (multi_workspace, cx) =
  618        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  619    let sidebar = setup_sidebar(&multi_workspace, cx);
  620
  621    let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
  622    let weak_sidebar = sidebar.downgrade();
  623    let weak_multi_workspace = multi_workspace.downgrade();
  624
  625    drop(sidebar);
  626    drop(multi_workspace);
  627    cx.update(|window, _cx| window.remove_window());
  628    cx.run_until_parked();
  629
  630    weak_multi_workspace.assert_released();
  631    weak_sidebar.assert_released();
  632    weak_workspace.assert_released();
  633}
  634
  635#[gpui::test]
  636async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
  637    let project = init_test_project_with_agent_panel("/my-project", cx).await;
  638    let (multi_workspace, cx) =
  639        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  640    let (_sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
  641
  642    assert_eq!(
  643        visible_entries_as_strings(&_sidebar, cx),
  644        vec!["v [my-project]"]
  645    );
  646}
  647
  648#[gpui::test]
  649async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
  650    let project = init_test_project("/my-project", cx).await;
  651    let (multi_workspace, cx) =
  652        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  653    let sidebar = setup_sidebar(&multi_workspace, cx);
  654
  655    save_thread_metadata(
  656        acp::SessionId::new(Arc::from("thread-1")),
  657        Some("Fix crash in project panel".into()),
  658        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
  659        None,
  660        None,
  661        &project,
  662        cx,
  663    );
  664
  665    save_thread_metadata(
  666        acp::SessionId::new(Arc::from("thread-2")),
  667        Some("Add inline diff view".into()),
  668        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
  669        None,
  670        None,
  671        &project,
  672        cx,
  673    );
  674    cx.run_until_parked();
  675
  676    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  677    cx.run_until_parked();
  678
  679    assert_eq!(
  680        visible_entries_as_strings(&sidebar, cx),
  681        vec![
  682            //
  683            "v [my-project]",
  684            "  Fix crash in project panel",
  685            "  Add inline diff view",
  686        ]
  687    );
  688}
  689
  690#[gpui::test]
  691async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
  692    let project = init_test_project("/project-a", cx).await;
  693    let (multi_workspace, cx) =
  694        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  695    let sidebar = setup_sidebar(&multi_workspace, cx);
  696
  697    // Single workspace with a thread
  698    save_thread_metadata(
  699        acp::SessionId::new(Arc::from("thread-a1")),
  700        Some("Thread A1".into()),
  701        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
  702        None,
  703        None,
  704        &project,
  705        cx,
  706    );
  707    cx.run_until_parked();
  708
  709    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  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    // Add a second workspace
  722    multi_workspace.update_in(cx, |mw, window, cx| {
  723        mw.create_test_workspace(window, cx).detach();
  724    });
  725    cx.run_until_parked();
  726
  727    assert_eq!(
  728        visible_entries_as_strings(&sidebar, cx),
  729        vec![
  730            //
  731            "v [project-a]",
  732            "  Thread A1",
  733        ]
  734    );
  735}
  736
  737#[gpui::test]
  738async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
  739    let project = init_test_project("/my-project", cx).await;
  740    let (multi_workspace, cx) =
  741        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  742    let sidebar = setup_sidebar(&multi_workspace, cx);
  743
  744    save_n_test_threads(1, &project, cx).await;
  745
  746    let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  747
  748    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
  749    cx.run_until_parked();
  750
  751    assert_eq!(
  752        visible_entries_as_strings(&sidebar, cx),
  753        vec![
  754            //
  755            "v [my-project]",
  756            "  Thread 1",
  757        ]
  758    );
  759
  760    // Collapse
  761    sidebar.update_in(cx, |s, window, cx| {
  762        s.toggle_collapse(&project_group_key, window, cx);
  763    });
  764    cx.run_until_parked();
  765
  766    assert_eq!(
  767        visible_entries_as_strings(&sidebar, cx),
  768        vec![
  769            //
  770            "> [my-project]",
  771        ]
  772    );
  773
  774    // Expand
  775    sidebar.update_in(cx, |s, window, cx| {
  776        s.toggle_collapse(&project_group_key, window, cx);
  777    });
  778    cx.run_until_parked();
  779
  780    assert_eq!(
  781        visible_entries_as_strings(&sidebar, cx),
  782        vec![
  783            //
  784            "v [my-project]",
  785            "  Thread 1",
  786        ]
  787    );
  788}
  789
  790#[gpui::test]
  791async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContext) {
  792    // When a worktree is added to a project, the project group key changes.
  793    // The sidebar's collapsed/expanded state is keyed by ProjectGroupKey, so
  794    // UI state must survive the key change.
  795    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
  796    let (multi_workspace, cx) =
  797        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  798    let sidebar = setup_sidebar(&multi_workspace, cx);
  799
  800    save_n_test_threads(2, &project, cx).await;
  801    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  802    cx.run_until_parked();
  803
  804    assert_eq!(
  805        visible_entries_as_strings(&sidebar, cx),
  806        vec!["v [project-a]", "  Thread 2", "  Thread 1",]
  807    );
  808
  809    // Collapse the group.
  810    let old_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
  811    sidebar.update_in(cx, |sidebar, window, cx| {
  812        sidebar.toggle_collapse(&old_key, window, cx);
  813    });
  814    cx.run_until_parked();
  815
  816    assert_eq!(
  817        visible_entries_as_strings(&sidebar, cx),
  818        vec!["> [project-a]"]
  819    );
  820
  821    // Add a second worktree — the key changes from [/project-a] to
  822    // [/project-a, /project-b].
  823    project
  824        .update(cx, |project, cx| {
  825            project.find_or_create_worktree("/project-b", true, cx)
  826        })
  827        .await
  828        .expect("should add worktree");
  829    cx.run_until_parked();
  830
  831    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
  832    cx.run_until_parked();
  833
  834    // The group should still be collapsed under the new key.
  835    assert_eq!(
  836        visible_entries_as_strings(&sidebar, cx),
  837        vec!["> [project-a, project-b]"]
  838    );
  839}
  840
  841#[gpui::test]
  842async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
  843    use workspace::ProjectGroup;
  844
  845    let project = init_test_project("/my-project", cx).await;
  846    let (multi_workspace, cx) =
  847        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
  848    let sidebar = setup_sidebar(&multi_workspace, cx);
  849
  850    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
  851    let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
  852    let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
  853
  854    // Set the collapsed group state through multi_workspace
  855    multi_workspace.update(cx, |mw, _cx| {
  856        mw.test_add_project_group(ProjectGroup {
  857            key: ProjectGroupKey::new(None, collapsed_path.clone()),
  858            workspaces: Vec::new(),
  859            expanded: false,
  860        });
  861    });
  862
  863    sidebar.update_in(cx, |s, _window, _cx| {
  864        let notified_thread_id = ThreadId::new();
  865        s.contents.notified_threads.insert(notified_thread_id);
  866        s.contents.entries = vec![
  867            // Expanded project header
  868            ListEntry::ProjectHeader {
  869                key: ProjectGroupKey::new(None, expanded_path.clone()),
  870                label: "expanded-project".into(),
  871                highlight_positions: Vec::new(),
  872                has_running_threads: false,
  873                waiting_thread_count: 0,
  874                is_active: true,
  875                has_threads: true,
  876            },
  877            ListEntry::Thread(ThreadEntry {
  878                metadata: ThreadMetadata {
  879                    thread_id: ThreadId::new(),
  880                    session_id: Some(acp::SessionId::new(Arc::from("t-1"))),
  881                    agent_id: AgentId::new("zed-agent"),
  882                    worktree_paths: WorktreePaths::default(),
  883                    title: Some("Completed thread".into()),
  884                    updated_at: Utc::now(),
  885                    created_at: Some(Utc::now()),
  886                    interacted_at: None,
  887                    archived: false,
  888                    remote_connection: None,
  889                },
  890                icon: IconName::ZedAgent,
  891                icon_from_external_svg: None,
  892                status: AgentThreadStatus::Completed,
  893                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  894                is_live: false,
  895                is_background: false,
  896                is_title_generating: false,
  897                highlight_positions: Vec::new(),
  898                worktrees: Vec::new(),
  899                diff_stats: DiffStats::default(),
  900            }),
  901            // Active thread with Running status
  902            ListEntry::Thread(ThreadEntry {
  903                metadata: ThreadMetadata {
  904                    thread_id: ThreadId::new(),
  905                    session_id: Some(acp::SessionId::new(Arc::from("t-2"))),
  906                    agent_id: AgentId::new("zed-agent"),
  907                    worktree_paths: WorktreePaths::default(),
  908                    title: Some("Running thread".into()),
  909                    updated_at: Utc::now(),
  910                    created_at: Some(Utc::now()),
  911                    interacted_at: None,
  912                    archived: false,
  913                    remote_connection: None,
  914                },
  915                icon: IconName::ZedAgent,
  916                icon_from_external_svg: None,
  917                status: AgentThreadStatus::Running,
  918                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  919                is_live: true,
  920                is_background: false,
  921                is_title_generating: false,
  922                highlight_positions: Vec::new(),
  923                worktrees: Vec::new(),
  924                diff_stats: DiffStats::default(),
  925            }),
  926            // Active thread with Error status
  927            ListEntry::Thread(ThreadEntry {
  928                metadata: ThreadMetadata {
  929                    thread_id: ThreadId::new(),
  930                    session_id: Some(acp::SessionId::new(Arc::from("t-3"))),
  931                    agent_id: AgentId::new("zed-agent"),
  932                    worktree_paths: WorktreePaths::default(),
  933                    title: Some("Error thread".into()),
  934                    updated_at: Utc::now(),
  935                    created_at: Some(Utc::now()),
  936                    interacted_at: None,
  937                    archived: false,
  938                    remote_connection: None,
  939                },
  940                icon: IconName::ZedAgent,
  941                icon_from_external_svg: None,
  942                status: AgentThreadStatus::Error,
  943                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  944                is_live: true,
  945                is_background: false,
  946                is_title_generating: false,
  947                highlight_positions: Vec::new(),
  948                worktrees: Vec::new(),
  949                diff_stats: DiffStats::default(),
  950            }),
  951            // Thread with WaitingForConfirmation status, not active
  952            // remote_connection: None,
  953            ListEntry::Thread(ThreadEntry {
  954                metadata: ThreadMetadata {
  955                    thread_id: ThreadId::new(),
  956                    session_id: Some(acp::SessionId::new(Arc::from("t-4"))),
  957                    agent_id: AgentId::new("zed-agent"),
  958                    worktree_paths: WorktreePaths::default(),
  959                    title: Some("Waiting thread".into()),
  960                    updated_at: Utc::now(),
  961                    created_at: Some(Utc::now()),
  962                    interacted_at: None,
  963                    archived: false,
  964                    remote_connection: None,
  965                },
  966                icon: IconName::ZedAgent,
  967                icon_from_external_svg: None,
  968                status: AgentThreadStatus::WaitingForConfirmation,
  969                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  970                is_live: false,
  971                is_background: false,
  972                is_title_generating: false,
  973                highlight_positions: Vec::new(),
  974                worktrees: Vec::new(),
  975                diff_stats: DiffStats::default(),
  976            }),
  977            // Background thread that completed (should show notification)
  978            // remote_connection: None,
  979            ListEntry::Thread(ThreadEntry {
  980                metadata: ThreadMetadata {
  981                    thread_id: notified_thread_id,
  982                    session_id: Some(acp::SessionId::new(Arc::from("t-5"))),
  983                    agent_id: AgentId::new("zed-agent"),
  984                    worktree_paths: WorktreePaths::default(),
  985                    title: Some("Notified thread".into()),
  986                    updated_at: Utc::now(),
  987                    created_at: Some(Utc::now()),
  988                    interacted_at: None,
  989                    archived: false,
  990                    remote_connection: None,
  991                },
  992                icon: IconName::ZedAgent,
  993                icon_from_external_svg: None,
  994                status: AgentThreadStatus::Completed,
  995                workspace: ThreadEntryWorkspace::Open(workspace.clone()),
  996                is_live: true,
  997                is_background: true,
  998                is_title_generating: false,
  999                highlight_positions: Vec::new(),
 1000                worktrees: Vec::new(),
 1001                diff_stats: DiffStats::default(),
 1002            }),
 1003            // Collapsed project header
 1004            ListEntry::ProjectHeader {
 1005                key: ProjectGroupKey::new(None, collapsed_path.clone()),
 1006                label: "collapsed-project".into(),
 1007                highlight_positions: Vec::new(),
 1008                has_running_threads: false,
 1009                waiting_thread_count: 0,
 1010                is_active: false,
 1011                has_threads: false,
 1012            },
 1013        ];
 1014
 1015        // Select the Running thread (index 2)
 1016        s.selection = Some(2);
 1017    });
 1018
 1019    assert_eq!(
 1020        visible_entries_as_strings(&sidebar, cx),
 1021        vec![
 1022            //
 1023            "v [expanded-project]",
 1024            "  Completed thread",
 1025            "  Running thread * (running)  <== selected",
 1026            "  Error thread * (error)",
 1027            "  Waiting thread (waiting)",
 1028            "  Notified thread * (!)",
 1029            "> [collapsed-project]",
 1030        ]
 1031    );
 1032
 1033    // Move selection to the collapsed header
 1034    sidebar.update_in(cx, |s, _window, _cx| {
 1035        s.selection = Some(6);
 1036    });
 1037
 1038    assert_eq!(
 1039        visible_entries_as_strings(&sidebar, cx).last().cloned(),
 1040        Some("> [collapsed-project]  <== selected".to_string()),
 1041    );
 1042
 1043    // Clear selection
 1044    sidebar.update_in(cx, |s, _window, _cx| {
 1045        s.selection = None;
 1046    });
 1047
 1048    // No entry should have the selected marker
 1049    let entries = visible_entries_as_strings(&sidebar, cx);
 1050    for entry in &entries {
 1051        assert!(
 1052            !entry.contains("<== selected"),
 1053            "unexpected selection marker in: {}",
 1054            entry
 1055        );
 1056    }
 1057}
 1058
 1059#[gpui::test]
 1060async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
 1061    let project = init_test_project("/my-project", cx).await;
 1062    let (multi_workspace, cx) =
 1063        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1064    let sidebar = setup_sidebar(&multi_workspace, cx);
 1065
 1066    save_n_test_threads(3, &project, cx).await;
 1067
 1068    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1069    cx.run_until_parked();
 1070
 1071    // Entries: [header, thread3, thread2, thread1]
 1072    // Focusing the sidebar does not set a selection; select_next/select_previous
 1073    // handle None gracefully by starting from the first or last entry.
 1074    focus_sidebar(&sidebar, cx);
 1075    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1076
 1077    // First SelectNext from None starts at index 0
 1078    cx.dispatch_action(SelectNext);
 1079    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1080
 1081    // Move down through remaining entries
 1082    cx.dispatch_action(SelectNext);
 1083    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1084
 1085    cx.dispatch_action(SelectNext);
 1086    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1087
 1088    cx.dispatch_action(SelectNext);
 1089    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1090
 1091    // At the end, wraps back to first entry
 1092    cx.dispatch_action(SelectNext);
 1093    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1094
 1095    // Navigate back to the end
 1096    cx.dispatch_action(SelectNext);
 1097    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1098    cx.dispatch_action(SelectNext);
 1099    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1100    cx.dispatch_action(SelectNext);
 1101    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1102
 1103    // Move back up
 1104    cx.dispatch_action(SelectPrevious);
 1105    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
 1106
 1107    cx.dispatch_action(SelectPrevious);
 1108    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1109
 1110    cx.dispatch_action(SelectPrevious);
 1111    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1112
 1113    // At the top, selection clears (focus returns to editor)
 1114    cx.dispatch_action(SelectPrevious);
 1115    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1116}
 1117
 1118#[gpui::test]
 1119async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
 1120    let project = init_test_project("/my-project", cx).await;
 1121    let (multi_workspace, cx) =
 1122        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1123    let sidebar = setup_sidebar(&multi_workspace, cx);
 1124
 1125    save_n_test_threads(3, &project, cx).await;
 1126    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1127    cx.run_until_parked();
 1128
 1129    focus_sidebar(&sidebar, cx);
 1130
 1131    // SelectLast jumps to the end
 1132    cx.dispatch_action(SelectLast);
 1133    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
 1134
 1135    // SelectFirst jumps to the beginning
 1136    cx.dispatch_action(SelectFirst);
 1137    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1138}
 1139
 1140#[gpui::test]
 1141async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
 1142    let project = init_test_project("/my-project", cx).await;
 1143    let (multi_workspace, cx) =
 1144        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1145    let sidebar = setup_sidebar(&multi_workspace, cx);
 1146
 1147    // Initially no selection
 1148    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1149
 1150    // Open the sidebar so it's rendered, then focus it to trigger focus_in.
 1151    // focus_in no longer sets a default selection.
 1152    focus_sidebar(&sidebar, cx);
 1153    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1154
 1155    // Manually set a selection, blur, then refocus — selection should be preserved
 1156    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1157        sidebar.selection = Some(0);
 1158    });
 1159
 1160    cx.update(|window, _cx| {
 1161        window.blur();
 1162    });
 1163    cx.run_until_parked();
 1164
 1165    sidebar.update_in(cx, |_, window, cx| {
 1166        cx.focus_self(window);
 1167    });
 1168    cx.run_until_parked();
 1169    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1170}
 1171
 1172#[gpui::test]
 1173async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
 1174    let project = init_test_project("/my-project", cx).await;
 1175    let (multi_workspace, cx) =
 1176        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1177    let sidebar = setup_sidebar(&multi_workspace, cx);
 1178
 1179    save_n_test_threads(1, &project, cx).await;
 1180    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1181    cx.run_until_parked();
 1182
 1183    assert_eq!(
 1184        visible_entries_as_strings(&sidebar, cx),
 1185        vec![
 1186            //
 1187            "v [my-project]",
 1188            "  Thread 1",
 1189        ]
 1190    );
 1191
 1192    // Focus the sidebar and select the header
 1193    focus_sidebar(&sidebar, cx);
 1194    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1195        sidebar.selection = Some(0);
 1196    });
 1197
 1198    // Confirm on project header collapses the group
 1199    cx.dispatch_action(Confirm);
 1200    cx.run_until_parked();
 1201
 1202    assert_eq!(
 1203        visible_entries_as_strings(&sidebar, cx),
 1204        vec![
 1205            //
 1206            "> [my-project]  <== selected",
 1207        ]
 1208    );
 1209
 1210    // Confirm again expands the group
 1211    cx.dispatch_action(Confirm);
 1212    cx.run_until_parked();
 1213
 1214    assert_eq!(
 1215        visible_entries_as_strings(&sidebar, cx),
 1216        vec![
 1217            //
 1218            "v [my-project]  <== selected",
 1219            "  Thread 1",
 1220        ]
 1221    );
 1222}
 1223
 1224#[gpui::test]
 1225async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
 1226    let project = init_test_project("/my-project", cx).await;
 1227    let (multi_workspace, cx) =
 1228        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1229    let sidebar = setup_sidebar(&multi_workspace, cx);
 1230
 1231    save_n_test_threads(1, &project, cx).await;
 1232    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1233    cx.run_until_parked();
 1234
 1235    assert_eq!(
 1236        visible_entries_as_strings(&sidebar, cx),
 1237        vec![
 1238            //
 1239            "v [my-project]",
 1240            "  Thread 1",
 1241        ]
 1242    );
 1243
 1244    // Focus sidebar and manually select the header (index 0). Press left to collapse.
 1245    focus_sidebar(&sidebar, cx);
 1246    sidebar.update_in(cx, |sidebar, _window, _cx| {
 1247        sidebar.selection = Some(0);
 1248    });
 1249
 1250    cx.dispatch_action(SelectParent);
 1251    cx.run_until_parked();
 1252
 1253    assert_eq!(
 1254        visible_entries_as_strings(&sidebar, cx),
 1255        vec![
 1256            //
 1257            "> [my-project]  <== selected",
 1258        ]
 1259    );
 1260
 1261    // Press right to expand
 1262    cx.dispatch_action(SelectChild);
 1263    cx.run_until_parked();
 1264
 1265    assert_eq!(
 1266        visible_entries_as_strings(&sidebar, cx),
 1267        vec![
 1268            //
 1269            "v [my-project]  <== selected",
 1270            "  Thread 1",
 1271        ]
 1272    );
 1273
 1274    // Press right again on already-expanded header moves selection down
 1275    cx.dispatch_action(SelectChild);
 1276    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1277}
 1278
 1279#[gpui::test]
 1280async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
 1281    let project = init_test_project("/my-project", cx).await;
 1282    let (multi_workspace, cx) =
 1283        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1284    let sidebar = setup_sidebar(&multi_workspace, cx);
 1285
 1286    save_n_test_threads(1, &project, cx).await;
 1287    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1288    cx.run_until_parked();
 1289
 1290    // Focus sidebar (selection starts at None), then navigate down to the thread (child)
 1291    focus_sidebar(&sidebar, cx);
 1292    cx.dispatch_action(SelectNext);
 1293    cx.dispatch_action(SelectNext);
 1294    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1295
 1296    assert_eq!(
 1297        visible_entries_as_strings(&sidebar, cx),
 1298        vec![
 1299            //
 1300            "v [my-project]",
 1301            "  Thread 1  <== selected",
 1302        ]
 1303    );
 1304
 1305    // Pressing left on a child collapses the parent group and selects it
 1306    cx.dispatch_action(SelectParent);
 1307    cx.run_until_parked();
 1308
 1309    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1310    assert_eq!(
 1311        visible_entries_as_strings(&sidebar, cx),
 1312        vec![
 1313            //
 1314            "> [my-project]  <== selected",
 1315        ]
 1316    );
 1317}
 1318
 1319#[gpui::test]
 1320async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
 1321    let project = init_test_project_with_agent_panel("/empty-project", cx).await;
 1322    let (multi_workspace, cx) =
 1323        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 1324    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1325
 1326    // An empty project has only the header (no auto-created draft).
 1327    assert_eq!(
 1328        visible_entries_as_strings(&sidebar, cx),
 1329        vec!["v [empty-project]"]
 1330    );
 1331
 1332    // Focus sidebar — focus_in does not set a selection
 1333    focus_sidebar(&sidebar, cx);
 1334    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1335
 1336    // First SelectNext from None starts at index 0 (header)
 1337    cx.dispatch_action(SelectNext);
 1338    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1339
 1340    // SelectNext with only one entry stays at index 0
 1341    cx.dispatch_action(SelectNext);
 1342    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1343
 1344    // SelectPrevious from first entry clears selection (returns to editor)
 1345    cx.dispatch_action(SelectPrevious);
 1346    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 1347
 1348    // SelectPrevious from None selects the last entry
 1349    cx.dispatch_action(SelectPrevious);
 1350    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
 1351}
 1352
 1353#[gpui::test]
 1354async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
 1355    let project = init_test_project("/my-project", cx).await;
 1356    let (multi_workspace, cx) =
 1357        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1358    let sidebar = setup_sidebar(&multi_workspace, cx);
 1359
 1360    save_n_test_threads(1, &project, cx).await;
 1361    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 1362    cx.run_until_parked();
 1363
 1364    // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
 1365    focus_sidebar(&sidebar, cx);
 1366    cx.dispatch_action(SelectNext);
 1367    cx.dispatch_action(SelectNext);
 1368    assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
 1369
 1370    // Collapse the group, which removes the thread from the list
 1371    cx.dispatch_action(SelectParent);
 1372    cx.run_until_parked();
 1373
 1374    // Selection should be clamped to the last valid index (0 = header)
 1375    let selection = sidebar.read_with(cx, |s, _| s.selection);
 1376    let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
 1377    assert!(
 1378        selection.unwrap_or(0) < entry_count,
 1379        "selection {} should be within bounds (entries: {})",
 1380        selection.unwrap_or(0),
 1381        entry_count,
 1382    );
 1383}
 1384
 1385async fn init_test_project_with_agent_panel(
 1386    worktree_path: &str,
 1387    cx: &mut TestAppContext,
 1388) -> Entity<project::Project> {
 1389    agent_ui::test_support::init_test(cx);
 1390    cx.update(|cx| {
 1391        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 1392        ThreadStore::init_global(cx);
 1393        ThreadMetadataStore::init_global(cx);
 1394        language_model::LanguageModelRegistry::test(cx);
 1395        prompt_store::init(cx);
 1396    });
 1397
 1398    let fs = FakeFs::new(cx.executor());
 1399    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
 1400        .await;
 1401    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 1402    project::Project::test(fs, [worktree_path.as_ref()], cx).await
 1403}
 1404
 1405fn add_agent_panel(
 1406    workspace: &Entity<Workspace>,
 1407    cx: &mut gpui::VisualTestContext,
 1408) -> Entity<AgentPanel> {
 1409    workspace.update_in(cx, |workspace, window, cx| {
 1410        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 1411        workspace.add_panel(panel.clone(), window, cx);
 1412        panel
 1413    })
 1414}
 1415
 1416fn setup_sidebar_with_agent_panel(
 1417    multi_workspace: &Entity<MultiWorkspace>,
 1418    cx: &mut gpui::VisualTestContext,
 1419) -> (Entity<Sidebar>, Entity<AgentPanel>) {
 1420    let sidebar = setup_sidebar(multi_workspace, cx);
 1421    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 1422    let panel = add_agent_panel(&workspace, cx);
 1423    (sidebar, panel)
 1424}
 1425
 1426#[gpui::test]
 1427async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAppContext) {
 1428    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1429    enable_agent_panel_terminal(cx);
 1430    let (multi_workspace, cx) =
 1431        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1432    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1433
 1434    let terminal_id = panel
 1435        .update_in(cx, |panel, window, cx| {
 1436            panel.insert_test_terminal("Dev Server", true, window, cx)
 1437        })
 1438        .expect("test terminal should be inserted");
 1439    cx.run_until_parked();
 1440
 1441    assert_eq!(
 1442        visible_entries_as_strings(&sidebar, cx),
 1443        vec!["v [my-project]", "  Dev Server"]
 1444    );
 1445    sidebar.read_with(cx, |sidebar, _cx| {
 1446        assert!(
 1447            matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) if *active_terminal_id == terminal_id),
 1448            "expected active terminal entry, got {:?}",
 1449            sidebar.active_entry,
 1450        );
 1451        assert!(
 1452            sidebar.contents.entries.iter().any(|entry| {
 1453                matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id && terminal.title.as_ref() == "Dev Server")
 1454            }),
 1455            "expected the inserted terminal to appear in sidebar contents",
 1456        );
 1457    });
 1458
 1459    type_in_search(&sidebar, "server", cx);
 1460    assert_eq!(
 1461        visible_entries_as_strings(&sidebar, cx),
 1462        vec!["v [my-project]", "  Dev Server  <== selected"]
 1463    );
 1464
 1465    type_in_search(&sidebar, "missing", cx);
 1466    assert_eq!(
 1467        visible_entries_as_strings(&sidebar, cx),
 1468        Vec::<String>::new()
 1469    );
 1470}
 1471
 1472#[gpui::test]
 1473async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestAppContext) {
 1474    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1475    enable_agent_panel_terminal(cx);
 1476    let (multi_workspace, cx) =
 1477        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1478    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1479
 1480    let build_terminal_id = panel
 1481        .update_in(cx, |panel, window, cx| {
 1482            panel.insert_test_terminal("Build", true, window, cx)
 1483        })
 1484        .expect("build test terminal should be inserted");
 1485    let server_terminal_id = panel
 1486        .update_in(cx, |panel, window, cx| {
 1487            panel.insert_test_terminal("Server", true, window, cx)
 1488        })
 1489        .expect("server test terminal should be inserted");
 1490    cx.run_until_parked();
 1491
 1492    panel.read_with(cx, |panel, _cx| {
 1493        assert_eq!(panel.active_terminal_id(), Some(server_terminal_id));
 1494    });
 1495
 1496    panel.update(cx, |panel, cx| {
 1497        panel.emit_test_terminal_bell(build_terminal_id, cx);
 1498    });
 1499    cx.run_until_parked();
 1500
 1501    sidebar.read_with(cx, |sidebar, cx| {
 1502        assert!(sidebar.has_notifications(cx));
 1503        assert!(sidebar.contents.notified_terminals.contains(&build_terminal_id));
 1504        assert!(sidebar.contents.entries.iter().any(|entry| {
 1505            matches!(entry, ListEntry::Terminal(terminal) if terminal.id == build_terminal_id && terminal.has_notification)
 1506        }));
 1507    });
 1508
 1509    panel.update_in(cx, |panel, window, cx| {
 1510        panel.activate_terminal(build_terminal_id, true, window, cx);
 1511    });
 1512    cx.run_until_parked();
 1513
 1514    sidebar.read_with(cx, |sidebar, cx| {
 1515        assert!(!sidebar.has_notifications(cx));
 1516        assert!(
 1517            !sidebar
 1518                .contents
 1519                .notified_terminals
 1520                .contains(&build_terminal_id)
 1521        );
 1522    });
 1523}
 1524
 1525#[gpui::test]
 1526async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut TestAppContext) {
 1527    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1528    enable_agent_panel_terminal(cx);
 1529    let (multi_workspace, cx) =
 1530        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1531    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1532    let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| {
 1533        multi_workspace.workspace().clone()
 1534    });
 1535
 1536    let build_terminal_id = panel
 1537        .update_in(cx, |panel, window, cx| {
 1538            panel.insert_test_terminal("Build", true, window, cx)
 1539        })
 1540        .expect("build test terminal should be inserted");
 1541    let server_terminal_id = panel
 1542        .update_in(cx, |panel, window, cx| {
 1543            panel.insert_test_terminal("Server", true, window, cx)
 1544        })
 1545        .expect("server test terminal should be inserted");
 1546    cx.run_until_parked();
 1547
 1548    sidebar.update_in(cx, |sidebar, window, cx| {
 1549        sidebar.close_terminal(&workspace, server_terminal_id, window, cx);
 1550    });
 1551    cx.run_until_parked();
 1552
 1553    panel.read_with(cx, |panel, _cx| {
 1554        assert!(!panel.has_terminal(server_terminal_id));
 1555        assert_eq!(panel.active_terminal_id(), Some(build_terminal_id));
 1556    });
 1557    sidebar.read_with(cx, |sidebar, _cx| {
 1558        assert!(
 1559            matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id, .. }) if *terminal_id == build_terminal_id),
 1560            "expected remaining terminal to become active, got {:?}",
 1561            sidebar.active_entry,
 1562        );
 1563    });
 1564    assert_eq!(
 1565        visible_entries_as_strings(&sidebar, cx),
 1566        vec!["v [my-project]", "  Build"]
 1567    );
 1568}
 1569
 1570#[gpui::test]
 1571async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
 1572    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1573    let (multi_workspace, cx) =
 1574        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1575    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1576
 1577    // Open thread A and keep it generating.
 1578    let connection = StubAgentConnection::new();
 1579    open_thread_with_connection(&panel, connection.clone(), cx);
 1580    send_message(&panel, cx);
 1581
 1582    let session_id_a = active_session_id(&panel, cx);
 1583    save_test_thread_metadata(&session_id_a, &project, cx).await;
 1584
 1585    cx.update(|_, cx| {
 1586        connection.send_update(
 1587            session_id_a.clone(),
 1588            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 1589            cx,
 1590        );
 1591    });
 1592    cx.run_until_parked();
 1593
 1594    // Open thread B (idle, default response) — thread A goes to background.
 1595    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1596        acp::ContentChunk::new("Done".into()),
 1597    )]);
 1598    open_thread_with_connection(&panel, connection, cx);
 1599    send_message(&panel, cx);
 1600
 1601    let session_id_b = active_session_id(&panel, cx);
 1602    save_test_thread_metadata(&session_id_b, &project, cx).await;
 1603
 1604    cx.run_until_parked();
 1605
 1606    let mut entries = visible_entries_as_strings(&sidebar, cx);
 1607    entries[1..].sort();
 1608    assert_eq!(
 1609        entries,
 1610        vec![
 1611            //
 1612            "v [my-project]",
 1613            "  Hello *",
 1614            "  Hello * (running)",
 1615        ]
 1616    );
 1617}
 1618
 1619#[gpui::test]
 1620async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
 1621    cx: &mut TestAppContext,
 1622) {
 1623    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 1624    let (multi_workspace, cx) =
 1625        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1626    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1627
 1628    let connection = StubAgentConnection::new().with_supports_load_session(true);
 1629    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 1630        acp::ContentChunk::new("Done".into()),
 1631    )]);
 1632    open_thread_with_connection(&panel, connection, cx);
 1633    send_message(&panel, cx);
 1634
 1635    let parent_session_id = active_session_id(&panel, cx);
 1636    save_test_thread_metadata(&parent_session_id, &project, cx).await;
 1637
 1638    let subagent_session_id = acp::SessionId::new("subagent-session");
 1639    cx.update(|_, cx| {
 1640        let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
 1641        parent_thread.update(cx, |thread: &mut AcpThread, cx| {
 1642            thread.subagent_spawned(subagent_session_id.clone(), cx);
 1643        });
 1644    });
 1645    cx.run_until_parked();
 1646
 1647    let subagent_thread = panel.read_with(cx, |panel, cx| {
 1648        panel
 1649            .active_conversation_view()
 1650            .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id))
 1651            .map(|thread_view| thread_view.read(cx).thread.clone())
 1652            .expect("Expected subagent thread to be loaded into the conversation")
 1653    });
 1654    request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
 1655
 1656    let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
 1657        sidebar
 1658            .contents
 1659            .entries
 1660            .iter()
 1661            .find_map(|entry| match entry {
 1662                ListEntry::Thread(thread)
 1663                    if thread.metadata.session_id.as_ref() == Some(&parent_session_id) =>
 1664                {
 1665                    Some(thread.status)
 1666                }
 1667                _ => None,
 1668            })
 1669            .expect("Expected parent thread entry in sidebar")
 1670    });
 1671
 1672    assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
 1673}
 1674
 1675#[gpui::test]
 1676async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
 1677    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 1678    let (multi_workspace, cx) =
 1679        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1680    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 1681
 1682    // Open thread on workspace A and keep it generating.
 1683    let connection_a = StubAgentConnection::new();
 1684    open_thread_with_connection(&panel_a, connection_a.clone(), cx);
 1685    send_message(&panel_a, cx);
 1686
 1687    let session_id_a = active_session_id(&panel_a, cx);
 1688    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 1689
 1690    cx.update(|_, cx| {
 1691        connection_a.send_update(
 1692            session_id_a.clone(),
 1693            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
 1694            cx,
 1695        );
 1696    });
 1697    cx.run_until_parked();
 1698
 1699    // Add a second workspace and activate it (making workspace A the background).
 1700    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 1701    let project_b = project::Project::test(fs, [], cx).await;
 1702    multi_workspace.update_in(cx, |mw, window, cx| {
 1703        mw.test_add_workspace(project_b, window, cx);
 1704    });
 1705    cx.run_until_parked();
 1706
 1707    // Thread A is still running; no notification yet.
 1708    assert_eq!(
 1709        visible_entries_as_strings(&sidebar, cx),
 1710        vec![
 1711            //
 1712            "v [project-a]",
 1713            "  Hello * (running)",
 1714        ]
 1715    );
 1716
 1717    // Complete thread A's turn (transition Running → Completed).
 1718    connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
 1719    cx.run_until_parked();
 1720
 1721    // The completed background thread shows a notification indicator.
 1722    assert_eq!(
 1723        visible_entries_as_strings(&sidebar, cx),
 1724        vec![
 1725            //
 1726            "v [project-a]",
 1727            "  Hello * (!)",
 1728        ]
 1729    );
 1730}
 1731
 1732fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
 1733    sidebar.update_in(cx, |sidebar, window, cx| {
 1734        window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
 1735        sidebar.filter_editor.update(cx, |editor, cx| {
 1736            editor.set_text(query, window, cx);
 1737        });
 1738    });
 1739    cx.run_until_parked();
 1740}
 1741
 1742#[gpui::test]
 1743async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
 1744    let project = init_test_project("/my-project", cx).await;
 1745    let (multi_workspace, cx) =
 1746        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1747    let sidebar = setup_sidebar(&multi_workspace, cx);
 1748
 1749    for (id, title, hour) in [
 1750        ("t-1", "Fix crash in project panel", 3),
 1751        ("t-2", "Add inline diff view", 2),
 1752        ("t-3", "Refactor settings module", 1),
 1753    ] {
 1754        save_thread_metadata(
 1755            acp::SessionId::new(Arc::from(id)),
 1756            Some(title.into()),
 1757            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1758            None,
 1759            None,
 1760            &project,
 1761            cx,
 1762        );
 1763    }
 1764    cx.run_until_parked();
 1765
 1766    assert_eq!(
 1767        visible_entries_as_strings(&sidebar, cx),
 1768        vec![
 1769            //
 1770            "v [my-project]",
 1771            "  Fix crash in project panel",
 1772            "  Add inline diff view",
 1773            "  Refactor settings module",
 1774        ]
 1775    );
 1776
 1777    // User types "diff" in the search box — only the matching thread remains,
 1778    // with its workspace header preserved for context.
 1779    type_in_search(&sidebar, "diff", cx);
 1780    assert_eq!(
 1781        visible_entries_as_strings(&sidebar, cx),
 1782        vec![
 1783            //
 1784            "v [my-project]",
 1785            "  Add inline diff view  <== selected",
 1786        ]
 1787    );
 1788
 1789    // User changes query to something with no matches — list is empty.
 1790    type_in_search(&sidebar, "nonexistent", cx);
 1791    assert_eq!(
 1792        visible_entries_as_strings(&sidebar, cx),
 1793        Vec::<String>::new()
 1794    );
 1795}
 1796
 1797#[gpui::test]
 1798async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
 1799    // Scenario: A user remembers a thread title but not the exact casing.
 1800    // Search should match case-insensitively so they can still find it.
 1801    let project = init_test_project("/my-project", cx).await;
 1802    let (multi_workspace, cx) =
 1803        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1804    let sidebar = setup_sidebar(&multi_workspace, cx);
 1805
 1806    save_thread_metadata(
 1807        acp::SessionId::new(Arc::from("thread-1")),
 1808        Some("Fix Crash In Project Panel".into()),
 1809        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 1810        None,
 1811        None,
 1812        &project,
 1813        cx,
 1814    );
 1815    cx.run_until_parked();
 1816
 1817    // Lowercase query matches mixed-case title.
 1818    type_in_search(&sidebar, "fix crash", cx);
 1819    assert_eq!(
 1820        visible_entries_as_strings(&sidebar, cx),
 1821        vec![
 1822            //
 1823            "v [my-project]",
 1824            "  Fix Crash In Project Panel  <== selected",
 1825        ]
 1826    );
 1827
 1828    // Uppercase query also matches the same title.
 1829    type_in_search(&sidebar, "FIX CRASH", cx);
 1830    assert_eq!(
 1831        visible_entries_as_strings(&sidebar, cx),
 1832        vec![
 1833            //
 1834            "v [my-project]",
 1835            "  Fix Crash In Project Panel  <== selected",
 1836        ]
 1837    );
 1838}
 1839
 1840#[gpui::test]
 1841async fn test_escape_from_search_focuses_first_thread(cx: &mut TestAppContext) {
 1842    // Scenario: A user searches, finds what they need, then presses Escape
 1843    // in the search field to hand keyboard control back to the thread list.
 1844    let project = init_test_project("/my-project", cx).await;
 1845    let (multi_workspace, cx) =
 1846        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 1847    let sidebar = setup_sidebar(&multi_workspace, cx);
 1848
 1849    for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
 1850        save_thread_metadata(
 1851            acp::SessionId::new(Arc::from(id)),
 1852            Some(title.into()),
 1853            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1854            None,
 1855            None,
 1856            &project,
 1857            cx,
 1858        )
 1859    }
 1860    cx.run_until_parked();
 1861
 1862    // Confirm the full list is showing.
 1863    assert_eq!(
 1864        visible_entries_as_strings(&sidebar, cx),
 1865        vec![
 1866            //
 1867            "v [my-project]",
 1868            "  Alpha thread",
 1869            "  Beta thread",
 1870        ]
 1871    );
 1872
 1873    // User types a search query to filter down.
 1874    focus_sidebar(&sidebar, cx);
 1875    type_in_search(&sidebar, "alpha", cx);
 1876    assert_eq!(
 1877        visible_entries_as_strings(&sidebar, cx),
 1878        vec![
 1879            //
 1880            "v [my-project]",
 1881            "  Alpha thread  <== selected",
 1882        ]
 1883    );
 1884
 1885    // First Escape clears the search text, restoring the full list.
 1886    // Focus stays on the filter editor.
 1887    cx.dispatch_action(Cancel);
 1888    cx.run_until_parked();
 1889    assert_eq!(
 1890        visible_entries_as_strings(&sidebar, cx),
 1891        vec![
 1892            //
 1893            "v [my-project]",
 1894            "  Alpha thread",
 1895            "  Beta thread",
 1896        ]
 1897    );
 1898    sidebar.update_in(cx, |sidebar, window, cx| {
 1899        assert!(sidebar.filter_editor.read(cx).is_focused(window));
 1900        assert!(!sidebar.focus_handle.is_focused(window));
 1901    });
 1902
 1903    // Second Escape moves focus from the empty search field to the thread list.
 1904    cx.dispatch_action(Cancel);
 1905    cx.run_until_parked();
 1906    sidebar.update_in(cx, |sidebar, window, cx| {
 1907        assert_eq!(sidebar.selection, Some(1));
 1908        assert!(sidebar.focus_handle.is_focused(window));
 1909        assert!(!sidebar.filter_editor.read(cx).is_focused(window));
 1910    });
 1911}
 1912
 1913#[gpui::test]
 1914async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
 1915    let project_a = init_test_project("/project-a", cx).await;
 1916    let (multi_workspace, cx) =
 1917        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 1918    let sidebar = setup_sidebar(&multi_workspace, cx);
 1919
 1920    for (id, title, hour) in [
 1921        ("a1", "Fix bug in sidebar", 2),
 1922        ("a2", "Add tests for editor", 1),
 1923    ] {
 1924        save_thread_metadata(
 1925            acp::SessionId::new(Arc::from(id)),
 1926            Some(title.into()),
 1927            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1928            None,
 1929            None,
 1930            &project_a,
 1931            cx,
 1932        )
 1933    }
 1934
 1935    // Add a second workspace.
 1936    multi_workspace.update_in(cx, |mw, window, cx| {
 1937        mw.create_test_workspace(window, cx).detach();
 1938    });
 1939    cx.run_until_parked();
 1940
 1941    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 1942        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 1943    });
 1944
 1945    for (id, title, hour) in [
 1946        ("b1", "Refactor sidebar layout", 3),
 1947        ("b2", "Fix typo in README", 1),
 1948    ] {
 1949        save_thread_metadata(
 1950            acp::SessionId::new(Arc::from(id)),
 1951            Some(title.into()),
 1952            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 1953            None,
 1954            None,
 1955            &project_b,
 1956            cx,
 1957        )
 1958    }
 1959    cx.run_until_parked();
 1960
 1961    assert_eq!(
 1962        visible_entries_as_strings(&sidebar, cx),
 1963        vec![
 1964            //
 1965            "v [project-a]",
 1966            "  Fix bug in sidebar",
 1967            "  Add tests for editor",
 1968        ]
 1969    );
 1970
 1971    // "sidebar" matches a thread in each workspace — both headers stay visible.
 1972    type_in_search(&sidebar, "sidebar", cx);
 1973    assert_eq!(
 1974        visible_entries_as_strings(&sidebar, cx),
 1975        vec![
 1976            //
 1977            "v [project-a]",
 1978            "  Fix bug in sidebar  <== selected",
 1979        ]
 1980    );
 1981
 1982    // "typo" only matches in the second workspace — the first header disappears.
 1983    type_in_search(&sidebar, "typo", cx);
 1984    assert_eq!(
 1985        visible_entries_as_strings(&sidebar, cx),
 1986        Vec::<String>::new()
 1987    );
 1988
 1989    // "project-a" matches the first workspace name — the header appears
 1990    // with all child threads included.
 1991    type_in_search(&sidebar, "project-a", cx);
 1992    assert_eq!(
 1993        visible_entries_as_strings(&sidebar, cx),
 1994        vec![
 1995            //
 1996            "v [project-a]",
 1997            "  Fix bug in sidebar  <== selected",
 1998            "  Add tests for editor",
 1999        ]
 2000    );
 2001}
 2002
 2003#[gpui::test]
 2004async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
 2005    let project_a = init_test_project("/alpha-project", cx).await;
 2006    let (multi_workspace, cx) =
 2007        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2008    let sidebar = setup_sidebar(&multi_workspace, cx);
 2009
 2010    for (id, title, hour) in [
 2011        ("a1", "Fix bug in sidebar", 2),
 2012        ("a2", "Add tests for editor", 1),
 2013    ] {
 2014        save_thread_metadata(
 2015            acp::SessionId::new(Arc::from(id)),
 2016            Some(title.into()),
 2017            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2018            None,
 2019            None,
 2020            &project_a,
 2021            cx,
 2022        )
 2023    }
 2024
 2025    // Add a second workspace.
 2026    multi_workspace.update_in(cx, |mw, window, cx| {
 2027        mw.create_test_workspace(window, cx).detach();
 2028    });
 2029    cx.run_until_parked();
 2030
 2031    let project_b = multi_workspace.read_with(cx, |mw, cx| {
 2032        mw.workspaces().nth(1).unwrap().read(cx).project().clone()
 2033    });
 2034
 2035    for (id, title, hour) in [
 2036        ("b1", "Refactor sidebar layout", 3),
 2037        ("b2", "Fix typo in README", 1),
 2038    ] {
 2039        save_thread_metadata(
 2040            acp::SessionId::new(Arc::from(id)),
 2041            Some(title.into()),
 2042            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2043            None,
 2044            None,
 2045            &project_b,
 2046            cx,
 2047        )
 2048    }
 2049    cx.run_until_parked();
 2050
 2051    // "alpha" matches the workspace name "alpha-project" but no thread titles.
 2052    // The workspace header should appear with all child threads included.
 2053    type_in_search(&sidebar, "alpha", cx);
 2054    assert_eq!(
 2055        visible_entries_as_strings(&sidebar, cx),
 2056        vec![
 2057            //
 2058            "v [alpha-project]",
 2059            "  Fix bug in sidebar  <== selected",
 2060            "  Add tests for editor",
 2061        ]
 2062    );
 2063
 2064    // "sidebar" matches thread titles in both workspaces but not workspace names.
 2065    // Both headers appear with their matching threads.
 2066    type_in_search(&sidebar, "sidebar", cx);
 2067    assert_eq!(
 2068        visible_entries_as_strings(&sidebar, cx),
 2069        vec![
 2070            //
 2071            "v [alpha-project]",
 2072            "  Fix bug in sidebar  <== selected",
 2073        ]
 2074    );
 2075
 2076    // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
 2077    // doesn't match) — but does not match either workspace name or any thread.
 2078    // Actually let's test something simpler: a query that matches both a workspace
 2079    // name AND some threads in that workspace. Matching threads should still appear.
 2080    type_in_search(&sidebar, "fix", cx);
 2081    assert_eq!(
 2082        visible_entries_as_strings(&sidebar, cx),
 2083        vec![
 2084            //
 2085            "v [alpha-project]",
 2086            "  Fix bug in sidebar  <== selected",
 2087        ]
 2088    );
 2089
 2090    // A query that matches a workspace name AND a thread in that same workspace.
 2091    // Both the header (highlighted) and all child threads should appear.
 2092    type_in_search(&sidebar, "alpha", cx);
 2093    assert_eq!(
 2094        visible_entries_as_strings(&sidebar, cx),
 2095        vec![
 2096            //
 2097            "v [alpha-project]",
 2098            "  Fix bug in sidebar  <== selected",
 2099            "  Add tests for editor",
 2100        ]
 2101    );
 2102
 2103    // Now search for something that matches only a workspace name when there
 2104    // are also threads with matching titles — the non-matching workspace's
 2105    // threads should still appear if their titles match.
 2106    type_in_search(&sidebar, "alp", cx);
 2107    assert_eq!(
 2108        visible_entries_as_strings(&sidebar, cx),
 2109        vec![
 2110            //
 2111            "v [alpha-project]",
 2112            "  Fix bug in sidebar  <== selected",
 2113            "  Add tests for editor",
 2114        ]
 2115    );
 2116}
 2117
 2118#[gpui::test]
 2119async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
 2120    let project = init_test_project("/my-project", cx).await;
 2121    let (multi_workspace, cx) =
 2122        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2123    let sidebar = setup_sidebar(&multi_workspace, cx);
 2124
 2125    save_thread_metadata(
 2126        acp::SessionId::new(Arc::from("thread-1")),
 2127        Some("Important thread".into()),
 2128        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 2129        None,
 2130        None,
 2131        &project,
 2132        cx,
 2133    );
 2134    cx.run_until_parked();
 2135
 2136    // User focuses the sidebar and collapses the group using keyboard:
 2137    // manually select the header, then press SelectParent to collapse.
 2138    focus_sidebar(&sidebar, cx);
 2139    sidebar.update_in(cx, |sidebar, _window, _cx| {
 2140        sidebar.selection = Some(0);
 2141    });
 2142    cx.dispatch_action(SelectParent);
 2143    cx.run_until_parked();
 2144
 2145    assert_eq!(
 2146        visible_entries_as_strings(&sidebar, cx),
 2147        vec![
 2148            //
 2149            "> [my-project]  <== selected",
 2150        ]
 2151    );
 2152
 2153    // User types a search — the thread appears even though its group is collapsed.
 2154    type_in_search(&sidebar, "important", cx);
 2155    assert_eq!(
 2156        visible_entries_as_strings(&sidebar, cx),
 2157        vec![
 2158            //
 2159            "> [my-project]",
 2160            "  Important thread  <== selected",
 2161        ]
 2162    );
 2163}
 2164
 2165#[gpui::test]
 2166async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
 2167    let project = init_test_project("/my-project", cx).await;
 2168    let (multi_workspace, cx) =
 2169        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2170    let sidebar = setup_sidebar(&multi_workspace, cx);
 2171
 2172    for (id, title, hour) in [
 2173        ("t-1", "Fix crash in panel", 3),
 2174        ("t-2", "Fix lint warnings", 2),
 2175        ("t-3", "Add new feature", 1),
 2176    ] {
 2177        save_thread_metadata(
 2178            acp::SessionId::new(Arc::from(id)),
 2179            Some(title.into()),
 2180            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
 2181            None,
 2182            None,
 2183            &project,
 2184            cx,
 2185        )
 2186    }
 2187    cx.run_until_parked();
 2188
 2189    focus_sidebar(&sidebar, cx);
 2190
 2191    // User types "fix" — two threads match.
 2192    type_in_search(&sidebar, "fix", cx);
 2193    assert_eq!(
 2194        visible_entries_as_strings(&sidebar, cx),
 2195        vec![
 2196            //
 2197            "v [my-project]",
 2198            "  Fix crash in panel  <== selected",
 2199            "  Fix lint warnings",
 2200        ]
 2201    );
 2202
 2203    // Selection starts on the first matching thread. User presses
 2204    // SelectNext to move to the second match.
 2205    cx.dispatch_action(SelectNext);
 2206    assert_eq!(
 2207        visible_entries_as_strings(&sidebar, cx),
 2208        vec![
 2209            //
 2210            "v [my-project]",
 2211            "  Fix crash in panel",
 2212            "  Fix lint warnings  <== selected",
 2213        ]
 2214    );
 2215
 2216    // User can also jump back with SelectPrevious.
 2217    cx.dispatch_action(SelectPrevious);
 2218    assert_eq!(
 2219        visible_entries_as_strings(&sidebar, cx),
 2220        vec![
 2221            //
 2222            "v [my-project]",
 2223            "  Fix crash in panel  <== selected",
 2224            "  Fix lint warnings",
 2225        ]
 2226    );
 2227}
 2228
 2229#[gpui::test]
 2230async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
 2231    let project = init_test_project("/my-project", cx).await;
 2232    let (multi_workspace, cx) =
 2233        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2234    let sidebar = setup_sidebar(&multi_workspace, cx);
 2235
 2236    multi_workspace.update_in(cx, |mw, window, cx| {
 2237        mw.create_test_workspace(window, cx).detach();
 2238    });
 2239    cx.run_until_parked();
 2240
 2241    let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| {
 2242        (
 2243            mw.workspaces().next().unwrap().clone(),
 2244            mw.workspaces().nth(1).unwrap().clone(),
 2245        )
 2246    });
 2247
 2248    save_thread_metadata(
 2249        acp::SessionId::new(Arc::from("hist-1")),
 2250        Some("Historical Thread".into()),
 2251        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2252        None,
 2253        None,
 2254        &project,
 2255        cx,
 2256    );
 2257    cx.run_until_parked();
 2258    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2259    cx.run_until_parked();
 2260
 2261    assert_eq!(
 2262        visible_entries_as_strings(&sidebar, cx),
 2263        vec![
 2264            //
 2265            "v [my-project]",
 2266            "  Historical Thread",
 2267        ]
 2268    );
 2269
 2270    // Switch to workspace 1 so we can verify the confirm switches back.
 2271    multi_workspace.update_in(cx, |mw, window, cx| {
 2272        let workspace = mw.workspaces().nth(1).unwrap().clone();
 2273        mw.activate(workspace, None, window, cx);
 2274    });
 2275    cx.run_until_parked();
 2276    assert_eq!(
 2277        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2278        workspace_1
 2279    );
 2280
 2281    // Confirm on the historical (non-live) thread at index 1.
 2282    // Before a previous fix, the workspace field was Option<usize> and
 2283    // historical threads had None, so activate_thread early-returned
 2284    // without switching the workspace.
 2285    sidebar.update_in(cx, |sidebar, window, cx| {
 2286        sidebar.selection = Some(1);
 2287        sidebar.confirm(&Confirm, window, cx);
 2288    });
 2289    cx.run_until_parked();
 2290
 2291    assert_eq!(
 2292        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2293        workspace_0
 2294    );
 2295}
 2296
 2297#[gpui::test]
 2298async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_order(
 2299    cx: &mut TestAppContext,
 2300) {
 2301    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2302    let (multi_workspace, cx) =
 2303        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2304    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2305
 2306    let newer_session_id = acp::SessionId::new(Arc::from("newer-historical-thread"));
 2307    let newer_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 2, 0, 0, 0).unwrap();
 2308    save_thread_metadata(
 2309        newer_session_id,
 2310        Some("Newer Historical Thread".into()),
 2311        newer_timestamp,
 2312        Some(newer_timestamp),
 2313        None,
 2314        &project,
 2315        cx,
 2316    );
 2317
 2318    let older_session_id = acp::SessionId::new(Arc::from("older-historical-thread"));
 2319    let older_timestamp = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap();
 2320    save_thread_metadata(
 2321        older_session_id.clone(),
 2322        Some("Older Historical Thread".into()),
 2323        older_timestamp,
 2324        Some(older_timestamp),
 2325        None,
 2326        &project,
 2327        cx,
 2328    );
 2329
 2330    cx.run_until_parked();
 2331    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2332    cx.run_until_parked();
 2333
 2334    let historical_entries_before: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2335        .into_iter()
 2336        .filter(|entry| entry.contains("Historical Thread"))
 2337        .collect();
 2338    assert_eq!(
 2339        historical_entries_before,
 2340        vec![
 2341            "  Newer Historical Thread".to_string(),
 2342            "  Older Historical Thread".to_string(),
 2343        ],
 2344        "expected the sidebar to sort historical threads by their saved timestamp before activation"
 2345    );
 2346
 2347    let older_entry_index = sidebar.read_with(cx, |sidebar, _cx| {
 2348        sidebar
 2349            .contents
 2350            .entries
 2351            .iter()
 2352            .position(|entry| {
 2353                matches!(entry, ListEntry::Thread(thread)
 2354                    if thread.metadata.session_id.as_ref() == Some(&older_session_id))
 2355            })
 2356            .expect("expected Older Historical Thread to appear in the sidebar")
 2357    });
 2358
 2359    sidebar.update_in(cx, |sidebar, window, cx| {
 2360        sidebar.selection = Some(older_entry_index);
 2361        sidebar.confirm(&Confirm, window, cx);
 2362    });
 2363    cx.run_until_parked();
 2364
 2365    let older_metadata = cx.update(|_, cx| {
 2366        ThreadMetadataStore::global(cx)
 2367            .read(cx)
 2368            .entry_by_session(&older_session_id)
 2369            .cloned()
 2370            .expect("expected metadata for Older Historical Thread after activation")
 2371    });
 2372    assert_eq!(
 2373        older_metadata.created_at,
 2374        Some(older_timestamp),
 2375        "activating a historical thread should not rewrite its saved created_at timestamp"
 2376    );
 2377
 2378    let historical_entries_after: Vec<_> = visible_entries_as_strings(&sidebar, cx)
 2379        .into_iter()
 2380        .filter(|entry| entry.contains("Historical Thread"))
 2381        .collect();
 2382    assert_eq!(
 2383        historical_entries_after,
 2384        vec![
 2385            "  Newer Historical Thread".to_string(),
 2386            "  Older Historical Thread  <== selected".to_string(),
 2387        ],
 2388        "activating an older historical thread should not reorder it ahead of a newer historical thread"
 2389    );
 2390}
 2391
 2392#[gpui::test]
 2393async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_thread(
 2394    cx: &mut TestAppContext,
 2395) {
 2396    use workspace::ProjectGroup;
 2397
 2398    agent_ui::test_support::init_test(cx);
 2399    cx.update(|cx| {
 2400        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 2401        ThreadStore::init_global(cx);
 2402        ThreadMetadataStore::init_global(cx);
 2403        language_model::LanguageModelRegistry::test(cx);
 2404        prompt_store::init(cx);
 2405    });
 2406
 2407    let fs = FakeFs::new(cx.executor());
 2408    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 2409        .await;
 2410    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2411        .await;
 2412    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 2413
 2414    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 2415    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 2416
 2417    let (multi_workspace, cx) =
 2418        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2419    let sidebar = setup_sidebar(&multi_workspace, cx);
 2420
 2421    let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
 2422    multi_workspace.update(cx, |mw, _cx| {
 2423        mw.test_add_project_group(ProjectGroup {
 2424            key: project_b_key.clone(),
 2425            workspaces: Vec::new(),
 2426            expanded: true,
 2427        });
 2428    });
 2429
 2430    let session_id = acp::SessionId::new(Arc::from("historical-new-project-group"));
 2431    save_thread_metadata(
 2432        session_id.clone(),
 2433        Some("Historical Thread in New Group".into()),
 2434        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 2435        None,
 2436        None,
 2437        &project_b,
 2438        cx,
 2439    );
 2440    cx.run_until_parked();
 2441
 2442    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2443    cx.run_until_parked();
 2444
 2445    let entries_before = visible_entries_as_strings(&sidebar, cx);
 2446    assert_eq!(
 2447        entries_before,
 2448        vec![
 2449            "v [project-a]",
 2450            "v [project-b]",
 2451            "  Historical Thread in New Group",
 2452        ],
 2453        "expected the closed project group to show the historical thread before first open"
 2454    );
 2455
 2456    assert_eq!(
 2457        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2458        1,
 2459        "should start without an open workspace for the new project group"
 2460    );
 2461
 2462    sidebar.update_in(cx, |sidebar, window, cx| {
 2463        sidebar.selection = Some(2);
 2464        sidebar.confirm(&Confirm, window, cx);
 2465    });
 2466
 2467    cx.run_until_parked();
 2468
 2469    assert_eq!(
 2470        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 2471        2,
 2472        "confirming the historical thread should open a workspace for the new project group"
 2473    );
 2474
 2475    let workspace_b = multi_workspace.read_with(cx, |mw, cx| {
 2476        mw.workspaces()
 2477            .find(|workspace| {
 2478                PathList::new(&workspace.read(cx).root_paths(cx))
 2479                    == project_b_key.path_list().clone()
 2480            })
 2481            .cloned()
 2482            .expect("expected workspace for project-b after opening the historical thread")
 2483    });
 2484
 2485    assert_eq!(
 2486        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 2487        workspace_b,
 2488        "opening the historical thread should activate the new project's workspace"
 2489    );
 2490
 2491    let panel = workspace_b.read_with(cx, |workspace, cx| {
 2492        workspace
 2493            .panel::<AgentPanel>(cx)
 2494            .expect("expected first-open activation to bootstrap the agent panel")
 2495    });
 2496
 2497    let expected_thread_id = cx.update(|_, cx| {
 2498        ThreadMetadataStore::global(cx)
 2499            .read(cx)
 2500            .entries()
 2501            .find(|e| e.session_id.as_ref() == Some(&session_id))
 2502            .map(|e| e.thread_id)
 2503            .expect("metadata should still map session id to thread id")
 2504    });
 2505
 2506    assert_eq!(
 2507        panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 2508        Some(expected_thread_id),
 2509        "expected the agent panel to activate the real historical thread rather than a draft"
 2510    );
 2511
 2512    let entries_after = visible_entries_as_strings(&sidebar, cx);
 2513    let matching_rows: Vec<_> = entries_after
 2514        .iter()
 2515        .filter(|entry| entry.contains("Historical Thread in New Group") || entry.contains("Draft"))
 2516        .cloned()
 2517        .collect();
 2518    assert_eq!(
 2519        matching_rows.len(),
 2520        1,
 2521        "expected only one matching row after first open into a new project group, got entries: {entries_after:?}"
 2522    );
 2523    assert!(
 2524        matching_rows[0].contains("Historical Thread in New Group"),
 2525        "expected the surviving row to be the real historical thread, got entries: {entries_after:?}"
 2526    );
 2527    assert!(
 2528        !matching_rows[0].contains("Draft"),
 2529        "expected no draft row after first open into a new project group, got entries: {entries_after:?}"
 2530    );
 2531}
 2532
 2533#[gpui::test]
 2534async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
 2535    let project = init_test_project("/my-project", cx).await;
 2536    let (multi_workspace, cx) =
 2537        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2538    let sidebar = setup_sidebar(&multi_workspace, cx);
 2539
 2540    save_thread_metadata(
 2541        acp::SessionId::new(Arc::from("t-1")),
 2542        Some("Thread A".into()),
 2543        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 2544        None,
 2545        None,
 2546        &project,
 2547        cx,
 2548    );
 2549
 2550    save_thread_metadata(
 2551        acp::SessionId::new(Arc::from("t-2")),
 2552        Some("Thread B".into()),
 2553        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 2554        None,
 2555        None,
 2556        &project,
 2557        cx,
 2558    );
 2559
 2560    cx.run_until_parked();
 2561    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 2562    cx.run_until_parked();
 2563
 2564    assert_eq!(
 2565        visible_entries_as_strings(&sidebar, cx),
 2566        vec![
 2567            //
 2568            "v [my-project]",
 2569            "  Thread A",
 2570            "  Thread B",
 2571        ]
 2572    );
 2573
 2574    // Keyboard confirm preserves selection.
 2575    sidebar.update_in(cx, |sidebar, window, cx| {
 2576        sidebar.selection = Some(1);
 2577        sidebar.confirm(&Confirm, window, cx);
 2578    });
 2579    assert_eq!(
 2580        sidebar.read_with(cx, |sidebar, _| sidebar.selection),
 2581        Some(1)
 2582    );
 2583
 2584    // Click handlers clear selection to None so no highlight lingers
 2585    // after a click regardless of focus state. The hover style provides
 2586    // visual feedback during mouse interaction instead.
 2587    sidebar.update_in(cx, |sidebar, window, cx| {
 2588        sidebar.selection = None;
 2589        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 2590        let project_group_key = ProjectGroupKey::new(None, path_list);
 2591        sidebar.toggle_collapse(&project_group_key, window, cx);
 2592    });
 2593    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2594
 2595    // When the user tabs back into the sidebar, focus_in no longer
 2596    // restores selection — it stays None.
 2597    sidebar.update_in(cx, |sidebar, window, cx| {
 2598        sidebar.focus_in(window, cx);
 2599    });
 2600    assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
 2601}
 2602
 2603#[gpui::test]
 2604async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
 2605    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2606    let (multi_workspace, cx) =
 2607        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2608    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2609
 2610    let connection = StubAgentConnection::new();
 2611    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2612        acp::ContentChunk::new("Hi there!".into()),
 2613    )]);
 2614    open_thread_with_connection(&panel, connection, cx);
 2615    send_message(&panel, cx);
 2616
 2617    let session_id = active_session_id(&panel, cx);
 2618    save_test_thread_metadata(&session_id, &project, cx).await;
 2619    cx.run_until_parked();
 2620
 2621    assert_eq!(
 2622        visible_entries_as_strings(&sidebar, cx),
 2623        vec![
 2624            //
 2625            "v [my-project]",
 2626            "  Hello *",
 2627        ]
 2628    );
 2629
 2630    // Simulate the agent generating a title. The notification chain is:
 2631    // AcpThread::set_title emits TitleUpdated →
 2632    // ConnectionView::handle_thread_event calls cx.notify() →
 2633    // AgentPanel observer fires and emits AgentPanelEvent →
 2634    // Sidebar subscription calls update_entries / rebuild_contents.
 2635    //
 2636    // Before the fix, handle_thread_event did NOT call cx.notify() for
 2637    // TitleUpdated, so the AgentPanel observer never fired and the
 2638    // sidebar kept showing the old title.
 2639    let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
 2640    thread.update(cx, |thread, cx| {
 2641        thread
 2642            .set_title("Friendly Greeting with AI".into(), cx)
 2643            .detach();
 2644    });
 2645    cx.run_until_parked();
 2646
 2647    assert_eq!(
 2648        visible_entries_as_strings(&sidebar, cx),
 2649        vec![
 2650            //
 2651            "v [my-project]",
 2652            "  Friendly Greeting with AI *",
 2653        ]
 2654    );
 2655}
 2656
 2657#[gpui::test]
 2658async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
 2659    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 2660    let (multi_workspace, cx) =
 2661        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 2662    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2663
 2664    // Save a thread so it appears in the list.
 2665    let connection_a = StubAgentConnection::new();
 2666    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2667        acp::ContentChunk::new("Done".into()),
 2668    )]);
 2669    open_thread_with_connection(&panel_a, connection_a, cx);
 2670    send_message(&panel_a, cx);
 2671    let session_id_a = active_session_id(&panel_a, cx);
 2672    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 2673
 2674    // Add a second workspace with its own agent panel.
 2675    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
 2676    fs.as_fake()
 2677        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2678        .await;
 2679    let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
 2680    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 2681        mw.test_add_workspace(project_b.clone(), window, cx)
 2682    });
 2683    let panel_b = add_agent_panel(&workspace_b, cx);
 2684    cx.run_until_parked();
 2685
 2686    let workspace_a =
 2687        multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
 2688
 2689    // ── 1. Initial state: focused thread derived from active panel ─────
 2690    sidebar.read_with(cx, |sidebar, _cx| {
 2691        assert_active_thread(
 2692            sidebar,
 2693            &session_id_a,
 2694            "The active panel's thread should be focused on startup",
 2695        );
 2696    });
 2697
 2698    let thread_metadata_a = cx.update(|_window, cx| {
 2699        ThreadMetadataStore::global(cx)
 2700            .read(cx)
 2701            .entry_by_session(&session_id_a)
 2702            .cloned()
 2703            .expect("session_id_a should exist in metadata store")
 2704    });
 2705    sidebar.update_in(cx, |sidebar, window, cx| {
 2706        sidebar.activate_thread(thread_metadata_a, &workspace_a, false, window, cx);
 2707    });
 2708    cx.run_until_parked();
 2709
 2710    sidebar.read_with(cx, |sidebar, _cx| {
 2711        assert_active_thread(
 2712            sidebar,
 2713            &session_id_a,
 2714            "After clicking a thread, it should be the focused thread",
 2715        );
 2716        assert!(
 2717            has_thread_entry(sidebar, &session_id_a),
 2718            "The clicked thread should be present in the entries"
 2719        );
 2720    });
 2721
 2722    workspace_a.read_with(cx, |workspace, cx| {
 2723        assert!(
 2724            workspace.panel::<AgentPanel>(cx).is_some(),
 2725            "Agent panel should exist"
 2726        );
 2727        let dock = workspace.left_dock().read(cx);
 2728        assert!(
 2729            dock.is_open(),
 2730            "Clicking a thread should open the agent panel dock"
 2731        );
 2732    });
 2733
 2734    let connection_b = StubAgentConnection::new();
 2735    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2736        acp::ContentChunk::new("Thread B".into()),
 2737    )]);
 2738    open_thread_with_connection(&panel_b, connection_b, cx);
 2739    send_message(&panel_b, cx);
 2740    let session_id_b = active_session_id(&panel_b, cx);
 2741    save_test_thread_metadata(&session_id_b, &project_b, cx).await;
 2742    cx.run_until_parked();
 2743
 2744    // Workspace A is currently active. Click a thread in workspace B,
 2745    // which also triggers a workspace switch.
 2746    let thread_metadata_b = cx.update(|_window, cx| {
 2747        ThreadMetadataStore::global(cx)
 2748            .read(cx)
 2749            .entry_by_session(&session_id_b)
 2750            .cloned()
 2751            .expect("session_id_b should exist in metadata store")
 2752    });
 2753    sidebar.update_in(cx, |sidebar, window, cx| {
 2754        sidebar.activate_thread(thread_metadata_b, &workspace_b, false, window, cx);
 2755    });
 2756    cx.run_until_parked();
 2757
 2758    sidebar.read_with(cx, |sidebar, _cx| {
 2759        assert_active_thread(
 2760            sidebar,
 2761            &session_id_b,
 2762            "Clicking a thread in another workspace should focus that thread",
 2763        );
 2764        assert!(
 2765            has_thread_entry(sidebar, &session_id_b),
 2766            "The cross-workspace thread should be present in the entries"
 2767        );
 2768    });
 2769
 2770    multi_workspace.update_in(cx, |mw, window, cx| {
 2771        let workspace = mw.workspaces().next().unwrap().clone();
 2772        mw.activate(workspace, None, window, cx);
 2773    });
 2774    cx.run_until_parked();
 2775
 2776    sidebar.read_with(cx, |sidebar, _cx| {
 2777        assert_active_thread(
 2778            sidebar,
 2779            &session_id_a,
 2780            "Switching workspace should seed focused_thread from the new active panel",
 2781        );
 2782        assert!(
 2783            has_thread_entry(sidebar, &session_id_a),
 2784            "The seeded thread should be present in the entries"
 2785        );
 2786    });
 2787
 2788    let connection_b2 = StubAgentConnection::new();
 2789    connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2790        acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
 2791    )]);
 2792    open_thread_with_connection(&panel_b, connection_b2, cx);
 2793    send_message(&panel_b, cx);
 2794    let session_id_b2 = active_session_id(&panel_b, cx);
 2795    save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
 2796    cx.run_until_parked();
 2797
 2798    // Panel B is not the active workspace's panel (workspace A is
 2799    // active), so opening a thread there should not change focused_thread.
 2800    // This prevents running threads in background workspaces from causing
 2801    // the selection highlight to jump around.
 2802    sidebar.read_with(cx, |sidebar, _cx| {
 2803        assert_active_thread(
 2804            sidebar,
 2805            &session_id_a,
 2806            "Opening a thread in a non-active panel should not change focused_thread",
 2807        );
 2808    });
 2809
 2810    workspace_b.update_in(cx, |workspace, window, cx| {
 2811        workspace.focus_handle(cx).focus(window, cx);
 2812    });
 2813    cx.run_until_parked();
 2814
 2815    sidebar.read_with(cx, |sidebar, _cx| {
 2816        assert_active_thread(
 2817            sidebar,
 2818            &session_id_a,
 2819            "Defocusing the sidebar should not change focused_thread",
 2820        );
 2821    });
 2822
 2823    // Switching workspaces via the multi_workspace (simulates clicking
 2824    // a workspace header) should clear focused_thread.
 2825    multi_workspace.update_in(cx, |mw, window, cx| {
 2826        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
 2827        if let Some(workspace) = workspace {
 2828            mw.activate(workspace, None, window, cx);
 2829        }
 2830    });
 2831    cx.run_until_parked();
 2832
 2833    sidebar.read_with(cx, |sidebar, _cx| {
 2834        assert_active_thread(
 2835            sidebar,
 2836            &session_id_b2,
 2837            "Switching workspace should seed focused_thread from the new active panel",
 2838        );
 2839        assert!(
 2840            has_thread_entry(sidebar, &session_id_b2),
 2841            "The seeded thread should be present in the entries"
 2842        );
 2843    });
 2844
 2845    // ── 8. Focusing the agent panel thread keeps focused_thread ────
 2846    // Workspace B still has session_id_b2 loaded in the agent panel.
 2847    // Clicking into the thread (simulated by focusing its view) should
 2848    // keep focused_thread since it was already seeded on workspace switch.
 2849    panel_b.update_in(cx, |panel, window, cx| {
 2850        if let Some(thread_view) = panel.active_conversation_view() {
 2851            thread_view.read(cx).focus_handle(cx).focus(window, cx);
 2852        }
 2853    });
 2854    cx.run_until_parked();
 2855
 2856    sidebar.read_with(cx, |sidebar, _cx| {
 2857        assert_active_thread(
 2858            sidebar,
 2859            &session_id_b2,
 2860            "Focusing the agent panel thread should set focused_thread",
 2861        );
 2862        assert!(
 2863            has_thread_entry(sidebar, &session_id_b2),
 2864            "The focused thread should be present in the entries"
 2865        );
 2866    });
 2867}
 2868
 2869#[gpui::test]
 2870async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
 2871    let project = init_test_project_with_agent_panel("/project-a", cx).await;
 2872    let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
 2873    let (multi_workspace, cx) =
 2874        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2875    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2876
 2877    // Start a thread and send a message so it has history.
 2878    let connection = StubAgentConnection::new();
 2879    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2880        acp::ContentChunk::new("Done".into()),
 2881    )]);
 2882    open_thread_with_connection(&panel, connection, cx);
 2883    send_message(&panel, cx);
 2884    let session_id = active_session_id(&panel, cx);
 2885    save_test_thread_metadata(&session_id, &project, cx).await;
 2886    cx.run_until_parked();
 2887
 2888    // Verify the thread appears in the sidebar.
 2889    assert_eq!(
 2890        visible_entries_as_strings(&sidebar, cx),
 2891        vec![
 2892            //
 2893            "v [project-a]",
 2894            "  Hello *",
 2895        ]
 2896    );
 2897
 2898    // The "New Thread" button should NOT be in "active/draft" state
 2899    // because the panel has a thread with messages.
 2900    sidebar.read_with(cx, |sidebar, _cx| {
 2901        assert!(
 2902            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
 2903            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
 2904            sidebar.active_entry,
 2905        );
 2906    });
 2907
 2908    // Now add a second folder to the workspace, changing the path_list.
 2909    fs.as_fake()
 2910        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 2911        .await;
 2912    project
 2913        .update(cx, |project, cx| {
 2914            project.find_or_create_worktree("/project-b", true, cx)
 2915        })
 2916        .await
 2917        .expect("should add worktree");
 2918    cx.run_until_parked();
 2919
 2920    // The workspace path_list is now [project-a, project-b]. The active
 2921    // thread's metadata was re-saved with the new paths by the agent panel's
 2922    // project subscription. The old [project-a] key is replaced by the new
 2923    // key since no other workspace claims it.
 2924    let entries = visible_entries_as_strings(&sidebar, cx);
 2925    // After adding a worktree, the thread migrates to the new group key.
 2926    // A reconciliation draft may appear during the transition.
 2927    assert!(
 2928        entries.contains(&"  Hello *".to_string()),
 2929        "thread should still be present after adding folder: {entries:?}"
 2930    );
 2931    assert_eq!(entries[0], "v [project-a, project-b]");
 2932
 2933    // The "New Thread" button must still be clickable (not stuck in
 2934    // "active/draft" state). Verify that `active_thread_is_draft` is
 2935    // false — the panel still has the old thread with messages.
 2936    sidebar.read_with(cx, |sidebar, _cx| {
 2937        assert!(
 2938            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
 2939            "After adding a folder the panel still has a thread with messages, \
 2940                 so active_entry should be Thread, got {:?}",
 2941            sidebar.active_entry,
 2942        );
 2943    });
 2944
 2945    // Actually click "New Thread" by calling create_new_thread and
 2946    // verify a new draft is created.
 2947    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 2948    sidebar.update_in(cx, |sidebar, window, cx| {
 2949        sidebar.create_new_thread(&workspace, window, cx);
 2950    });
 2951    cx.run_until_parked();
 2952
 2953    // After creating a new thread, the panel should now be in draft
 2954    // state (no messages on the new thread).
 2955    sidebar.read_with(cx, |sidebar, _cx| {
 2956        assert_active_draft(
 2957            sidebar,
 2958            &workspace,
 2959            "After creating a new thread active_entry should be Draft",
 2960        );
 2961    });
 2962}
 2963#[gpui::test]
 2964async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
 2965    // When the user presses Cmd-N (NewThread action) while viewing a
 2966    // non-empty thread, the panel should switch to the draft thread.
 2967    // Drafts are not shown as sidebar rows.
 2968    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 2969    let (multi_workspace, cx) =
 2970        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 2971    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 2972
 2973    // Create a non-empty thread (has messages).
 2974    let connection = StubAgentConnection::new();
 2975    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 2976        acp::ContentChunk::new("Done".into()),
 2977    )]);
 2978    open_thread_with_connection(&panel, connection, cx);
 2979    send_message(&panel, cx);
 2980
 2981    let session_id = active_session_id(&panel, cx);
 2982    save_test_thread_metadata(&session_id, &project, cx).await;
 2983    cx.run_until_parked();
 2984
 2985    assert_eq!(
 2986        visible_entries_as_strings(&sidebar, cx),
 2987        vec![
 2988            //
 2989            "v [my-project]",
 2990            "  Hello *",
 2991        ]
 2992    );
 2993
 2994    // Simulate cmd-n
 2995    let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
 2996    panel.update_in(cx, |panel, window, cx| {
 2997        panel.new_thread(&NewThread, window, cx);
 2998    });
 2999    workspace.update_in(cx, |workspace, window, cx| {
 3000        workspace.focus_panel::<AgentPanel>(window, cx);
 3001    });
 3002    cx.run_until_parked();
 3003
 3004    // Drafts are not shown as sidebar rows, so entries stay the same.
 3005    assert_eq!(
 3006        visible_entries_as_strings(&sidebar, cx),
 3007        vec!["v [my-project]", "  Hello *"],
 3008        "After Cmd-N the sidebar should not show a Draft entry"
 3009    );
 3010
 3011    // The panel should be on the draft and active_entry should track it.
 3012    panel.read_with(cx, |panel, cx| {
 3013        assert!(
 3014            panel.active_thread_is_draft(cx),
 3015            "panel should be showing the draft after Cmd-N",
 3016        );
 3017    });
 3018    sidebar.read_with(cx, |sidebar, _cx| {
 3019        assert_active_draft(
 3020            sidebar,
 3021            &workspace,
 3022            "active_entry should be Draft after Cmd-N",
 3023        );
 3024    });
 3025}
 3026
 3027#[gpui::test]
 3028async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
 3029    // When the active workspace is an absorbed git worktree, cmd-n
 3030    // should activate the draft thread in the panel. Drafts are not
 3031    // shown as sidebar rows.
 3032    agent_ui::test_support::init_test(cx);
 3033    cx.update(|cx| {
 3034        ThreadStore::init_global(cx);
 3035        ThreadMetadataStore::init_global(cx);
 3036        language_model::LanguageModelRegistry::test(cx);
 3037        prompt_store::init(cx);
 3038    });
 3039
 3040    let fs = FakeFs::new(cx.executor());
 3041
 3042    // Main repo with a linked worktree.
 3043    fs.insert_tree(
 3044        "/project",
 3045        serde_json::json!({
 3046            ".git": {},
 3047            "src": {},
 3048        }),
 3049    )
 3050    .await;
 3051
 3052    // Worktree checkout pointing back to the main repo.
 3053    fs.add_linked_worktree_for_repo(
 3054        Path::new("/project/.git"),
 3055        false,
 3056        git::repository::Worktree {
 3057            path: std::path::PathBuf::from("/wt-feature-a"),
 3058            ref_name: Some("refs/heads/feature-a".into()),
 3059            sha: "aaa".into(),
 3060            is_main: false,
 3061            is_bare: false,
 3062        },
 3063    )
 3064    .await;
 3065
 3066    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3067
 3068    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3069    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3070
 3071    main_project
 3072        .update(cx, |p, cx| p.git_scans_complete(cx))
 3073        .await;
 3074    worktree_project
 3075        .update(cx, |p, cx| p.git_scans_complete(cx))
 3076        .await;
 3077
 3078    let (multi_workspace, cx) =
 3079        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3080
 3081    let sidebar = setup_sidebar(&multi_workspace, cx);
 3082
 3083    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3084        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3085    });
 3086
 3087    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3088
 3089    // Switch to the worktree workspace.
 3090    multi_workspace.update_in(cx, |mw, window, cx| {
 3091        let workspace = mw.workspaces().nth(1).unwrap().clone();
 3092        mw.activate(workspace, None, window, cx);
 3093    });
 3094
 3095    // Create a non-empty thread in the worktree workspace.
 3096    let connection = StubAgentConnection::new();
 3097    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 3098        acp::ContentChunk::new("Done".into()),
 3099    )]);
 3100    open_thread_with_connection(&worktree_panel, connection, cx);
 3101    send_message(&worktree_panel, cx);
 3102
 3103    let session_id = active_session_id(&worktree_panel, cx);
 3104    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3105    cx.run_until_parked();
 3106
 3107    assert_eq!(
 3108        visible_entries_as_strings(&sidebar, cx),
 3109        vec![
 3110            //
 3111            "v [project]",
 3112            "  Hello {wt-feature-a} *",
 3113        ]
 3114    );
 3115
 3116    // Simulate Cmd-N in the worktree workspace.
 3117    worktree_panel.update_in(cx, |panel, window, cx| {
 3118        panel.new_thread(&NewThread, window, cx);
 3119    });
 3120    worktree_workspace.update_in(cx, |workspace, window, cx| {
 3121        workspace.focus_panel::<AgentPanel>(window, cx);
 3122    });
 3123    cx.run_until_parked();
 3124
 3125    // Drafts are not shown as sidebar rows, so entries stay the same.
 3126    assert_eq!(
 3127        visible_entries_as_strings(&sidebar, cx),
 3128        vec![
 3129            //
 3130            "v [project]",
 3131            "  Hello {wt-feature-a} *"
 3132        ],
 3133        "After Cmd-N the sidebar should not show a Draft entry"
 3134    );
 3135
 3136    // The panel should be on the draft and active_entry should track it.
 3137    worktree_panel.read_with(cx, |panel, cx| {
 3138        assert!(
 3139            panel.active_thread_is_draft(cx),
 3140            "panel should be showing the draft after Cmd-N",
 3141        );
 3142    });
 3143    sidebar.read_with(cx, |sidebar, _cx| {
 3144        assert_active_draft(
 3145            sidebar,
 3146            &worktree_workspace,
 3147            "active_entry should be Draft after Cmd-N",
 3148        );
 3149    });
 3150}
 3151
 3152async fn init_test_project_with_git(
 3153    worktree_path: &str,
 3154    cx: &mut TestAppContext,
 3155) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
 3156    init_test(cx);
 3157    let fs = FakeFs::new(cx.executor());
 3158    fs.insert_tree(
 3159        worktree_path,
 3160        serde_json::json!({
 3161            ".git": {},
 3162            "src": {},
 3163        }),
 3164    )
 3165    .await;
 3166    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3167    let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
 3168    (project, fs)
 3169}
 3170
 3171#[gpui::test]
 3172async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
 3173    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3174
 3175    fs.as_fake()
 3176        .add_linked_worktree_for_repo(
 3177            Path::new("/project/.git"),
 3178            false,
 3179            git::repository::Worktree {
 3180                path: std::path::PathBuf::from("/wt/rosewood"),
 3181                ref_name: Some("refs/heads/rosewood".into()),
 3182                sha: "abc".into(),
 3183                is_main: false,
 3184                is_bare: false,
 3185            },
 3186        )
 3187        .await;
 3188
 3189    project
 3190        .update(cx, |project, cx| project.git_scans_complete(cx))
 3191        .await;
 3192
 3193    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3194    worktree_project
 3195        .update(cx, |p, cx| p.git_scans_complete(cx))
 3196        .await;
 3197
 3198    let (multi_workspace, cx) =
 3199        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3200    let sidebar = setup_sidebar(&multi_workspace, cx);
 3201
 3202    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
 3203    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
 3204
 3205    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3206    cx.run_until_parked();
 3207
 3208    // Search for "rosewood" — should match the worktree name, not the title.
 3209    type_in_search(&sidebar, "rosewood", cx);
 3210
 3211    assert_eq!(
 3212        visible_entries_as_strings(&sidebar, cx),
 3213        vec![
 3214            //
 3215            "v [project]",
 3216            "  Fix Bug {rosewood}  <== selected",
 3217        ],
 3218    );
 3219}
 3220
 3221#[gpui::test]
 3222async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
 3223    let (project, fs) = init_test_project_with_git("/project", cx).await;
 3224
 3225    project
 3226        .update(cx, |project, cx| project.git_scans_complete(cx))
 3227        .await;
 3228
 3229    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
 3230    worktree_project
 3231        .update(cx, |p, cx| p.git_scans_complete(cx))
 3232        .await;
 3233
 3234    let (multi_workspace, cx) =
 3235        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3236    let sidebar = setup_sidebar(&multi_workspace, cx);
 3237
 3238    // Save a thread against a worktree path with the correct main
 3239    // worktree association (as if the git state had been resolved).
 3240    save_thread_metadata_with_main_paths(
 3241        "wt-thread",
 3242        "Worktree Thread",
 3243        PathList::new(&[PathBuf::from("/wt/rosewood")]),
 3244        PathList::new(&[PathBuf::from("/project")]),
 3245        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3246        cx,
 3247    );
 3248
 3249    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3250    cx.run_until_parked();
 3251
 3252    // Thread is visible because its main_worktree_paths match the group.
 3253    // The chip name is derived from the path even before git discovery.
 3254    assert_eq!(
 3255        visible_entries_as_strings(&sidebar, cx),
 3256        vec!["v [project]", "  Worktree Thread {rosewood}"]
 3257    );
 3258
 3259    // Now add the worktree to the git state and trigger a rescan.
 3260    fs.as_fake()
 3261        .add_linked_worktree_for_repo(
 3262            Path::new("/project/.git"),
 3263            true,
 3264            git::repository::Worktree {
 3265                path: std::path::PathBuf::from("/wt/rosewood"),
 3266                ref_name: Some("refs/heads/rosewood".into()),
 3267                sha: "abc".into(),
 3268                is_main: false,
 3269                is_bare: false,
 3270            },
 3271        )
 3272        .await;
 3273
 3274    cx.run_until_parked();
 3275
 3276    assert_eq!(
 3277        visible_entries_as_strings(&sidebar, cx),
 3278        vec![
 3279            //
 3280            "v [project]",
 3281            "  Worktree Thread {rosewood}",
 3282        ]
 3283    );
 3284}
 3285
 3286#[gpui::test]
 3287async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
 3288    init_test(cx);
 3289    let fs = FakeFs::new(cx.executor());
 3290
 3291    // Create the main repo directory (not opened as a workspace yet).
 3292    fs.insert_tree(
 3293        "/project",
 3294        serde_json::json!({
 3295            ".git": {
 3296            },
 3297            "src": {},
 3298        }),
 3299    )
 3300    .await;
 3301
 3302    // Two worktree checkouts whose .git files point back to the main repo.
 3303    fs.add_linked_worktree_for_repo(
 3304        Path::new("/project/.git"),
 3305        false,
 3306        git::repository::Worktree {
 3307            path: std::path::PathBuf::from("/wt-feature-a"),
 3308            ref_name: Some("refs/heads/feature-a".into()),
 3309            sha: "aaa".into(),
 3310            is_main: false,
 3311            is_bare: false,
 3312        },
 3313    )
 3314    .await;
 3315    fs.add_linked_worktree_for_repo(
 3316        Path::new("/project/.git"),
 3317        false,
 3318        git::repository::Worktree {
 3319            path: std::path::PathBuf::from("/wt-feature-b"),
 3320            ref_name: Some("refs/heads/feature-b".into()),
 3321            sha: "bbb".into(),
 3322            is_main: false,
 3323            is_bare: false,
 3324        },
 3325    )
 3326    .await;
 3327
 3328    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3329
 3330    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3331    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3332
 3333    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3334    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3335
 3336    // Open both worktrees as workspaces — no main repo yet.
 3337    let (multi_workspace, cx) =
 3338        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3339    multi_workspace.update_in(cx, |mw, window, cx| {
 3340        mw.test_add_workspace(project_b.clone(), window, cx);
 3341    });
 3342    let sidebar = setup_sidebar(&multi_workspace, cx);
 3343
 3344    save_thread_metadata(
 3345        acp::SessionId::new(Arc::from("thread-a")),
 3346        Some("Thread A".into()),
 3347        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 3348        None,
 3349        None,
 3350        &project_a,
 3351        cx,
 3352    );
 3353    save_thread_metadata(
 3354        acp::SessionId::new(Arc::from("thread-b")),
 3355        Some("Thread B".into()),
 3356        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 3357        None,
 3358        None,
 3359        &project_b,
 3360        cx,
 3361    );
 3362
 3363    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3364    cx.run_until_parked();
 3365
 3366    // Without the main repo, each worktree has its own header.
 3367    assert_eq!(
 3368        visible_entries_as_strings(&sidebar, cx),
 3369        vec![
 3370            //
 3371            "v [project]",
 3372            "  Thread B {wt-feature-b}",
 3373            "  Thread A {wt-feature-a}",
 3374        ]
 3375    );
 3376
 3377    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3378    main_project
 3379        .update(cx, |p, cx| p.git_scans_complete(cx))
 3380        .await;
 3381
 3382    multi_workspace.update_in(cx, |mw, window, cx| {
 3383        mw.test_add_workspace(main_project.clone(), window, cx);
 3384    });
 3385    cx.run_until_parked();
 3386
 3387    // Both worktree workspaces should now be absorbed under the main
 3388    // repo header, with worktree chips.
 3389    assert_eq!(
 3390        visible_entries_as_strings(&sidebar, cx),
 3391        vec![
 3392            //
 3393            "v [project]",
 3394            "  Thread B {wt-feature-b}",
 3395            "  Thread A {wt-feature-a}",
 3396        ]
 3397    );
 3398}
 3399
 3400#[gpui::test]
 3401async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) {
 3402    // When a group has two workspaces — one with threads and one
 3403    // without — the threadless workspace should appear as a
 3404    // "New Thread" button with its worktree chip.
 3405    init_test(cx);
 3406    let fs = FakeFs::new(cx.executor());
 3407
 3408    // Main repo with two linked worktrees.
 3409    fs.insert_tree(
 3410        "/project",
 3411        serde_json::json!({
 3412            ".git": {},
 3413            "src": {},
 3414        }),
 3415    )
 3416    .await;
 3417    fs.add_linked_worktree_for_repo(
 3418        Path::new("/project/.git"),
 3419        false,
 3420        git::repository::Worktree {
 3421            path: std::path::PathBuf::from("/wt-feature-a"),
 3422            ref_name: Some("refs/heads/feature-a".into()),
 3423            sha: "aaa".into(),
 3424            is_main: false,
 3425            is_bare: false,
 3426        },
 3427    )
 3428    .await;
 3429    fs.add_linked_worktree_for_repo(
 3430        Path::new("/project/.git"),
 3431        false,
 3432        git::repository::Worktree {
 3433            path: std::path::PathBuf::from("/wt-feature-b"),
 3434            ref_name: Some("refs/heads/feature-b".into()),
 3435            sha: "bbb".into(),
 3436            is_main: false,
 3437            is_bare: false,
 3438        },
 3439    )
 3440    .await;
 3441
 3442    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3443
 3444    // Workspace A: worktree feature-a (has threads).
 3445    let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3446    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3447
 3448    // Workspace B: worktree feature-b (no threads).
 3449    let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 3450    project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3451
 3452    let (multi_workspace, cx) =
 3453        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 3454    multi_workspace.update_in(cx, |mw, window, cx| {
 3455        mw.test_add_workspace(project_b.clone(), window, cx);
 3456    });
 3457    let sidebar = setup_sidebar(&multi_workspace, cx);
 3458
 3459    // Only save a thread for workspace A.
 3460    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
 3461
 3462    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3463    cx.run_until_parked();
 3464
 3465    // Workspace A's thread appears normally. Workspace B (threadless)
 3466    // appears as a "New Thread" button with its worktree chip.
 3467    assert_eq!(
 3468        visible_entries_as_strings(&sidebar, cx),
 3469        vec!["v [project]", "  Thread A {wt-feature-a}",]
 3470    );
 3471}
 3472
 3473#[gpui::test]
 3474async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
 3475    // A thread created in a workspace with roots from different git
 3476    // worktrees should show a chip for each distinct worktree name.
 3477    init_test(cx);
 3478    let fs = FakeFs::new(cx.executor());
 3479
 3480    // Two main repos.
 3481    fs.insert_tree(
 3482        "/project_a",
 3483        serde_json::json!({
 3484            ".git": {},
 3485            "src": {},
 3486        }),
 3487    )
 3488    .await;
 3489    fs.insert_tree(
 3490        "/project_b",
 3491        serde_json::json!({
 3492            ".git": {},
 3493            "src": {},
 3494        }),
 3495    )
 3496    .await;
 3497
 3498    // Worktree checkouts.
 3499    for repo in &["project_a", "project_b"] {
 3500        let git_path = format!("/{repo}/.git");
 3501        for branch in &["olivetti", "selectric"] {
 3502            fs.add_linked_worktree_for_repo(
 3503                Path::new(&git_path),
 3504                false,
 3505                git::repository::Worktree {
 3506                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
 3507                    ref_name: Some(format!("refs/heads/{branch}").into()),
 3508                    sha: "aaa".into(),
 3509                    is_main: false,
 3510                    is_bare: false,
 3511                },
 3512            )
 3513            .await;
 3514        }
 3515    }
 3516
 3517    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3518
 3519    // Open a workspace with the worktree checkout paths as roots
 3520    // (this is the workspace the thread was created in).
 3521    let project = project::Project::test(
 3522        fs.clone(),
 3523        [
 3524            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3525            "/worktrees/project_b/selectric/project_b".as_ref(),
 3526        ],
 3527        cx,
 3528    )
 3529    .await;
 3530    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3531
 3532    let (multi_workspace, cx) =
 3533        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3534    let sidebar = setup_sidebar(&multi_workspace, cx);
 3535
 3536    // Save a thread under the same paths as the workspace roots.
 3537    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
 3538
 3539    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3540    cx.run_until_parked();
 3541
 3542    // Should show two distinct worktree chips.
 3543    assert_eq!(
 3544        visible_entries_as_strings(&sidebar, cx),
 3545        vec![
 3546            //
 3547            "v [project_a, project_b]",
 3548            "  Cross Worktree Thread {project_a:olivetti}, {project_b:selectric}",
 3549        ]
 3550    );
 3551}
 3552
 3553#[gpui::test]
 3554async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
 3555    // When a thread's roots span multiple repos but share the same
 3556    // worktree name (e.g. both in "olivetti"), only one chip should
 3557    // appear.
 3558    init_test(cx);
 3559    let fs = FakeFs::new(cx.executor());
 3560
 3561    fs.insert_tree(
 3562        "/project_a",
 3563        serde_json::json!({
 3564            ".git": {},
 3565            "src": {},
 3566        }),
 3567    )
 3568    .await;
 3569    fs.insert_tree(
 3570        "/project_b",
 3571        serde_json::json!({
 3572            ".git": {},
 3573            "src": {},
 3574        }),
 3575    )
 3576    .await;
 3577
 3578    for repo in &["project_a", "project_b"] {
 3579        let git_path = format!("/{repo}/.git");
 3580        fs.add_linked_worktree_for_repo(
 3581            Path::new(&git_path),
 3582            false,
 3583            git::repository::Worktree {
 3584                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
 3585                ref_name: Some("refs/heads/olivetti".into()),
 3586                sha: "aaa".into(),
 3587                is_main: false,
 3588                is_bare: false,
 3589            },
 3590        )
 3591        .await;
 3592    }
 3593
 3594    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3595
 3596    let project = project::Project::test(
 3597        fs.clone(),
 3598        [
 3599            "/worktrees/project_a/olivetti/project_a".as_ref(),
 3600            "/worktrees/project_b/olivetti/project_b".as_ref(),
 3601        ],
 3602        cx,
 3603    )
 3604    .await;
 3605    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 3606
 3607    let (multi_workspace, cx) =
 3608        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 3609    let sidebar = setup_sidebar(&multi_workspace, cx);
 3610
 3611    // Thread with roots in both repos' "olivetti" worktrees.
 3612    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
 3613
 3614    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3615    cx.run_until_parked();
 3616
 3617    // Both worktree paths have the name "olivetti", so only one chip.
 3618    assert_eq!(
 3619        visible_entries_as_strings(&sidebar, cx),
 3620        vec![
 3621            //
 3622            "v [project_a, project_b]",
 3623            "  Same Branch Thread {olivetti}",
 3624        ]
 3625    );
 3626}
 3627
 3628#[gpui::test]
 3629async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
 3630    // When a worktree workspace is absorbed under the main repo, a
 3631    // running thread in the worktree's agent panel should still show
 3632    // live status (spinner + "(running)") in the sidebar.
 3633    agent_ui::test_support::init_test(cx);
 3634    cx.update(|cx| {
 3635        ThreadStore::init_global(cx);
 3636        ThreadMetadataStore::init_global(cx);
 3637        language_model::LanguageModelRegistry::test(cx);
 3638        prompt_store::init(cx);
 3639    });
 3640
 3641    let fs = FakeFs::new(cx.executor());
 3642
 3643    // Main repo with a linked worktree.
 3644    fs.insert_tree(
 3645        "/project",
 3646        serde_json::json!({
 3647            ".git": {},
 3648            "src": {},
 3649        }),
 3650    )
 3651    .await;
 3652
 3653    // Worktree checkout pointing back to the main repo.
 3654    fs.add_linked_worktree_for_repo(
 3655        Path::new("/project/.git"),
 3656        false,
 3657        git::repository::Worktree {
 3658            path: std::path::PathBuf::from("/wt-feature-a"),
 3659            ref_name: Some("refs/heads/feature-a".into()),
 3660            sha: "aaa".into(),
 3661            is_main: false,
 3662            is_bare: false,
 3663        },
 3664    )
 3665    .await;
 3666
 3667    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3668
 3669    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3670    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3671
 3672    main_project
 3673        .update(cx, |p, cx| p.git_scans_complete(cx))
 3674        .await;
 3675    worktree_project
 3676        .update(cx, |p, cx| p.git_scans_complete(cx))
 3677        .await;
 3678
 3679    // Create the MultiWorkspace with both projects.
 3680    let (multi_workspace, cx) =
 3681        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3682
 3683    let sidebar = setup_sidebar(&multi_workspace, cx);
 3684
 3685    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3686        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3687    });
 3688
 3689    // Add an agent panel to the worktree workspace so we can run a
 3690    // thread inside it.
 3691    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3692
 3693    // Switch back to the main workspace before setting up the sidebar.
 3694    multi_workspace.update_in(cx, |mw, window, cx| {
 3695        let workspace = mw.workspaces().next().unwrap().clone();
 3696        mw.activate(workspace, None, window, cx);
 3697    });
 3698
 3699    // Start a thread in the worktree workspace's panel and keep it
 3700    // generating (don't resolve it).
 3701    let connection = StubAgentConnection::new();
 3702    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 3703    send_message(&worktree_panel, cx);
 3704
 3705    let session_id = active_session_id(&worktree_panel, cx);
 3706
 3707    // Save metadata so the sidebar knows about this thread.
 3708    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3709
 3710    // Keep the thread generating by sending a chunk without ending
 3711    // the turn.
 3712    cx.update(|_, cx| {
 3713        connection.send_update(
 3714            session_id.clone(),
 3715            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 3716            cx,
 3717        );
 3718    });
 3719    cx.run_until_parked();
 3720
 3721    // The worktree thread should be absorbed under the main project
 3722    // and show live running status.
 3723    let entries = visible_entries_as_strings(&sidebar, cx);
 3724    assert_eq!(
 3725        entries,
 3726        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 3727    );
 3728}
 3729
 3730#[gpui::test]
 3731async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
 3732    agent_ui::test_support::init_test(cx);
 3733    cx.update(|cx| {
 3734        ThreadStore::init_global(cx);
 3735        ThreadMetadataStore::init_global(cx);
 3736        language_model::LanguageModelRegistry::test(cx);
 3737        prompt_store::init(cx);
 3738    });
 3739
 3740    let fs = FakeFs::new(cx.executor());
 3741
 3742    fs.insert_tree(
 3743        "/project",
 3744        serde_json::json!({
 3745            ".git": {},
 3746            "src": {},
 3747        }),
 3748    )
 3749    .await;
 3750
 3751    fs.add_linked_worktree_for_repo(
 3752        Path::new("/project/.git"),
 3753        false,
 3754        git::repository::Worktree {
 3755            path: std::path::PathBuf::from("/wt-feature-a"),
 3756            ref_name: Some("refs/heads/feature-a".into()),
 3757            sha: "aaa".into(),
 3758            is_main: false,
 3759            is_bare: false,
 3760        },
 3761    )
 3762    .await;
 3763
 3764    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3765
 3766    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3767    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3768
 3769    main_project
 3770        .update(cx, |p, cx| p.git_scans_complete(cx))
 3771        .await;
 3772    worktree_project
 3773        .update(cx, |p, cx| p.git_scans_complete(cx))
 3774        .await;
 3775
 3776    let (multi_workspace, cx) =
 3777        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3778
 3779    let sidebar = setup_sidebar(&multi_workspace, cx);
 3780
 3781    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 3782        mw.test_add_workspace(worktree_project.clone(), window, cx)
 3783    });
 3784
 3785    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 3786
 3787    multi_workspace.update_in(cx, |mw, window, cx| {
 3788        let workspace = mw.workspaces().next().unwrap().clone();
 3789        mw.activate(workspace, None, window, cx);
 3790    });
 3791
 3792    let connection = StubAgentConnection::new();
 3793    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 3794    send_message(&worktree_panel, cx);
 3795
 3796    let session_id = active_session_id(&worktree_panel, cx);
 3797    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 3798
 3799    cx.update(|_, cx| {
 3800        connection.send_update(
 3801            session_id.clone(),
 3802            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 3803            cx,
 3804        );
 3805    });
 3806    cx.run_until_parked();
 3807
 3808    assert_eq!(
 3809        visible_entries_as_strings(&sidebar, cx),
 3810        vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
 3811    );
 3812
 3813    connection.end_turn(session_id, acp::StopReason::EndTurn);
 3814    cx.run_until_parked();
 3815
 3816    assert_eq!(
 3817        visible_entries_as_strings(&sidebar, cx),
 3818        vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
 3819    );
 3820}
 3821
 3822#[gpui::test]
 3823async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) {
 3824    init_test(cx);
 3825    let fs = FakeFs::new(cx.executor());
 3826
 3827    fs.insert_tree(
 3828        "/project",
 3829        serde_json::json!({
 3830            ".git": {},
 3831            "src": {},
 3832        }),
 3833    )
 3834    .await;
 3835
 3836    fs.add_linked_worktree_for_repo(
 3837        Path::new("/project/.git"),
 3838        false,
 3839        git::repository::Worktree {
 3840            path: std::path::PathBuf::from("/wt-feature-a"),
 3841            ref_name: Some("refs/heads/feature-a".into()),
 3842            sha: "aaa".into(),
 3843            is_main: false,
 3844            is_bare: false,
 3845        },
 3846    )
 3847    .await;
 3848
 3849    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3850
 3851    // Only open the main repo — no workspace for the worktree.
 3852    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3853    main_project
 3854        .update(cx, |p, cx| p.git_scans_complete(cx))
 3855        .await;
 3856
 3857    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3858    worktree_project
 3859        .update(cx, |p, cx| p.git_scans_complete(cx))
 3860        .await;
 3861
 3862    let (multi_workspace, cx) =
 3863        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3864    let sidebar = setup_sidebar(&multi_workspace, cx);
 3865
 3866    // Save a thread for the worktree path (no workspace for it).
 3867    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 3868
 3869    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3870    cx.run_until_parked();
 3871
 3872    // Thread should appear under the main repo with a worktree chip.
 3873    assert_eq!(
 3874        visible_entries_as_strings(&sidebar, cx),
 3875        vec![
 3876            //
 3877            "v [project]",
 3878            "  WT Thread {wt-feature-a}",
 3879        ],
 3880    );
 3881
 3882    // Only 1 workspace should exist.
 3883    assert_eq!(
 3884        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 3885        1,
 3886    );
 3887
 3888    // Focus the sidebar and select the worktree thread.
 3889    focus_sidebar(&sidebar, cx);
 3890    sidebar.update_in(cx, |sidebar, _window, _cx| {
 3891        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 3892    });
 3893
 3894    // Confirm to open the worktree thread.
 3895    cx.dispatch_action(Confirm);
 3896    cx.run_until_parked();
 3897
 3898    // A new workspace should have been created for the worktree path.
 3899    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
 3900        assert_eq!(
 3901            mw.workspaces().count(),
 3902            2,
 3903            "confirming a worktree thread without a workspace should open one",
 3904        );
 3905        mw.workspaces().nth(1).unwrap().clone()
 3906    });
 3907
 3908    let new_path_list =
 3909        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 3910    assert_eq!(
 3911        new_path_list,
 3912        PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
 3913        "the new workspace should have been opened for the worktree path",
 3914    );
 3915}
 3916
 3917#[gpui::test]
 3918async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
 3919    cx: &mut TestAppContext,
 3920) {
 3921    init_test(cx);
 3922    let fs = FakeFs::new(cx.executor());
 3923
 3924    fs.insert_tree(
 3925        "/project",
 3926        serde_json::json!({
 3927            ".git": {},
 3928            "src": {},
 3929        }),
 3930    )
 3931    .await;
 3932
 3933    fs.add_linked_worktree_for_repo(
 3934        Path::new("/project/.git"),
 3935        false,
 3936        git::repository::Worktree {
 3937            path: std::path::PathBuf::from("/wt-feature-a"),
 3938            ref_name: Some("refs/heads/feature-a".into()),
 3939            sha: "aaa".into(),
 3940            is_main: false,
 3941            is_bare: false,
 3942        },
 3943    )
 3944    .await;
 3945
 3946    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 3947
 3948    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 3949    main_project
 3950        .update(cx, |p, cx| p.git_scans_complete(cx))
 3951        .await;
 3952
 3953    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 3954    worktree_project
 3955        .update(cx, |p, cx| p.git_scans_complete(cx))
 3956        .await;
 3957
 3958    let (multi_workspace, cx) =
 3959        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 3960    let sidebar = setup_sidebar(&multi_workspace, cx);
 3961
 3962    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 3963
 3964    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 3965    cx.run_until_parked();
 3966
 3967    assert_eq!(
 3968        visible_entries_as_strings(&sidebar, cx),
 3969        vec![
 3970            //
 3971            "v [project]",
 3972            "  WT Thread {wt-feature-a}",
 3973        ],
 3974    );
 3975
 3976    focus_sidebar(&sidebar, cx);
 3977    sidebar.update_in(cx, |sidebar, _window, _cx| {
 3978        sidebar.selection = Some(1); // index 0 is header, 1 is the thread
 3979    });
 3980
 3981    let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
 3982        let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
 3983            if let ListEntry::ProjectHeader { label, .. } = entry {
 3984                Some(label.as_ref())
 3985            } else {
 3986                None
 3987            }
 3988        });
 3989
 3990        let Some(project_header) = project_headers.next() else {
 3991            panic!("expected exactly one sidebar project header named `project`, found none");
 3992        };
 3993        assert_eq!(
 3994            project_header, "project",
 3995            "expected the only sidebar project header to be `project`"
 3996        );
 3997        if let Some(unexpected_header) = project_headers.next() {
 3998            panic!(
 3999                "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
 4000            );
 4001        }
 4002
 4003        let mut saw_expected_thread = false;
 4004        for entry in &sidebar.contents.entries {
 4005            match entry {
 4006                ListEntry::ProjectHeader { label, .. } => {
 4007                    assert_eq!(
 4008                        label.as_ref(),
 4009                        "project",
 4010                        "expected the only sidebar project header to be `project`"
 4011                    );
 4012                }
 4013                ListEntry::Thread(thread)
 4014                    if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
 4015                        && thread
 4016                            .worktrees
 4017                            .first()
 4018                            .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
 4019                            == Some("wt-feature-a") =>
 4020                {
 4021                    saw_expected_thread = true;
 4022                }
 4023                ListEntry::Thread(thread) => {
 4024                    let title = thread.metadata.display_title();
 4025                    let worktree_name = thread
 4026                        .worktrees
 4027                        .first()
 4028                        .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
 4029                        .unwrap_or("<none>");
 4030                    panic!(
 4031                        "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
 4032                        title, worktree_name
 4033                    );
 4034                }
 4035                ListEntry::Terminal(terminal) => {
 4036                    panic!(
 4037                        "unexpected sidebar terminal while opening linked worktree thread: title=`{}`",
 4038                        terminal.title
 4039                    );
 4040                }
 4041            }
 4042        }
 4043
 4044        assert!(
 4045            saw_expected_thread,
 4046            "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
 4047        );
 4048    };
 4049
 4050    sidebar
 4051        .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
 4052        .detach();
 4053
 4054    let window = cx.windows()[0];
 4055    cx.update_window(window, |_, window, cx| {
 4056        window.dispatch_action(Confirm.boxed_clone(), cx);
 4057    })
 4058    .unwrap();
 4059
 4060    cx.run_until_parked();
 4061
 4062    sidebar.update(cx, assert_sidebar_state);
 4063}
 4064
 4065#[gpui::test]
 4066async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
 4067    cx: &mut TestAppContext,
 4068) {
 4069    init_test(cx);
 4070    let fs = FakeFs::new(cx.executor());
 4071
 4072    fs.insert_tree(
 4073        "/project",
 4074        serde_json::json!({
 4075            ".git": {},
 4076            "src": {},
 4077        }),
 4078    )
 4079    .await;
 4080
 4081    fs.add_linked_worktree_for_repo(
 4082        Path::new("/project/.git"),
 4083        false,
 4084        git::repository::Worktree {
 4085            path: std::path::PathBuf::from("/wt-feature-a"),
 4086            ref_name: Some("refs/heads/feature-a".into()),
 4087            sha: "aaa".into(),
 4088            is_main: false,
 4089            is_bare: false,
 4090        },
 4091    )
 4092    .await;
 4093
 4094    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4095
 4096    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4097    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4098
 4099    main_project
 4100        .update(cx, |p, cx| p.git_scans_complete(cx))
 4101        .await;
 4102    worktree_project
 4103        .update(cx, |p, cx| p.git_scans_complete(cx))
 4104        .await;
 4105
 4106    let (multi_workspace, cx) =
 4107        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4108
 4109    let sidebar = setup_sidebar(&multi_workspace, cx);
 4110
 4111    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4112        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4113    });
 4114
 4115    // Activate the main workspace before setting up the sidebar.
 4116    let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4117        let workspace = mw.workspaces().next().unwrap().clone();
 4118        mw.activate(workspace.clone(), None, window, cx);
 4119        workspace
 4120    });
 4121
 4122    save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await;
 4123    save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await;
 4124
 4125    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 4126    cx.run_until_parked();
 4127
 4128    // The worktree workspace should be absorbed under the main repo.
 4129    let entries = visible_entries_as_strings(&sidebar, cx);
 4130    assert_eq!(entries.len(), 3);
 4131    assert_eq!(entries[0], "v [project]");
 4132    assert!(entries.contains(&"  Main Thread".to_string()));
 4133    assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
 4134
 4135    let wt_thread_index = entries
 4136        .iter()
 4137        .position(|e| e.contains("WT Thread"))
 4138        .expect("should find the worktree thread entry");
 4139
 4140    assert_eq!(
 4141        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4142        main_workspace,
 4143        "main workspace should be active initially"
 4144    );
 4145
 4146    // Focus the sidebar and select the absorbed worktree thread.
 4147    focus_sidebar(&sidebar, cx);
 4148    sidebar.update_in(cx, |sidebar, _window, _cx| {
 4149        sidebar.selection = Some(wt_thread_index);
 4150    });
 4151
 4152    // Confirm to activate the worktree thread.
 4153    cx.dispatch_action(Confirm);
 4154    cx.run_until_parked();
 4155
 4156    // The worktree workspace should now be active, not the main one.
 4157    let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 4158    assert_eq!(
 4159        active_workspace, worktree_workspace,
 4160        "clicking an absorbed worktree thread should activate the worktree workspace"
 4161    );
 4162}
 4163
 4164// Reproduces the core of the user-reported bug: a thread belonging to
 4165// a multi-root workspace that mixes a standalone project and a linked
 4166// git worktree can become invisible in the sidebar when its stored
 4167// `main_worktree_paths` don't match the workspace's project group
 4168// key. The metadata still exists and Thread History still shows it,
 4169// but the sidebar rebuild's lookups all miss.
 4170//
 4171// Real-world setup: a single multi-root workspace whose roots are
 4172// `[/cloud, /worktrees/zed/wt_a/zed]`, where:
 4173//   - `/cloud` is a standalone git repo (main == folder).
 4174//   - `/worktrees/zed/wt_a/zed` is a linked worktree of `/zed`.
 4175//
 4176// Once git scans complete the project group key is
 4177// `[/cloud, /zed]` — the main paths of the two roots. A thread
 4178// created in this workspace is written with
 4179// `main=[/cloud, /zed], folder=[/cloud, /worktrees/zed/wt_a/zed]`
 4180// and the sidebar finds it via `entries_for_main_worktree_path`.
 4181//
 4182// If some other code path (stale data on reload, a path-less archive
 4183// restored via the project picker, a legacy write …) persists the
 4184// thread with `main == folder` instead, the stored
 4185// `main_worktree_paths` is
 4186// `[/cloud, /worktrees/zed/wt_a/zed]` ≠ `[/cloud, /zed]`. The three
 4187// lookups in `rebuild_contents` all miss:
 4188//
 4189//   1. `entries_for_main_worktree_path([/cloud, /zed])` — the
 4190//      thread's stored main doesn't equal the group key.
 4191//   2. `entries_for_path([/cloud, /zed])` — the thread's folder paths
 4192//      don't equal the group key either.
 4193//   3. The linked-worktree fallback iterates the group's workspaces'
 4194//      `linked_worktrees()` snapshots. Those yield *sibling* linked
 4195//      worktrees of the repo, not the workspace's own roots, so the
 4196//      thread's folder `/worktrees/zed/wt_a/zed` doesn't match.
 4197//
 4198// The row falls out of the sidebar entirely — matching the user's
 4199// symptom of a thread visible in the agent panel but missing from
 4200// the sidebar. It only reappears once something re-writes the
 4201// thread's metadata in the good shape (e.g. `handle_conversation_event`
 4202// firing after the user sends a message).
 4203//
 4204// We directly persist the bad shape via `store.save(...)` rather
 4205// than trying to reproduce the original writer. The bug is
 4206// ultimately about the sidebar's tolerance for any stale row whose
 4207// folder paths correspond to an open workspace's roots, regardless
 4208// of how that row came to be in the store.
 4209#[gpui::test]
 4210async fn test_sidebar_keeps_multi_root_thread_with_stale_main_paths(cx: &mut TestAppContext) {
 4211    agent_ui::test_support::init_test(cx);
 4212    cx.update(|cx| {
 4213        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 4214        ThreadStore::init_global(cx);
 4215        ThreadMetadataStore::init_global(cx);
 4216        language_model::LanguageModelRegistry::test(cx);
 4217        prompt_store::init(cx);
 4218    });
 4219
 4220    let fs = FakeFs::new(cx.executor());
 4221
 4222    // Standalone repo — one of the workspace's two roots, main
 4223    // worktree of its own .git.
 4224    fs.insert_tree(
 4225        "/cloud",
 4226        serde_json::json!({
 4227            ".git": {},
 4228            "src": {},
 4229        }),
 4230    )
 4231    .await;
 4232
 4233    // Separate /zed repo whose linked worktree will form the second
 4234    // workspace root. /zed itself is NOT opened as a workspace root.
 4235    fs.insert_tree(
 4236        "/zed",
 4237        serde_json::json!({
 4238            ".git": {},
 4239            "src": {},
 4240        }),
 4241    )
 4242    .await;
 4243    fs.insert_tree(
 4244        "/worktrees/zed/wt_a/zed",
 4245        serde_json::json!({
 4246            ".git": "gitdir: /zed/.git/worktrees/wt_a",
 4247            "src": {},
 4248        }),
 4249    )
 4250    .await;
 4251    fs.add_linked_worktree_for_repo(
 4252        Path::new("/zed/.git"),
 4253        false,
 4254        git::repository::Worktree {
 4255            path: std::path::PathBuf::from("/worktrees/zed/wt_a/zed"),
 4256            ref_name: Some("refs/heads/wt_a".into()),
 4257            sha: "aaa".into(),
 4258            is_main: false,
 4259            is_bare: false,
 4260        },
 4261    )
 4262    .await;
 4263
 4264    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4265
 4266    // Single multi-root project with both /cloud and the linked
 4267    // worktree of /zed.
 4268    let project = project::Project::test(
 4269        fs.clone(),
 4270        ["/cloud".as_ref(), "/worktrees/zed/wt_a/zed".as_ref()],
 4271        cx,
 4272    )
 4273    .await;
 4274    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 4275
 4276    let (multi_workspace, cx) =
 4277        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 4278    let sidebar = setup_sidebar(&multi_workspace, cx);
 4279    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4280    let _panel = add_agent_panel(&workspace, cx);
 4281    cx.run_until_parked();
 4282
 4283    // Sanity-check the shapes the rest of the test depends on.
 4284    let group_key = workspace.read_with(cx, |ws, cx| ws.project_group_key(cx));
 4285    let expected_main_paths = PathList::new(&[PathBuf::from("/cloud"), PathBuf::from("/zed")]);
 4286    assert_eq!(
 4287        group_key.path_list(),
 4288        &expected_main_paths,
 4289        "expected the multi-root workspace's project group key to normalize to \
 4290         [/cloud, /zed] (main of the standalone repo + main of the linked worktree)"
 4291    );
 4292
 4293    let folder_paths = PathList::new(&[
 4294        PathBuf::from("/cloud"),
 4295        PathBuf::from("/worktrees/zed/wt_a/zed"),
 4296    ]);
 4297    let workspace_root_paths = workspace.read_with(cx, |ws, cx| PathList::new(&ws.root_paths(cx)));
 4298    assert_eq!(
 4299        workspace_root_paths, folder_paths,
 4300        "expected the workspace's root paths to equal [/cloud, /worktrees/zed/wt_a/zed]"
 4301    );
 4302
 4303    let session_id = acp::SessionId::new(Arc::from("multi-root-stale-paths"));
 4304    let thread_id = ThreadId::new();
 4305
 4306    // Persist the thread in the "bad" shape that the bug manifests as:
 4307    // main == folder for every root. Any stale row where
 4308    // `main_worktree_paths` no longer equals the group key produces
 4309    // the same user-visible symptom; this is the concrete shape
 4310    // produced by `WorktreePaths::from_folder_paths` on the workspace
 4311    // roots.
 4312    cx.update(|_, cx| {
 4313        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 4314            store.save(
 4315                ThreadMetadata {
 4316                    thread_id,
 4317                    session_id: Some(session_id.clone()),
 4318                    agent_id: agent::ZED_AGENT_ID.clone(),
 4319                    title: Some("Stale Multi-Root Thread".into()),
 4320                    updated_at: Utc::now(),
 4321                    created_at: None,
 4322                    interacted_at: None,
 4323                    worktree_paths: WorktreePaths::from_folder_paths(&folder_paths),
 4324                    archived: false,
 4325                    remote_connection: None,
 4326                },
 4327                cx,
 4328            )
 4329        });
 4330    });
 4331    cx.run_until_parked();
 4332
 4333    let entries = visible_entries_as_strings(&sidebar, cx);
 4334    let visible = sidebar.read_with(cx, |sidebar, _cx| has_thread_entry(sidebar, &session_id));
 4335
 4336    // If this assert fails, we've reproduced the bug: the sidebar's
 4337    // rebuild queries can't locate the thread under the current
 4338    // project group, even though the metadata is intact and the
 4339    // thread's folder paths exactly equal the open workspace's roots.
 4340    assert!(
 4341        visible,
 4342        "thread disappeared from the sidebar when its main_worktree_paths \
 4343         ({folder_paths:?}) diverged from the project group key ({expected_main_paths:?}); \
 4344         sidebar entries: {entries:?}"
 4345    );
 4346}
 4347
 4348#[gpui::test]
 4349async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
 4350    cx: &mut TestAppContext,
 4351) {
 4352    // Thread has saved metadata in ThreadStore. A matching workspace is
 4353    // already open. Expected: activates the matching workspace.
 4354    init_test(cx);
 4355    let fs = FakeFs::new(cx.executor());
 4356    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4357        .await;
 4358    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4359        .await;
 4360    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4361
 4362    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4363    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4364
 4365    let (multi_workspace, cx) =
 4366        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 4367
 4368    let sidebar = setup_sidebar(&multi_workspace, cx);
 4369
 4370    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4371        mw.test_add_workspace(project_b.clone(), window, cx)
 4372    });
 4373    let workspace_a =
 4374        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4375
 4376    // Save a thread with path_list pointing to project-b.
 4377    let session_id = acp::SessionId::new(Arc::from("archived-1"));
 4378    save_test_thread_metadata(&session_id, &project_b, cx).await;
 4379
 4380    // Ensure workspace A is active.
 4381    multi_workspace.update_in(cx, |mw, window, cx| {
 4382        let workspace = mw.workspaces().next().unwrap().clone();
 4383        mw.activate(workspace, None, window, cx);
 4384    });
 4385    cx.run_until_parked();
 4386    assert_eq!(
 4387        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4388        workspace_a
 4389    );
 4390
 4391    // Call activate_archived_thread – should resolve saved paths and
 4392    // switch to the workspace for project-b.
 4393    sidebar.update_in(cx, |sidebar, window, cx| {
 4394        sidebar.open_thread_from_archive(
 4395            ThreadMetadata {
 4396                thread_id: ThreadId::new(),
 4397                session_id: Some(session_id.clone()),
 4398                agent_id: agent::ZED_AGENT_ID.clone(),
 4399                title: Some("Archived Thread".into()),
 4400                updated_at: Utc::now(),
 4401                created_at: None,
 4402                interacted_at: None,
 4403                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4404                    "/project-b",
 4405                )])),
 4406                archived: false,
 4407                remote_connection: None,
 4408            },
 4409            window,
 4410            cx,
 4411        );
 4412    });
 4413    cx.run_until_parked();
 4414
 4415    assert_eq!(
 4416        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4417        workspace_b,
 4418        "should have switched to the workspace matching the saved paths"
 4419    );
 4420}
 4421
 4422#[gpui::test]
 4423async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
 4424    cx: &mut TestAppContext,
 4425) {
 4426    // Thread has no saved metadata but session_info has cwd. A matching
 4427    // workspace is open. Expected: uses cwd to find and activate it.
 4428    init_test(cx);
 4429    let fs = FakeFs::new(cx.executor());
 4430    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4431        .await;
 4432    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4433        .await;
 4434    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4435
 4436    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4437    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4438
 4439    let (multi_workspace, cx) =
 4440        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4441
 4442    let sidebar = setup_sidebar(&multi_workspace, cx);
 4443
 4444    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4445        mw.test_add_workspace(project_b, window, cx)
 4446    });
 4447    let workspace_a =
 4448        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4449
 4450    // Start with workspace A active.
 4451    multi_workspace.update_in(cx, |mw, window, cx| {
 4452        let workspace = mw.workspaces().next().unwrap().clone();
 4453        mw.activate(workspace, None, window, cx);
 4454    });
 4455    cx.run_until_parked();
 4456    assert_eq!(
 4457        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4458        workspace_a
 4459    );
 4460
 4461    // No thread saved to the store – cwd is the only path hint.
 4462    sidebar.update_in(cx, |sidebar, window, cx| {
 4463        sidebar.open_thread_from_archive(
 4464            ThreadMetadata {
 4465                thread_id: ThreadId::new(),
 4466                session_id: Some(acp::SessionId::new(Arc::from("unknown-session"))),
 4467                agent_id: agent::ZED_AGENT_ID.clone(),
 4468                title: Some("CWD Thread".into()),
 4469                updated_at: Utc::now(),
 4470                created_at: None,
 4471                interacted_at: None,
 4472                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 4473                    std::path::PathBuf::from("/project-b"),
 4474                ])),
 4475                archived: false,
 4476                remote_connection: None,
 4477            },
 4478            window,
 4479            cx,
 4480        );
 4481    });
 4482    cx.run_until_parked();
 4483
 4484    assert_eq!(
 4485        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4486        workspace_b,
 4487        "should have activated the workspace matching the cwd"
 4488    );
 4489}
 4490
 4491#[gpui::test]
 4492async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
 4493    cx: &mut TestAppContext,
 4494) {
 4495    // Thread has no saved metadata and no cwd. Expected: falls back to
 4496    // the currently active workspace.
 4497    init_test(cx);
 4498    let fs = FakeFs::new(cx.executor());
 4499    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4500        .await;
 4501    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4502        .await;
 4503    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4504
 4505    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4506    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4507
 4508    let (multi_workspace, cx) =
 4509        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4510
 4511    let sidebar = setup_sidebar(&multi_workspace, cx);
 4512
 4513    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 4514        mw.test_add_workspace(project_b, window, cx)
 4515    });
 4516
 4517    // Activate workspace B (index 1) to make it the active one.
 4518    multi_workspace.update_in(cx, |mw, window, cx| {
 4519        let workspace = mw.workspaces().nth(1).unwrap().clone();
 4520        mw.activate(workspace, None, window, cx);
 4521    });
 4522    cx.run_until_parked();
 4523    assert_eq!(
 4524        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4525        workspace_b
 4526    );
 4527
 4528    // No saved thread, no cwd – should fall back to the active workspace.
 4529    sidebar.update_in(cx, |sidebar, window, cx| {
 4530        sidebar.open_thread_from_archive(
 4531            ThreadMetadata {
 4532                thread_id: ThreadId::new(),
 4533                session_id: Some(acp::SessionId::new(Arc::from("no-context-session"))),
 4534                agent_id: agent::ZED_AGENT_ID.clone(),
 4535                title: Some("Contextless Thread".into()),
 4536                updated_at: Utc::now(),
 4537                created_at: None,
 4538                interacted_at: None,
 4539                worktree_paths: WorktreePaths::default(),
 4540                archived: false,
 4541                remote_connection: None,
 4542            },
 4543            window,
 4544            cx,
 4545        );
 4546    });
 4547    cx.run_until_parked();
 4548
 4549    assert_eq!(
 4550        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
 4551        workspace_b,
 4552        "should have stayed on the active workspace when no path info is available"
 4553    );
 4554}
 4555
 4556#[gpui::test]
 4557async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) {
 4558    // Thread has saved metadata pointing to a path with no open workspace.
 4559    // Expected: opens a new workspace for that path.
 4560    init_test(cx);
 4561    let fs = FakeFs::new(cx.executor());
 4562    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4563        .await;
 4564    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4565        .await;
 4566    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4567
 4568    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4569
 4570    let (multi_workspace, cx) =
 4571        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4572
 4573    let sidebar = setup_sidebar(&multi_workspace, cx);
 4574
 4575    // Save a thread with path_list pointing to project-b – which has no
 4576    // open workspace.
 4577    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 4578    let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
 4579
 4580    assert_eq!(
 4581        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4582        1,
 4583        "should start with one workspace"
 4584    );
 4585
 4586    sidebar.update_in(cx, |sidebar, window, cx| {
 4587        sidebar.open_thread_from_archive(
 4588            ThreadMetadata {
 4589                thread_id: ThreadId::new(),
 4590                session_id: Some(session_id.clone()),
 4591                agent_id: agent::ZED_AGENT_ID.clone(),
 4592                title: Some("New WS Thread".into()),
 4593                updated_at: Utc::now(),
 4594                created_at: None,
 4595                interacted_at: None,
 4596                worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 4597                archived: false,
 4598                remote_connection: None,
 4599            },
 4600            window,
 4601            cx,
 4602        );
 4603    });
 4604    cx.run_until_parked();
 4605
 4606    assert_eq!(
 4607        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 4608        2,
 4609        "should have opened a second workspace for the archived thread's saved paths"
 4610    );
 4611}
 4612
 4613#[gpui::test]
 4614async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) {
 4615    init_test(cx);
 4616    let fs = FakeFs::new(cx.executor());
 4617    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4618        .await;
 4619    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4620        .await;
 4621    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4622
 4623    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4624    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4625
 4626    let multi_workspace_a =
 4627        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4628    let multi_workspace_b =
 4629        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, 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 = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4639
 4640    let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
 4641
 4642    sidebar.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("Cross 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-b",
 4654                )])),
 4655                archived: false,
 4656                remote_connection: None,
 4657            },
 4658            window,
 4659            cx,
 4660        );
 4661    });
 4662    cx_a.run_until_parked();
 4663
 4664    assert_eq!(
 4665        multi_workspace_a
 4666            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4667            .unwrap(),
 4668        1,
 4669        "should not add the other window's workspace into the current window"
 4670    );
 4671    assert_eq!(
 4672        multi_workspace_b
 4673            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4674            .unwrap(),
 4675        1,
 4676        "should reuse the existing workspace in the other window"
 4677    );
 4678    assert!(
 4679        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4680        "should activate the window that already owns the matching workspace"
 4681    );
 4682    sidebar.read_with(cx_a, |sidebar, _| {
 4683            assert!(
 4684                !is_active_session(&sidebar, &session_id),
 4685                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4686            );
 4687        });
 4688}
 4689
 4690#[gpui::test]
 4691async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
 4692    cx: &mut TestAppContext,
 4693) {
 4694    init_test(cx);
 4695    let fs = FakeFs::new(cx.executor());
 4696    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4697        .await;
 4698    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 4699        .await;
 4700    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4701
 4702    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4703    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 4704
 4705    let multi_workspace_a =
 4706        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4707    let multi_workspace_b =
 4708        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
 4709
 4710    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4711    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4712
 4713    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4714    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4715
 4716    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4717    let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4718    let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
 4719    let _panel_b = add_agent_panel(&workspace_b, cx_b);
 4720
 4721    let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
 4722
 4723    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4724        sidebar.open_thread_from_archive(
 4725            ThreadMetadata {
 4726                thread_id: ThreadId::new(),
 4727                session_id: Some(session_id.clone()),
 4728                agent_id: agent::ZED_AGENT_ID.clone(),
 4729                title: Some("Cross Window Thread".into()),
 4730                updated_at: Utc::now(),
 4731                created_at: None,
 4732                interacted_at: None,
 4733                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4734                    "/project-b",
 4735                )])),
 4736                archived: false,
 4737                remote_connection: None,
 4738            },
 4739            window,
 4740            cx,
 4741        );
 4742    });
 4743    cx_a.run_until_parked();
 4744
 4745    assert_eq!(
 4746        multi_workspace_a
 4747            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4748            .unwrap(),
 4749        1,
 4750        "should not add the other window's workspace into the current window"
 4751    );
 4752    assert_eq!(
 4753        multi_workspace_b
 4754            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4755            .unwrap(),
 4756        1,
 4757        "should reuse the existing workspace in the other window"
 4758    );
 4759    assert!(
 4760        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
 4761        "should activate the window that already owns the matching workspace"
 4762    );
 4763    sidebar_a.read_with(cx_a, |sidebar, _| {
 4764            assert!(
 4765                !is_active_session(&sidebar, &session_id),
 4766                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
 4767            );
 4768        });
 4769    sidebar_b.read_with(cx_b, |sidebar, _| {
 4770        assert_active_thread(
 4771            sidebar,
 4772            &session_id,
 4773            "target window's sidebar should eagerly focus the activated archived thread",
 4774        );
 4775    });
 4776}
 4777
 4778#[gpui::test]
 4779async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
 4780    cx: &mut TestAppContext,
 4781) {
 4782    init_test(cx);
 4783    let fs = FakeFs::new(cx.executor());
 4784    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 4785        .await;
 4786    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4787
 4788    let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4789    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 4790
 4791    let multi_workspace_b =
 4792        cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
 4793    let multi_workspace_a =
 4794        cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 4795
 4796    let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
 4797    let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
 4798
 4799    let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
 4800    let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
 4801
 4802    let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
 4803    let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
 4804
 4805    let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
 4806
 4807    sidebar_a.update_in(cx_a, |sidebar, window, cx| {
 4808        sidebar.open_thread_from_archive(
 4809            ThreadMetadata {
 4810                thread_id: ThreadId::new(),
 4811                session_id: Some(session_id.clone()),
 4812                agent_id: agent::ZED_AGENT_ID.clone(),
 4813                title: Some("Current Window Thread".into()),
 4814                updated_at: Utc::now(),
 4815                created_at: None,
 4816                interacted_at: None,
 4817                worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 4818                    "/project-a",
 4819                )])),
 4820                archived: false,
 4821                remote_connection: None,
 4822            },
 4823            window,
 4824            cx,
 4825        );
 4826    });
 4827    cx_a.run_until_parked();
 4828
 4829    assert!(
 4830        cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
 4831        "should keep activation in the current window when it already has a matching workspace"
 4832    );
 4833    sidebar_a.read_with(cx_a, |sidebar, _| {
 4834        assert_active_thread(
 4835            sidebar,
 4836            &session_id,
 4837            "current window's sidebar should eagerly focus the activated archived thread",
 4838        );
 4839    });
 4840    assert_eq!(
 4841        multi_workspace_a
 4842            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4843            .unwrap(),
 4844        1,
 4845        "current window should continue reusing its existing workspace"
 4846    );
 4847    assert_eq!(
 4848        multi_workspace_b
 4849            .read_with(cx_a, |mw, _| mw.workspaces().count())
 4850            .unwrap(),
 4851        1,
 4852        "other windows should not be activated just because they also match the saved paths"
 4853    );
 4854}
 4855
 4856#[gpui::test]
 4857async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
 4858    // Regression test: archive_thread previously always loaded the next thread
 4859    // through group_workspace (the main workspace's ProjectHeader), even when
 4860    // the next thread belonged to an absorbed linked-worktree workspace. That
 4861    // caused the worktree thread to be loaded in the main panel, which bound it
 4862    // to the main project and corrupted its stored folder_paths.
 4863    //
 4864    // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
 4865    // falling back to group_workspace only for Closed workspaces.
 4866    agent_ui::test_support::init_test(cx);
 4867    cx.update(|cx| {
 4868        ThreadStore::init_global(cx);
 4869        ThreadMetadataStore::init_global(cx);
 4870        language_model::LanguageModelRegistry::test(cx);
 4871        prompt_store::init(cx);
 4872    });
 4873
 4874    let fs = FakeFs::new(cx.executor());
 4875
 4876    fs.insert_tree(
 4877        "/project",
 4878        serde_json::json!({
 4879            ".git": {},
 4880            "src": {},
 4881        }),
 4882    )
 4883    .await;
 4884
 4885    fs.add_linked_worktree_for_repo(
 4886        Path::new("/project/.git"),
 4887        false,
 4888        git::repository::Worktree {
 4889            path: std::path::PathBuf::from("/wt-feature-a"),
 4890            ref_name: Some("refs/heads/feature-a".into()),
 4891            sha: "aaa".into(),
 4892            is_main: false,
 4893            is_bare: false,
 4894        },
 4895    )
 4896    .await;
 4897
 4898    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 4899
 4900    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 4901    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 4902
 4903    main_project
 4904        .update(cx, |p, cx| p.git_scans_complete(cx))
 4905        .await;
 4906    worktree_project
 4907        .update(cx, |p, cx| p.git_scans_complete(cx))
 4908        .await;
 4909
 4910    let (multi_workspace, cx) =
 4911        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 4912
 4913    let sidebar = setup_sidebar(&multi_workspace, cx);
 4914
 4915    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 4916        mw.test_add_workspace(worktree_project.clone(), window, cx)
 4917    });
 4918
 4919    // Activate main workspace so the sidebar tracks the main panel.
 4920    multi_workspace.update_in(cx, |mw, window, cx| {
 4921        let workspace = mw.workspaces().next().unwrap().clone();
 4922        mw.activate(workspace, None, window, cx);
 4923    });
 4924
 4925    let main_workspace =
 4926        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 4927    let main_panel = add_agent_panel(&main_workspace, cx);
 4928    let _worktree_panel = add_agent_panel(&worktree_workspace, cx);
 4929
 4930    // Open Thread 2 in the main panel and keep it running.
 4931    let connection = StubAgentConnection::new();
 4932    open_thread_with_connection(&main_panel, connection.clone(), cx);
 4933    send_message(&main_panel, cx);
 4934
 4935    let thread2_session_id = active_session_id(&main_panel, cx);
 4936
 4937    cx.update(|_, cx| {
 4938        connection.send_update(
 4939            thread2_session_id.clone(),
 4940            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
 4941            cx,
 4942        );
 4943    });
 4944
 4945    // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
 4946    save_thread_metadata(
 4947        thread2_session_id.clone(),
 4948        Some("Thread 2".into()),
 4949        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 4950        None,
 4951        None,
 4952        &main_project,
 4953        cx,
 4954    );
 4955
 4956    // Save thread 1's metadata with the worktree path and an older timestamp so
 4957    // it sorts below thread 2. archive_thread will find it as the "next" candidate.
 4958    let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
 4959    save_thread_metadata(
 4960        thread1_session_id,
 4961        Some("Thread 1".into()),
 4962        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 4963        None,
 4964        None,
 4965        &worktree_project,
 4966        cx,
 4967    );
 4968
 4969    cx.run_until_parked();
 4970
 4971    // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
 4972    let entries_before = visible_entries_as_strings(&sidebar, cx);
 4973    assert!(
 4974        entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
 4975        "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
 4976        entries_before
 4977    );
 4978
 4979    // The sidebar should track T2 as the focused thread (derived from the
 4980    // main panel's active view).
 4981    sidebar.read_with(cx, |s, _| {
 4982        assert_active_thread(
 4983            s,
 4984            &thread2_session_id,
 4985            "focused thread should be Thread 2 before archiving",
 4986        );
 4987    });
 4988
 4989    // Archive thread 2.
 4990    sidebar.update_in(cx, |sidebar, window, cx| {
 4991        sidebar.archive_thread(&thread2_session_id, window, cx);
 4992    });
 4993
 4994    cx.run_until_parked();
 4995
 4996    // The main panel's active thread must still be thread 2.
 4997    let main_active = main_panel.read_with(cx, |panel, cx| {
 4998        panel
 4999            .active_agent_thread(cx)
 5000            .map(|t| t.read(cx).session_id().clone())
 5001    });
 5002    assert_eq!(
 5003        main_active,
 5004        Some(thread2_session_id.clone()),
 5005        "main panel should not have been taken over by loading the linked-worktree thread T1; \
 5006             before the fix, archive_thread used group_workspace instead of next.workspace, \
 5007             causing T1 to be loaded in the wrong panel"
 5008    );
 5009
 5010    // Thread 1 should still appear in the sidebar with its worktree chip
 5011    // (Thread 2 was archived so it is gone from the list).
 5012    let entries_after = visible_entries_as_strings(&sidebar, cx);
 5013    assert!(
 5014        entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
 5015        "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
 5016        entries_after
 5017    );
 5018}
 5019
 5020#[gpui::test]
 5021async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
 5022    // When the last non-archived thread for a linked worktree is archived,
 5023    // the linked worktree workspace should be removed from the multi-workspace.
 5024    // The main worktree workspace should remain (it's always reachable via
 5025    // the project header).
 5026    init_test(cx);
 5027    let fs = FakeFs::new(cx.executor());
 5028
 5029    fs.insert_tree(
 5030        "/project",
 5031        serde_json::json!({
 5032            ".git": {
 5033                "worktrees": {
 5034                    "feature-a": {
 5035                        "commondir": "../../",
 5036                        "HEAD": "ref: refs/heads/feature-a",
 5037                    },
 5038                },
 5039            },
 5040            "src": {},
 5041        }),
 5042    )
 5043    .await;
 5044
 5045    fs.insert_tree(
 5046        "/worktrees/project/feature-a/project",
 5047        serde_json::json!({
 5048            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5049            "src": {},
 5050        }),
 5051    )
 5052    .await;
 5053
 5054    fs.add_linked_worktree_for_repo(
 5055        Path::new("/project/.git"),
 5056        false,
 5057        git::repository::Worktree {
 5058            path: PathBuf::from("/worktrees/project/feature-a/project"),
 5059            ref_name: Some("refs/heads/feature-a".into()),
 5060            sha: "abc".into(),
 5061            is_main: false,
 5062            is_bare: false,
 5063        },
 5064    )
 5065    .await;
 5066
 5067    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5068
 5069    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5070    let worktree_project = project::Project::test(
 5071        fs.clone(),
 5072        ["/worktrees/project/feature-a/project".as_ref()],
 5073        cx,
 5074    )
 5075    .await;
 5076
 5077    main_project
 5078        .update(cx, |p, cx| p.git_scans_complete(cx))
 5079        .await;
 5080    worktree_project
 5081        .update(cx, |p, cx| p.git_scans_complete(cx))
 5082        .await;
 5083
 5084    let (multi_workspace, cx) =
 5085        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5086    let sidebar = setup_sidebar(&multi_workspace, cx);
 5087
 5088    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5089        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5090    });
 5091
 5092    // Save a thread for the main project.
 5093    save_thread_metadata(
 5094        acp::SessionId::new(Arc::from("main-thread")),
 5095        Some("Main Thread".into()),
 5096        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5097        None,
 5098        None,
 5099        &main_project,
 5100        cx,
 5101    );
 5102
 5103    // Save a thread for the linked worktree.
 5104    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 5105    save_thread_metadata(
 5106        wt_thread_id.clone(),
 5107        Some("Worktree Thread".into()),
 5108        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5109        None,
 5110        None,
 5111        &worktree_project,
 5112        cx,
 5113    );
 5114    cx.run_until_parked();
 5115
 5116    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5117    cx.run_until_parked();
 5118
 5119    // Should have 2 workspaces.
 5120    assert_eq!(
 5121        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5122        2,
 5123        "should start with 2 workspaces (main + linked worktree)"
 5124    );
 5125
 5126    // Archive the worktree thread (the only thread for /wt-feature-a).
 5127    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 5128        sidebar.archive_thread(&wt_thread_id, window, cx);
 5129    });
 5130
 5131    // archive_thread spawns a multi-layered chain of tasks (workspace
 5132    // removal → git persist → disk removal), each of which may spawn
 5133    // further background work. Each run_until_parked() call drives one
 5134    // layer of pending work.
 5135
 5136    cx.run_until_parked();
 5137
 5138    // The linked worktree workspace should have been removed.
 5139    assert_eq!(
 5140        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5141        1,
 5142        "linked worktree workspace should be removed after archiving its last thread"
 5143    );
 5144
 5145    // The linked worktree checkout directory should also be removed from disk.
 5146    assert!(
 5147        !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
 5148            .await,
 5149        "linked worktree directory should be removed from disk after archiving its last thread"
 5150    );
 5151
 5152    // The main thread should still be visible.
 5153    let entries = visible_entries_as_strings(&sidebar, cx);
 5154    assert!(
 5155        entries.iter().any(|e| e.contains("Main Thread")),
 5156        "main thread should still be visible: {entries:?}"
 5157    );
 5158    assert!(
 5159        !entries.iter().any(|e| e.contains("Worktree Thread")),
 5160        "archived worktree thread should not be visible: {entries:?}"
 5161    );
 5162
 5163    // The archived thread must retain its folder_paths so it can be
 5164    // restored to the correct workspace later.
 5165    let wt_thread_id = cx.update(|_window, cx| {
 5166        ThreadMetadataStore::global(cx)
 5167            .read(cx)
 5168            .entry_by_session(&wt_thread_id)
 5169            .unwrap()
 5170            .thread_id
 5171    });
 5172    let archived_paths = cx.update(|_window, cx| {
 5173        ThreadMetadataStore::global(cx)
 5174            .read(cx)
 5175            .entry(wt_thread_id)
 5176            .unwrap()
 5177            .folder_paths()
 5178            .clone()
 5179    });
 5180    assert_eq!(
 5181        archived_paths.paths(),
 5182        &[PathBuf::from("/worktrees/project/feature-a/project")],
 5183        "archived thread must retain its folder_paths for restore"
 5184    );
 5185}
 5186
 5187#[gpui::test]
 5188async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
 5189    // restore_worktree_via_git should succeed when the branch has moved
 5190    // to a different SHA since archival. The worktree stays in detached
 5191    // HEAD and the moved branch is left untouched.
 5192    init_test(cx);
 5193    let fs = FakeFs::new(cx.executor());
 5194
 5195    fs.insert_tree(
 5196        "/project",
 5197        serde_json::json!({
 5198            ".git": {
 5199                "worktrees": {
 5200                    "feature-a": {
 5201                        "commondir": "../../",
 5202                        "HEAD": "ref: refs/heads/feature-a",
 5203                    },
 5204                },
 5205            },
 5206            "src": {},
 5207        }),
 5208    )
 5209    .await;
 5210    fs.insert_tree(
 5211        "/wt-feature-a",
 5212        serde_json::json!({
 5213            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5214            "src": {},
 5215        }),
 5216    )
 5217    .await;
 5218    fs.add_linked_worktree_for_repo(
 5219        Path::new("/project/.git"),
 5220        false,
 5221        git::repository::Worktree {
 5222            path: PathBuf::from("/wt-feature-a"),
 5223            ref_name: Some("refs/heads/feature-a".into()),
 5224            sha: "original-sha".into(),
 5225            is_main: false,
 5226            is_bare: false,
 5227        },
 5228    )
 5229    .await;
 5230    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5231
 5232    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5233    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5234    main_project
 5235        .update(cx, |p, cx| p.git_scans_complete(cx))
 5236        .await;
 5237    worktree_project
 5238        .update(cx, |p, cx| p.git_scans_complete(cx))
 5239        .await;
 5240
 5241    let (multi_workspace, _cx) =
 5242        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5243    multi_workspace.update_in(_cx, |mw, window, cx| {
 5244        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5245    });
 5246
 5247    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5248        project.repositories(cx).values().next().unwrap().clone()
 5249    });
 5250    let (staged_hash, unstaged_hash) = cx
 5251        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5252        .await
 5253        .unwrap()
 5254        .unwrap();
 5255
 5256    // Move the branch to a different SHA.
 5257    fs.with_git_state(Path::new("/project/.git"), false, |state| {
 5258        state
 5259            .refs
 5260            .insert("refs/heads/feature-a".into(), "moved-sha".into());
 5261    })
 5262    .unwrap();
 5263
 5264    let result = cx
 5265        .spawn(|mut cx| async move {
 5266            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5267                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5268                    id: 1,
 5269                    worktree_path: PathBuf::from("/wt-feature-a"),
 5270                    main_repo_path: PathBuf::from("/project"),
 5271                    branch_name: Some("feature-a".to_string()),
 5272                    staged_commit_hash: staged_hash,
 5273                    unstaged_commit_hash: unstaged_hash,
 5274                    original_commit_hash: "original-sha".to_string(),
 5275                },
 5276                None,
 5277                &mut cx,
 5278            )
 5279            .await
 5280        })
 5281        .await;
 5282
 5283    assert!(
 5284        result.is_ok(),
 5285        "restore should succeed even when branch has moved: {:?}",
 5286        result.err()
 5287    );
 5288
 5289    // The moved branch ref should be completely untouched.
 5290    let branch_sha = fs
 5291        .with_git_state(Path::new("/project/.git"), false, |state| {
 5292            state.refs.get("refs/heads/feature-a").cloned()
 5293        })
 5294        .unwrap();
 5295    assert_eq!(
 5296        branch_sha.as_deref(),
 5297        Some("moved-sha"),
 5298        "the moved branch ref should not be modified by the restore"
 5299    );
 5300}
 5301
 5302#[gpui::test]
 5303async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext) {
 5304    // restore_worktree_via_git should succeed when the branch still
 5305    // points at the same SHA as at archive time.
 5306    init_test(cx);
 5307    let fs = FakeFs::new(cx.executor());
 5308
 5309    fs.insert_tree(
 5310        "/project",
 5311        serde_json::json!({
 5312            ".git": {
 5313                "worktrees": {
 5314                    "feature-b": {
 5315                        "commondir": "../../",
 5316                        "HEAD": "ref: refs/heads/feature-b",
 5317                    },
 5318                },
 5319            },
 5320            "src": {},
 5321        }),
 5322    )
 5323    .await;
 5324    fs.insert_tree(
 5325        "/wt-feature-b",
 5326        serde_json::json!({
 5327            ".git": "gitdir: /project/.git/worktrees/feature-b",
 5328            "src": {},
 5329        }),
 5330    )
 5331    .await;
 5332    fs.add_linked_worktree_for_repo(
 5333        Path::new("/project/.git"),
 5334        false,
 5335        git::repository::Worktree {
 5336            path: PathBuf::from("/wt-feature-b"),
 5337            ref_name: Some("refs/heads/feature-b".into()),
 5338            sha: "original-sha".into(),
 5339            is_main: false,
 5340            is_bare: false,
 5341        },
 5342    )
 5343    .await;
 5344    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5345
 5346    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5347    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
 5348    main_project
 5349        .update(cx, |p, cx| p.git_scans_complete(cx))
 5350        .await;
 5351    worktree_project
 5352        .update(cx, |p, cx| p.git_scans_complete(cx))
 5353        .await;
 5354
 5355    let (multi_workspace, _cx) =
 5356        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5357    multi_workspace.update_in(_cx, |mw, window, cx| {
 5358        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5359    });
 5360
 5361    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5362        project.repositories(cx).values().next().unwrap().clone()
 5363    });
 5364    let (staged_hash, unstaged_hash) = cx
 5365        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5366        .await
 5367        .unwrap()
 5368        .unwrap();
 5369
 5370    // refs/heads/feature-b already points at "original-sha" (set by
 5371    // add_linked_worktree_for_repo), matching original_commit_hash.
 5372
 5373    let result = cx
 5374        .spawn(|mut cx| async move {
 5375            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5376                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5377                    id: 1,
 5378                    worktree_path: PathBuf::from("/wt-feature-b"),
 5379                    main_repo_path: PathBuf::from("/project"),
 5380                    branch_name: Some("feature-b".to_string()),
 5381                    staged_commit_hash: staged_hash,
 5382                    unstaged_commit_hash: unstaged_hash,
 5383                    original_commit_hash: "original-sha".to_string(),
 5384                },
 5385                None,
 5386                &mut cx,
 5387            )
 5388            .await
 5389        })
 5390        .await;
 5391
 5392    assert!(
 5393        result.is_ok(),
 5394        "restore should succeed when branch has not moved: {:?}",
 5395        result.err()
 5396    );
 5397}
 5398
 5399#[gpui::test]
 5400async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContext) {
 5401    // restore_worktree_via_git should succeed when the branch no longer
 5402    // exists (e.g. it was deleted while the thread was archived). The
 5403    // code should attempt to recreate the branch.
 5404    init_test(cx);
 5405    let fs = FakeFs::new(cx.executor());
 5406
 5407    fs.insert_tree(
 5408        "/project",
 5409        serde_json::json!({
 5410            ".git": {
 5411                "worktrees": {
 5412                    "feature-d": {
 5413                        "commondir": "../../",
 5414                        "HEAD": "ref: refs/heads/feature-d",
 5415                    },
 5416                },
 5417            },
 5418            "src": {},
 5419        }),
 5420    )
 5421    .await;
 5422    fs.insert_tree(
 5423        "/wt-feature-d",
 5424        serde_json::json!({
 5425            ".git": "gitdir: /project/.git/worktrees/feature-d",
 5426            "src": {},
 5427        }),
 5428    )
 5429    .await;
 5430    fs.add_linked_worktree_for_repo(
 5431        Path::new("/project/.git"),
 5432        false,
 5433        git::repository::Worktree {
 5434            path: PathBuf::from("/wt-feature-d"),
 5435            ref_name: Some("refs/heads/feature-d".into()),
 5436            sha: "original-sha".into(),
 5437            is_main: false,
 5438            is_bare: false,
 5439        },
 5440    )
 5441    .await;
 5442    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5443
 5444    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5445    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-d".as_ref()], cx).await;
 5446    main_project
 5447        .update(cx, |p, cx| p.git_scans_complete(cx))
 5448        .await;
 5449    worktree_project
 5450        .update(cx, |p, cx| p.git_scans_complete(cx))
 5451        .await;
 5452
 5453    let (multi_workspace, _cx) =
 5454        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5455    multi_workspace.update_in(_cx, |mw, window, cx| {
 5456        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5457    });
 5458
 5459    let wt_repo = worktree_project.read_with(cx, |project, cx| {
 5460        project.repositories(cx).values().next().unwrap().clone()
 5461    });
 5462    let (staged_hash, unstaged_hash) = cx
 5463        .update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
 5464        .await
 5465        .unwrap()
 5466        .unwrap();
 5467
 5468    // Remove the branch ref so change_branch will fail.
 5469    fs.with_git_state(Path::new("/project/.git"), false, |state| {
 5470        state.refs.remove("refs/heads/feature-d");
 5471    })
 5472    .unwrap();
 5473
 5474    let result = cx
 5475        .spawn(|mut cx| async move {
 5476            agent_ui::thread_worktree_archive::restore_worktree_via_git(
 5477                &agent_ui::thread_metadata_store::ArchivedGitWorktree {
 5478                    id: 1,
 5479                    worktree_path: PathBuf::from("/wt-feature-d"),
 5480                    main_repo_path: PathBuf::from("/project"),
 5481                    branch_name: Some("feature-d".to_string()),
 5482                    staged_commit_hash: staged_hash,
 5483                    unstaged_commit_hash: unstaged_hash,
 5484                    original_commit_hash: "original-sha".to_string(),
 5485                },
 5486                None,
 5487                &mut cx,
 5488            )
 5489            .await
 5490        })
 5491        .await;
 5492
 5493    assert!(
 5494        result.is_ok(),
 5495        "restore should succeed when branch does not exist: {:?}",
 5496        result.err()
 5497    );
 5498}
 5499
 5500#[gpui::test]
 5501async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
 5502    // Activating an archived linked worktree thread whose directory has
 5503    // been deleted should reuse the existing main repo workspace, not
 5504    // create a new one. The provisional ProjectGroupKey must be derived
 5505    // from main_worktree_paths so that find_or_create_local_workspace
 5506    // matches the main repo workspace when the worktree path is absent.
 5507    init_test(cx);
 5508    let fs = FakeFs::new(cx.executor());
 5509
 5510    fs.insert_tree(
 5511        "/project",
 5512        serde_json::json!({
 5513            ".git": {
 5514                "worktrees": {
 5515                    "feature-c": {
 5516                        "commondir": "../../",
 5517                        "HEAD": "ref: refs/heads/feature-c",
 5518                    },
 5519                },
 5520            },
 5521            "src": {},
 5522        }),
 5523    )
 5524    .await;
 5525
 5526    fs.insert_tree(
 5527        "/wt-feature-c",
 5528        serde_json::json!({
 5529            ".git": "gitdir: /project/.git/worktrees/feature-c",
 5530            "src": {},
 5531        }),
 5532    )
 5533    .await;
 5534
 5535    fs.add_linked_worktree_for_repo(
 5536        Path::new("/project/.git"),
 5537        false,
 5538        git::repository::Worktree {
 5539            path: PathBuf::from("/wt-feature-c"),
 5540            ref_name: Some("refs/heads/feature-c".into()),
 5541            sha: "original-sha".into(),
 5542            is_main: false,
 5543            is_bare: false,
 5544        },
 5545    )
 5546    .await;
 5547
 5548    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5549
 5550    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5551    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-c".as_ref()], cx).await;
 5552
 5553    main_project
 5554        .update(cx, |p, cx| p.git_scans_complete(cx))
 5555        .await;
 5556    worktree_project
 5557        .update(cx, |p, cx| p.git_scans_complete(cx))
 5558        .await;
 5559
 5560    let (multi_workspace, cx) =
 5561        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5562    let sidebar = setup_sidebar(&multi_workspace, cx);
 5563
 5564    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5565        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5566    });
 5567
 5568    // Save thread metadata for the linked worktree.
 5569    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread-c"));
 5570    save_thread_metadata(
 5571        wt_session_id.clone(),
 5572        Some("Worktree Thread C".into()),
 5573        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5574        None,
 5575        None,
 5576        &worktree_project,
 5577        cx,
 5578    );
 5579    cx.run_until_parked();
 5580
 5581    let thread_id = cx.update(|_window, cx| {
 5582        ThreadMetadataStore::global(cx)
 5583            .read(cx)
 5584            .entry_by_session(&wt_session_id)
 5585            .unwrap()
 5586            .thread_id
 5587    });
 5588
 5589    // Archive the thread without creating ArchivedGitWorktree records.
 5590    let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx));
 5591    cx.update(|_window, cx| {
 5592        store.update(cx, |store, cx| store.archive(thread_id, None, cx));
 5593    });
 5594    cx.run_until_parked();
 5595
 5596    // Remove the worktree workspace and delete the worktree from disk.
 5597    let main_workspace =
 5598        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 5599    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 5600        mw.remove(
 5601            vec![worktree_workspace],
 5602            move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
 5603            window,
 5604            cx,
 5605        )
 5606    });
 5607    remove_task.await.ok();
 5608    cx.run_until_parked();
 5609    cx.run_until_parked();
 5610    fs.remove_dir(
 5611        Path::new("/wt-feature-c"),
 5612        fs::RemoveOptions {
 5613            recursive: true,
 5614            ignore_if_not_exists: true,
 5615        },
 5616    )
 5617    .await
 5618    .unwrap();
 5619
 5620    let workspace_count_before = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 5621    assert_eq!(
 5622        workspace_count_before, 1,
 5623        "should have only the main workspace"
 5624    );
 5625
 5626    // Activate the archived thread. The worktree path is missing from
 5627    // disk, so find_or_create_local_workspace falls back to the
 5628    // provisional ProjectGroupKey to find a matching workspace.
 5629    let metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).unwrap().clone());
 5630    sidebar.update_in(cx, |sidebar, window, cx| {
 5631        sidebar.open_thread_from_archive(metadata, window, cx);
 5632    });
 5633    cx.run_until_parked();
 5634
 5635    // The provisional key should use [/project] (the main repo),
 5636    // which matches the existing main workspace. If it incorrectly
 5637    // used [/wt-feature-c] (the linked worktree path), no workspace
 5638    // would match and a spurious new one would be created.
 5639    let workspace_count_after = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 5640    assert_eq!(
 5641        workspace_count_after, 1,
 5642        "restoring a linked worktree thread should reuse the main repo workspace, \
 5643         not create a new one (workspace count went from {workspace_count_before} to \
 5644         {workspace_count_after})"
 5645    );
 5646}
 5647
 5648#[gpui::test]
 5649async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_path(
 5650    cx: &mut TestAppContext,
 5651) {
 5652    // A remote thread at the same path as a local linked worktree thread
 5653    // should not prevent the local workspace from being removed when the
 5654    // local thread is archived (the last local thread for that worktree).
 5655    init_test(cx);
 5656    let fs = FakeFs::new(cx.executor());
 5657
 5658    fs.insert_tree(
 5659        "/project",
 5660        serde_json::json!({
 5661            ".git": {
 5662                "worktrees": {
 5663                    "feature-a": {
 5664                        "commondir": "../../",
 5665                        "HEAD": "ref: refs/heads/feature-a",
 5666                    },
 5667                },
 5668            },
 5669            "src": {},
 5670        }),
 5671    )
 5672    .await;
 5673
 5674    fs.insert_tree(
 5675        "/wt-feature-a",
 5676        serde_json::json!({
 5677            ".git": "gitdir: /project/.git/worktrees/feature-a",
 5678            "src": {},
 5679        }),
 5680    )
 5681    .await;
 5682
 5683    fs.add_linked_worktree_for_repo(
 5684        Path::new("/project/.git"),
 5685        false,
 5686        git::repository::Worktree {
 5687            path: PathBuf::from("/wt-feature-a"),
 5688            ref_name: Some("refs/heads/feature-a".into()),
 5689            sha: "abc".into(),
 5690            is_main: false,
 5691            is_bare: false,
 5692        },
 5693    )
 5694    .await;
 5695
 5696    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5697
 5698    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5699    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5700
 5701    main_project
 5702        .update(cx, |p, cx| p.git_scans_complete(cx))
 5703        .await;
 5704    worktree_project
 5705        .update(cx, |p, cx| p.git_scans_complete(cx))
 5706        .await;
 5707
 5708    let (multi_workspace, cx) =
 5709        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 5710    let sidebar = setup_sidebar(&multi_workspace, cx);
 5711
 5712    let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5713        mw.test_add_workspace(worktree_project.clone(), window, cx)
 5714    });
 5715
 5716    // Save a thread for the main project.
 5717    save_thread_metadata(
 5718        acp::SessionId::new(Arc::from("main-thread")),
 5719        Some("Main Thread".into()),
 5720        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 5721        None,
 5722        None,
 5723        &main_project,
 5724        cx,
 5725    );
 5726
 5727    // Save a local thread for the linked worktree.
 5728    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
 5729    save_thread_metadata(
 5730        wt_thread_id.clone(),
 5731        Some("Local Worktree Thread".into()),
 5732        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5733        None,
 5734        None,
 5735        &worktree_project,
 5736        cx,
 5737    );
 5738
 5739    // Save a remote thread at the same /wt-feature-a path but on a
 5740    // different host. This should NOT count as a remaining thread for
 5741    // the local linked worktree workspace.
 5742    let remote_host =
 5743        remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
 5744    cx.update(|_window, cx| {
 5745        let metadata = ThreadMetadata {
 5746            thread_id: ThreadId::new(),
 5747            session_id: Some(acp::SessionId::new(Arc::from("remote-wt-thread"))),
 5748            agent_id: agent::ZED_AGENT_ID.clone(),
 5749            title: Some("Remote Worktree Thread".into()),
 5750            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5751            created_at: None,
 5752            interacted_at: None,
 5753            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 5754                "/wt-feature-a",
 5755            )])),
 5756            archived: false,
 5757            remote_connection: Some(remote_host),
 5758        };
 5759        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 5760            store.save(metadata, cx);
 5761        });
 5762    });
 5763    cx.run_until_parked();
 5764
 5765    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 5766    cx.run_until_parked();
 5767
 5768    assert_eq!(
 5769        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5770        2,
 5771        "should start with 2 workspaces (main + linked worktree)"
 5772    );
 5773
 5774    // The remote thread should NOT appear in the sidebar (it belongs
 5775    // to a different host and no matching remote project group exists).
 5776    let entries_before = visible_entries_as_strings(&sidebar, cx);
 5777    assert!(
 5778        !entries_before
 5779            .iter()
 5780            .any(|e| e.contains("Remote Worktree Thread")),
 5781        "remote thread should not appear in local sidebar: {entries_before:?}"
 5782    );
 5783
 5784    // Archive the local worktree thread.
 5785    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 5786        sidebar.archive_thread(&wt_thread_id, window, cx);
 5787    });
 5788
 5789    cx.run_until_parked();
 5790
 5791    // The linked worktree workspace should be removed because the
 5792    // only *local* thread for it was archived. The remote thread at
 5793    // the same path should not have prevented removal.
 5794    assert_eq!(
 5795        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 5796        1,
 5797        "linked worktree workspace should be removed; the remote thread at the same path \
 5798         should not count as a remaining local thread"
 5799    );
 5800
 5801    let entries = visible_entries_as_strings(&sidebar, cx);
 5802    assert!(
 5803        entries.iter().any(|e| e.contains("Main Thread")),
 5804        "main thread should still be visible: {entries:?}"
 5805    );
 5806    assert!(
 5807        !entries.iter().any(|e| e.contains("Local Worktree Thread")),
 5808        "archived local worktree thread should not be visible: {entries:?}"
 5809    );
 5810    assert!(
 5811        !entries.iter().any(|e| e.contains("Remote Worktree Thread")),
 5812        "remote thread should still not appear in local sidebar: {entries:?}"
 5813    );
 5814}
 5815
 5816#[gpui::test]
 5817async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
 5818    // When a multi-root workspace (e.g. [/other, /project]) shares a
 5819    // repo with a single-root workspace (e.g. [/project]), linked
 5820    // worktree threads from the shared repo should only appear under
 5821    // the dedicated group [project], not under [other, project].
 5822    agent_ui::test_support::init_test(cx);
 5823    cx.update(|cx| {
 5824        ThreadStore::init_global(cx);
 5825        ThreadMetadataStore::init_global(cx);
 5826        language_model::LanguageModelRegistry::test(cx);
 5827        prompt_store::init(cx);
 5828    });
 5829    let fs = FakeFs::new(cx.executor());
 5830
 5831    // Two independent repos, each with their own git history.
 5832    fs.insert_tree(
 5833        "/project",
 5834        serde_json::json!({
 5835            ".git": {},
 5836            "src": {},
 5837        }),
 5838    )
 5839    .await;
 5840    fs.insert_tree(
 5841        "/other",
 5842        serde_json::json!({
 5843            ".git": {},
 5844            "src": {},
 5845        }),
 5846    )
 5847    .await;
 5848
 5849    // Register the linked worktree in the main repo.
 5850    fs.add_linked_worktree_for_repo(
 5851        Path::new("/project/.git"),
 5852        false,
 5853        git::repository::Worktree {
 5854            path: std::path::PathBuf::from("/wt-feature-a"),
 5855            ref_name: Some("refs/heads/feature-a".into()),
 5856            sha: "aaa".into(),
 5857            is_main: false,
 5858            is_bare: false,
 5859        },
 5860    )
 5861    .await;
 5862
 5863    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 5864
 5865    // Workspace 1: just /project.
 5866    let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 5867    project_only
 5868        .update(cx, |p, cx| p.git_scans_complete(cx))
 5869        .await;
 5870
 5871    // Workspace 2: /other and /project together (multi-root).
 5872    let multi_root =
 5873        project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
 5874    multi_root
 5875        .update(cx, |p, cx| p.git_scans_complete(cx))
 5876        .await;
 5877
 5878    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 5879    worktree_project
 5880        .update(cx, |p, cx| p.git_scans_complete(cx))
 5881        .await;
 5882
 5883    // Save a thread under the linked worktree path BEFORE setting up
 5884    // the sidebar and panels, so that reconciliation sees the [project]
 5885    // group as non-empty and doesn't create a spurious draft there.
 5886    let wt_session_id = acp::SessionId::new(Arc::from("wt-thread"));
 5887    save_thread_metadata(
 5888        wt_session_id,
 5889        Some("Worktree Thread".into()),
 5890        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5891        None,
 5892        None,
 5893        &worktree_project,
 5894        cx,
 5895    );
 5896
 5897    let (multi_workspace, cx) =
 5898        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx));
 5899    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5900    let multi_root_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 5901        mw.test_add_workspace(multi_root.clone(), window, cx)
 5902    });
 5903    add_agent_panel(&multi_root_workspace, cx);
 5904    cx.run_until_parked();
 5905
 5906    // The thread should appear only under [project] (the dedicated
 5907    // group for the /project repo), not under [other, project].
 5908    assert_eq!(
 5909        visible_entries_as_strings(&sidebar, cx),
 5910        vec![
 5911            //
 5912            "v [other, project]",
 5913            "v [project]",
 5914            "  Worktree Thread {wt-feature-a}",
 5915        ]
 5916    );
 5917}
 5918
 5919fn thread_id_for(session_id: &acp::SessionId, cx: &mut TestAppContext) -> ThreadId {
 5920    cx.read(|cx| {
 5921        ThreadMetadataStore::global(cx)
 5922            .read(cx)
 5923            .entry_by_session(session_id)
 5924            .map(|m| m.thread_id)
 5925            .expect("thread metadata should exist")
 5926    })
 5927}
 5928
 5929#[gpui::test]
 5930async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
 5931    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 5932    let (multi_workspace, cx) =
 5933        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 5934    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 5935
 5936    let switcher_ids =
 5937        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> Vec<ThreadId> {
 5938            sidebar.read_with(cx, |sidebar, cx| {
 5939                let switcher = sidebar
 5940                    .thread_switcher
 5941                    .as_ref()
 5942                    .expect("switcher should be open");
 5943                switcher
 5944                    .read(cx)
 5945                    .entries()
 5946                    .iter()
 5947                    .map(|e| e.metadata.thread_id)
 5948                    .collect()
 5949            })
 5950        };
 5951
 5952    let switcher_selected_id =
 5953        |sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext| -> ThreadId {
 5954            sidebar.read_with(cx, |sidebar, cx| {
 5955                let switcher = sidebar
 5956                    .thread_switcher
 5957                    .as_ref()
 5958                    .expect("switcher should be open");
 5959                let s = switcher.read(cx);
 5960                s.selected_entry()
 5961                    .expect("should have selection")
 5962                    .metadata
 5963                    .thread_id
 5964            })
 5965        };
 5966
 5967    // ── Setup: create three threads with distinct created_at times ──────
 5968    // Thread C (oldest), Thread B, Thread A (newest) — by created_at.
 5969    // We send messages in each so they also get last_message_sent_or_queued timestamps.
 5970    let connection_c = StubAgentConnection::new();
 5971    connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5972        acp::ContentChunk::new("Done C".into()),
 5973    )]);
 5974    open_thread_with_connection(&panel, connection_c, cx);
 5975    send_message(&panel, cx);
 5976    let session_id_c = active_session_id(&panel, cx);
 5977    let thread_id_c = active_thread_id(&panel, cx);
 5978    save_thread_metadata(
 5979        session_id_c.clone(),
 5980        Some("Thread C".into()),
 5981        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 5982        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap()),
 5983        None,
 5984        &project,
 5985        cx,
 5986    );
 5987
 5988    let connection_b = StubAgentConnection::new();
 5989    connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 5990        acp::ContentChunk::new("Done B".into()),
 5991    )]);
 5992    open_thread_with_connection(&panel, connection_b, cx);
 5993    send_message(&panel, cx);
 5994    let session_id_b = active_session_id(&panel, cx);
 5995    let thread_id_b = active_thread_id(&panel, cx);
 5996    save_thread_metadata(
 5997        session_id_b.clone(),
 5998        Some("Thread B".into()),
 5999        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 6000        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap()),
 6001        None,
 6002        &project,
 6003        cx,
 6004    );
 6005
 6006    let connection_a = StubAgentConnection::new();
 6007    connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6008        acp::ContentChunk::new("Done A".into()),
 6009    )]);
 6010    open_thread_with_connection(&panel, connection_a, cx);
 6011    send_message(&panel, cx);
 6012    let session_id_a = active_session_id(&panel, cx);
 6013    let thread_id_a = active_thread_id(&panel, cx);
 6014    save_thread_metadata(
 6015        session_id_a.clone(),
 6016        Some("Thread A".into()),
 6017        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
 6018        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap()),
 6019        None,
 6020        &project,
 6021        cx,
 6022    );
 6023
 6024    // All three threads are now live. Thread A was opened last, so it's
 6025    // the one being viewed. Opening each thread called record_thread_access,
 6026    // so all three have last_accessed_at set.
 6027    // Access order is: A (most recent), B, C (oldest).
 6028
 6029    // ── 1. Open switcher: threads sorted by last_accessed_at ─────────────────
 6030    focus_sidebar(&sidebar, cx);
 6031    sidebar.update_in(cx, |sidebar, window, cx| {
 6032        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6033    });
 6034    cx.run_until_parked();
 6035
 6036    // All three have last_accessed_at, so they sort by access time.
 6037    // A was accessed most recently (it's the currently viewed thread),
 6038    // then B, then C.
 6039    assert_eq!(
 6040        switcher_ids(&sidebar, cx),
 6041        vec![thread_id_a, thread_id_b, thread_id_c,],
 6042    );
 6043    // First ctrl-tab selects the second entry (B).
 6044    assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_b);
 6045
 6046    // Dismiss the switcher without confirming.
 6047    sidebar.update_in(cx, |sidebar, _window, cx| {
 6048        sidebar.dismiss_thread_switcher(cx);
 6049    });
 6050    cx.run_until_parked();
 6051
 6052    // ── 2. Confirm on Thread C: it becomes most-recently-accessed ──────
 6053    sidebar.update_in(cx, |sidebar, window, cx| {
 6054        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6055    });
 6056    cx.run_until_parked();
 6057
 6058    // Cycle twice to land on Thread C (index 2).
 6059    sidebar.read_with(cx, |sidebar, cx| {
 6060        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6061        assert_eq!(switcher.read(cx).selected_index(), 1);
 6062    });
 6063    sidebar.update_in(cx, |sidebar, _window, cx| {
 6064        sidebar
 6065            .thread_switcher
 6066            .as_ref()
 6067            .unwrap()
 6068            .update(cx, |s, cx| s.cycle_selection(cx));
 6069    });
 6070    cx.run_until_parked();
 6071    assert_eq!(switcher_selected_id(&sidebar, cx), thread_id_c);
 6072
 6073    assert!(sidebar.update(cx, |sidebar, _cx| sidebar.thread_last_accessed.is_empty()));
 6074
 6075    // Confirm on Thread C.
 6076    sidebar.update_in(cx, |sidebar, window, cx| {
 6077        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6078        let focus = switcher.focus_handle(cx);
 6079        focus.dispatch_action(&menu::Confirm, window, cx);
 6080    });
 6081    cx.run_until_parked();
 6082
 6083    // Switcher should be dismissed after confirm.
 6084    sidebar.read_with(cx, |sidebar, _cx| {
 6085        assert!(
 6086            sidebar.thread_switcher.is_none(),
 6087            "switcher should be dismissed"
 6088        );
 6089    });
 6090
 6091    sidebar.update(cx, |sidebar, _cx| {
 6092        let last_accessed = sidebar
 6093            .thread_last_accessed
 6094            .keys()
 6095            .cloned()
 6096            .collect::<Vec<_>>();
 6097        assert_eq!(last_accessed.len(), 1);
 6098        assert!(last_accessed.contains(&thread_id_c));
 6099        assert!(
 6100            is_active_session(&sidebar, &session_id_c),
 6101            "active_entry should be Thread({session_id_c:?})"
 6102        );
 6103    });
 6104
 6105    sidebar.update_in(cx, |sidebar, window, cx| {
 6106        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6107    });
 6108    cx.run_until_parked();
 6109
 6110    assert_eq!(
 6111        switcher_ids(&sidebar, cx),
 6112        vec![thread_id_c, thread_id_a, thread_id_b],
 6113    );
 6114
 6115    // Confirm on Thread A.
 6116    sidebar.update_in(cx, |sidebar, window, cx| {
 6117        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6118        let focus = switcher.focus_handle(cx);
 6119        focus.dispatch_action(&menu::Confirm, window, cx);
 6120    });
 6121    cx.run_until_parked();
 6122
 6123    sidebar.update(cx, |sidebar, _cx| {
 6124        let last_accessed = sidebar
 6125            .thread_last_accessed
 6126            .keys()
 6127            .cloned()
 6128            .collect::<Vec<_>>();
 6129        assert_eq!(last_accessed.len(), 2);
 6130        assert!(last_accessed.contains(&thread_id_c));
 6131        assert!(last_accessed.contains(&thread_id_a));
 6132        assert!(
 6133            is_active_session(&sidebar, &session_id_a),
 6134            "active_entry should be Thread({session_id_a:?})"
 6135        );
 6136    });
 6137
 6138    sidebar.update_in(cx, |sidebar, window, cx| {
 6139        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6140    });
 6141    cx.run_until_parked();
 6142
 6143    assert_eq!(
 6144        switcher_ids(&sidebar, cx),
 6145        vec![thread_id_a, thread_id_c, thread_id_b,],
 6146    );
 6147
 6148    sidebar.update_in(cx, |sidebar, _window, cx| {
 6149        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6150        switcher.update(cx, |switcher, cx| switcher.cycle_selection(cx));
 6151    });
 6152    cx.run_until_parked();
 6153
 6154    // Confirm on Thread B.
 6155    sidebar.update_in(cx, |sidebar, window, cx| {
 6156        let switcher = sidebar.thread_switcher.as_ref().unwrap();
 6157        let focus = switcher.focus_handle(cx);
 6158        focus.dispatch_action(&menu::Confirm, window, cx);
 6159    });
 6160    cx.run_until_parked();
 6161
 6162    sidebar.update(cx, |sidebar, _cx| {
 6163        let last_accessed = sidebar
 6164            .thread_last_accessed
 6165            .keys()
 6166            .cloned()
 6167            .collect::<Vec<_>>();
 6168        assert_eq!(last_accessed.len(), 3);
 6169        assert!(last_accessed.contains(&thread_id_c));
 6170        assert!(last_accessed.contains(&thread_id_a));
 6171        assert!(last_accessed.contains(&thread_id_b));
 6172        assert!(
 6173            is_active_session(&sidebar, &session_id_b),
 6174            "active_entry should be Thread({session_id_b:?})"
 6175        );
 6176    });
 6177
 6178    // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
 6179    // This thread was never opened in a panel — it only exists in metadata.
 6180    save_thread_metadata(
 6181        acp::SessionId::new(Arc::from("thread-historical")),
 6182        Some("Historical Thread".into()),
 6183        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
 6184        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap()),
 6185        None,
 6186        &project,
 6187        cx,
 6188    );
 6189
 6190    sidebar.update_in(cx, |sidebar, window, cx| {
 6191        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6192    });
 6193    cx.run_until_parked();
 6194
 6195    // Historical Thread has no last_accessed_at and no last_message_sent_or_queued,
 6196    // so it falls to tier 3 (sorted by created_at). It should appear after all
 6197    // accessed threads, even though its created_at (June 2024) is much later
 6198    // than the others.
 6199    //
 6200    // But the live threads (A, B, C) each had send_message called which sets
 6201    // last_message_sent_or_queued. So for the accessed threads (tier 1) the
 6202    // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at.
 6203    let session_id_hist = acp::SessionId::new(Arc::from("thread-historical"));
 6204    let thread_id_hist = thread_id_for(&session_id_hist, cx);
 6205
 6206    let ids = switcher_ids(&sidebar, cx);
 6207    assert_eq!(
 6208        ids,
 6209        vec![thread_id_b, thread_id_a, thread_id_c, thread_id_hist],
 6210    );
 6211
 6212    sidebar.update_in(cx, |sidebar, _window, cx| {
 6213        sidebar.dismiss_thread_switcher(cx);
 6214    });
 6215    cx.run_until_parked();
 6216
 6217    // ── 4. Add another historical thread with older created_at ─────────
 6218    save_thread_metadata(
 6219        acp::SessionId::new(Arc::from("thread-old-historical")),
 6220        Some("Old Historical Thread".into()),
 6221        chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
 6222        Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap()),
 6223        None,
 6224        &project,
 6225        cx,
 6226    );
 6227
 6228    sidebar.update_in(cx, |sidebar, window, cx| {
 6229        sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx);
 6230    });
 6231    cx.run_until_parked();
 6232
 6233    // Both historical threads have no access or message times. They should
 6234    // appear after accessed threads, sorted by created_at (newest first).
 6235    let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical"));
 6236    let thread_id_old_hist = thread_id_for(&session_id_old_hist, cx);
 6237    let ids = switcher_ids(&sidebar, cx);
 6238    assert_eq!(
 6239        ids,
 6240        vec![
 6241            thread_id_b,
 6242            thread_id_a,
 6243            thread_id_c,
 6244            thread_id_hist,
 6245            thread_id_old_hist,
 6246        ],
 6247    );
 6248
 6249    sidebar.update_in(cx, |sidebar, _window, cx| {
 6250        sidebar.dismiss_thread_switcher(cx);
 6251    });
 6252    cx.run_until_parked();
 6253}
 6254
 6255#[gpui::test]
 6256async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
 6257    let project = init_test_project("/my-project", cx).await;
 6258    let (multi_workspace, cx) =
 6259        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6260    let sidebar = setup_sidebar(&multi_workspace, cx);
 6261
 6262    save_thread_metadata(
 6263        acp::SessionId::new(Arc::from("thread-to-archive")),
 6264        Some("Thread To Archive".into()),
 6265        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 6266        None,
 6267        None,
 6268        &project,
 6269        cx,
 6270    );
 6271    cx.run_until_parked();
 6272
 6273    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 6274    cx.run_until_parked();
 6275
 6276    let entries = visible_entries_as_strings(&sidebar, cx);
 6277    assert!(
 6278        entries.iter().any(|e| e.contains("Thread To Archive")),
 6279        "expected thread to be visible before archiving, got: {entries:?}"
 6280    );
 6281
 6282    sidebar.update_in(cx, |sidebar, window, cx| {
 6283        sidebar.archive_thread(
 6284            &acp::SessionId::new(Arc::from("thread-to-archive")),
 6285            window,
 6286            cx,
 6287        );
 6288    });
 6289    cx.run_until_parked();
 6290
 6291    let entries = visible_entries_as_strings(&sidebar, cx);
 6292    assert!(
 6293        !entries.iter().any(|e| e.contains("Thread To Archive")),
 6294        "expected thread to be hidden after archiving, got: {entries:?}"
 6295    );
 6296
 6297    cx.update(|_, cx| {
 6298        let store = ThreadMetadataStore::global(cx);
 6299        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 6300        assert_eq!(archived.len(), 1);
 6301        assert_eq!(
 6302            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 6303            "thread-to-archive"
 6304        );
 6305        assert!(archived[0].archived);
 6306    });
 6307}
 6308
 6309#[gpui::test]
 6310async fn test_archive_thread_drops_retained_conversation_view(cx: &mut TestAppContext) {
 6311    let project = init_test_project_with_agent_panel("/project-a", cx).await;
 6312    let (multi_workspace, cx) =
 6313        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6314    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6315    cx.run_until_parked();
 6316
 6317    let connection = acp_thread::StubAgentConnection::new();
 6318    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6319        acp::ContentChunk::new("Done".into()),
 6320    )]);
 6321    open_thread_with_connection(&panel, connection, cx);
 6322    send_message(&panel, cx);
 6323    let session_id = active_session_id(&panel, cx);
 6324    let thread_id = active_thread_id(&panel, cx);
 6325    cx.run_until_parked();
 6326
 6327    sidebar.read_with(cx, |sidebar, _| {
 6328        assert!(
 6329            is_active_session(sidebar, &session_id),
 6330            "expected the newly created thread to be active before archiving",
 6331        );
 6332    });
 6333
 6334    sidebar.update_in(cx, |sidebar, window, cx| {
 6335        sidebar.archive_thread(&session_id, window, cx);
 6336    });
 6337    cx.run_until_parked();
 6338
 6339    panel.read_with(cx, |panel, _| {
 6340        assert!(
 6341            !panel.is_retained_thread(&thread_id),
 6342            "archiving a thread must drop its ConversationView from retained_threads, \
 6343             but the archived thread id {thread_id:?} is still retained",
 6344        );
 6345    });
 6346}
 6347
 6348#[gpui::test]
 6349async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
 6350    // Tests two archive scenarios:
 6351    // 1. Archiving a thread in a non-active workspace leaves active_entry
 6352    //    as the current draft.
 6353    // 2. Archiving the thread the user is looking at falls back to a draft
 6354    //    on the same workspace.
 6355    agent_ui::test_support::init_test(cx);
 6356    cx.update(|cx| {
 6357        ThreadStore::init_global(cx);
 6358        ThreadMetadataStore::init_global(cx);
 6359        language_model::LanguageModelRegistry::test(cx);
 6360        prompt_store::init(cx);
 6361    });
 6362
 6363    let fs = FakeFs::new(cx.executor());
 6364    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6365        .await;
 6366    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6367        .await;
 6368    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6369
 6370    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6371    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6372
 6373    let (multi_workspace, cx) =
 6374        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6375    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6376
 6377    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6378        mw.test_add_workspace(project_b.clone(), window, cx)
 6379    });
 6380    let panel_b = add_agent_panel(&workspace_b, cx);
 6381    cx.run_until_parked();
 6382
 6383    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 6384    sidebar.update_in(cx, |sidebar, window, cx| {
 6385        sidebar.create_new_thread(&workspace_b, window, cx);
 6386    });
 6387    cx.run_until_parked();
 6388
 6389    // --- Scenario 1: archive a thread in the non-active workspace ---
 6390
 6391    // Create a thread in project-a (non-active — project-b is active).
 6392    let connection = acp_thread::StubAgentConnection::new();
 6393    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6394        acp::ContentChunk::new("Done".into()),
 6395    )]);
 6396    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 6397    agent_ui::test_support::send_message(&panel_a, cx);
 6398    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 6399    cx.run_until_parked();
 6400
 6401    sidebar.update_in(cx, |sidebar, window, cx| {
 6402        sidebar.archive_thread(&thread_a, window, cx);
 6403    });
 6404    cx.run_until_parked();
 6405
 6406    // active_entry should still be a draft on workspace_b (the active one).
 6407    sidebar.read_with(cx, |sidebar, _| {
 6408        assert!(
 6409            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b),
 6410            "expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
 6411            sidebar.active_entry,
 6412        );
 6413    });
 6414
 6415    // --- Scenario 2: archive the thread the user is looking at ---
 6416
 6417    // Create a thread in project-b (the active workspace) and verify it
 6418    // becomes the active entry.
 6419    let connection = acp_thread::StubAgentConnection::new();
 6420    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6421        acp::ContentChunk::new("Done".into()),
 6422    )]);
 6423    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6424    agent_ui::test_support::send_message(&panel_b, cx);
 6425    let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
 6426    cx.run_until_parked();
 6427
 6428    sidebar.read_with(cx, |sidebar, _| {
 6429        assert!(
 6430            is_active_session(&sidebar, &thread_b),
 6431            "expected active_entry to be Thread({thread_b}), got: {:?}",
 6432            sidebar.active_entry,
 6433        );
 6434    });
 6435
 6436    sidebar.update_in(cx, |sidebar, window, cx| {
 6437        sidebar.archive_thread(&thread_b, window, cx);
 6438    });
 6439    cx.run_until_parked();
 6440
 6441    // Archiving the active thread activates a draft on the same workspace
 6442    // (via clear_base_view → activate_draft). The draft is not shown as a
 6443    // sidebar row but active_entry tracks it.
 6444    sidebar.read_with(cx, |sidebar, _| {
 6445        assert!(
 6446            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b),
 6447            "expected draft on workspace_b after archiving active thread, got: {:?}",
 6448            sidebar.active_entry,
 6449        );
 6450    });
 6451}
 6452
 6453#[gpui::test]
 6454async fn test_unarchive_only_shows_restored_thread(cx: &mut TestAppContext) {
 6455    // Full flow: create a thread, archive it (removing the workspace),
 6456    // then unarchive. Only the restored thread should appear — no
 6457    // leftover drafts or previously-serialized threads.
 6458    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 6459    let (multi_workspace, cx) =
 6460        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6461    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6462    cx.run_until_parked();
 6463
 6464    // Create a thread and send a message so it's a real thread.
 6465    let connection = acp_thread::StubAgentConnection::new();
 6466    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6467        acp::ContentChunk::new("Hello".into()),
 6468    )]);
 6469    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6470    agent_ui::test_support::send_message(&panel, cx);
 6471    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6472    cx.run_until_parked();
 6473
 6474    // Archive it.
 6475    sidebar.update_in(cx, |sidebar, window, cx| {
 6476        sidebar.archive_thread(&session_id, window, cx);
 6477    });
 6478    cx.run_until_parked();
 6479
 6480    // Grab metadata for unarchive.
 6481    let thread_id = cx.update(|_, cx| {
 6482        ThreadMetadataStore::global(cx)
 6483            .read(cx)
 6484            .entries()
 6485            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6486            .map(|e| e.thread_id)
 6487            .expect("thread should exist")
 6488    });
 6489    let metadata = cx.update(|_, cx| {
 6490        ThreadMetadataStore::global(cx)
 6491            .read(cx)
 6492            .entry(thread_id)
 6493            .cloned()
 6494            .expect("metadata should exist")
 6495    });
 6496
 6497    // Unarchive it — the draft should be replaced by the restored thread.
 6498    sidebar.update_in(cx, |sidebar, window, cx| {
 6499        sidebar.open_thread_from_archive(metadata, window, cx);
 6500    });
 6501    cx.run_until_parked();
 6502
 6503    // Only the unarchived thread should be visible — no drafts, no other threads.
 6504    let entries = visible_entries_as_strings(&sidebar, cx);
 6505    let thread_count = entries
 6506        .iter()
 6507        .filter(|e| !e.starts_with("v ") && !e.starts_with("> "))
 6508        .count();
 6509    assert_eq!(
 6510        thread_count, 1,
 6511        "expected exactly 1 thread entry (the restored one), got entries: {entries:?}"
 6512    );
 6513    assert!(
 6514        !entries.iter().any(|e| e.contains("Draft")),
 6515        "expected no drafts after restoring, got entries: {entries:?}"
 6516    );
 6517}
 6518
 6519#[gpui::test]
 6520async fn test_unarchive_first_thread_in_group_does_not_create_spurious_draft(
 6521    cx: &mut TestAppContext,
 6522) {
 6523    // When a thread is unarchived into a project group that has no open
 6524    // workspace, the sidebar opens a new workspace and loads the thread.
 6525    // No spurious draft should appear alongside the unarchived thread.
 6526    agent_ui::test_support::init_test(cx);
 6527    cx.update(|cx| {
 6528        ThreadStore::init_global(cx);
 6529        ThreadMetadataStore::init_global(cx);
 6530        language_model::LanguageModelRegistry::test(cx);
 6531        prompt_store::init(cx);
 6532    });
 6533
 6534    let fs = FakeFs::new(cx.executor());
 6535    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6536        .await;
 6537    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6538        .await;
 6539    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6540
 6541    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6542    let (multi_workspace, cx) =
 6543        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6544    let sidebar = setup_sidebar(&multi_workspace, cx);
 6545    cx.run_until_parked();
 6546
 6547    // Save an archived thread whose folder_paths point to project-b,
 6548    // which has no open workspace.
 6549    let session_id = acp::SessionId::new(Arc::from("archived-thread"));
 6550    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
 6551    let thread_id = ThreadId::new();
 6552    cx.update(|_, cx| {
 6553        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6554            store.save(
 6555                ThreadMetadata {
 6556                    thread_id,
 6557                    session_id: Some(session_id.clone()),
 6558                    agent_id: agent::ZED_AGENT_ID.clone(),
 6559                    title: Some("Unarchived Thread".into()),
 6560                    updated_at: Utc::now(),
 6561                    created_at: None,
 6562                    interacted_at: None,
 6563                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6564                    archived: true,
 6565                    remote_connection: None,
 6566                },
 6567                cx,
 6568            )
 6569        });
 6570    });
 6571    cx.run_until_parked();
 6572
 6573    // Verify no workspace for project-b exists yet.
 6574    assert_eq!(
 6575        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6576        1,
 6577        "should start with only the project-a workspace"
 6578    );
 6579
 6580    // Un-archive the thread — should open project-b workspace and load it.
 6581    let metadata = cx.update(|_, cx| {
 6582        ThreadMetadataStore::global(cx)
 6583            .read(cx)
 6584            .entry(thread_id)
 6585            .cloned()
 6586            .expect("metadata should exist")
 6587    });
 6588
 6589    sidebar.update_in(cx, |sidebar, window, cx| {
 6590        sidebar.open_thread_from_archive(metadata, window, cx);
 6591    });
 6592    cx.run_until_parked();
 6593
 6594    // A second workspace should have been created for project-b.
 6595    assert_eq!(
 6596        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6597        2,
 6598        "should have opened a workspace for the unarchived thread"
 6599    );
 6600
 6601    // The sidebar should show the unarchived thread without a spurious draft
 6602    // in the project-b group.
 6603    let entries = visible_entries_as_strings(&sidebar, cx);
 6604    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6605    // project-a gets a draft (it's the active workspace with no threads),
 6606    // but project-b should NOT have one — only the unarchived thread.
 6607    assert!(
 6608        draft_count <= 1,
 6609        "expected at most one draft (for project-a), got entries: {entries:?}"
 6610    );
 6611    assert!(
 6612        entries.iter().any(|e| e.contains("Unarchived Thread")),
 6613        "expected unarchived thread to appear, got entries: {entries:?}"
 6614    );
 6615}
 6616
 6617#[gpui::test]
 6618async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread(
 6619    cx: &mut TestAppContext,
 6620) {
 6621    agent_ui::test_support::init_test(cx);
 6622    cx.update(|cx| {
 6623        ThreadStore::init_global(cx);
 6624        ThreadMetadataStore::init_global(cx);
 6625        language_model::LanguageModelRegistry::test(cx);
 6626        prompt_store::init(cx);
 6627    });
 6628
 6629    let fs = FakeFs::new(cx.executor());
 6630    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6631        .await;
 6632    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6633        .await;
 6634    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6635
 6636    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6637    let (multi_workspace, cx) =
 6638        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6639    let sidebar = setup_sidebar(&multi_workspace, cx);
 6640    cx.run_until_parked();
 6641
 6642    let session_id = acp::SessionId::new(Arc::from("restore-into-new-workspace"));
 6643    let path_list_b = PathList::new(&[PathBuf::from("/project-b")]);
 6644    let original_thread_id = ThreadId::new();
 6645    cx.update(|_, cx| {
 6646        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6647            store.save(
 6648                ThreadMetadata {
 6649                    thread_id: original_thread_id,
 6650                    session_id: Some(session_id.clone()),
 6651                    agent_id: agent::ZED_AGENT_ID.clone(),
 6652                    title: Some("Unarchived Thread".into()),
 6653                    updated_at: Utc::now(),
 6654                    created_at: None,
 6655                    interacted_at: None,
 6656                    worktree_paths: WorktreePaths::from_folder_paths(&path_list_b),
 6657                    archived: true,
 6658                    remote_connection: None,
 6659                },
 6660                cx,
 6661            )
 6662        });
 6663    });
 6664    cx.run_until_parked();
 6665
 6666    let metadata = cx.update(|_, cx| {
 6667        ThreadMetadataStore::global(cx)
 6668            .read(cx)
 6669            .entry(original_thread_id)
 6670            .cloned()
 6671            .expect("metadata should exist before unarchive")
 6672    });
 6673
 6674    sidebar.update_in(cx, |sidebar, window, cx| {
 6675        sidebar.open_thread_from_archive(metadata, window, cx);
 6676    });
 6677
 6678    cx.run_until_parked();
 6679
 6680    assert_eq!(
 6681        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 6682        2,
 6683        "expected unarchive to open the target workspace"
 6684    );
 6685
 6686    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 6687        mw.workspaces()
 6688            .find(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)) == path_list_b)
 6689            .cloned()
 6690            .expect("expected restored workspace for unarchived thread")
 6691    });
 6692    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 6693        workspace
 6694            .panel::<AgentPanel>(cx)
 6695            .expect("expected unarchive to install an agent panel in the new workspace")
 6696    });
 6697
 6698    let restored_thread_id = restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6699    assert_eq!(
 6700        restored_thread_id,
 6701        Some(original_thread_id),
 6702        "expected the new workspace's agent panel to target the restored archived thread id"
 6703    );
 6704
 6705    let session_entries = cx.update(|_, cx| {
 6706        ThreadMetadataStore::global(cx)
 6707            .read(cx)
 6708            .entries()
 6709            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 6710            .cloned()
 6711            .collect::<Vec<_>>()
 6712    });
 6713    assert_eq!(
 6714        session_entries.len(),
 6715        1,
 6716        "expected exactly one metadata row for restored session after opening a new workspace, got: {session_entries:?}"
 6717    );
 6718    assert_eq!(
 6719        session_entries[0].thread_id, original_thread_id,
 6720        "expected restore into a new workspace to reuse the original thread id"
 6721    );
 6722    assert!(
 6723        !session_entries[0].archived,
 6724        "expected restored thread metadata to be unarchived, got: {:?}",
 6725        session_entries[0]
 6726    );
 6727
 6728    let mapped_thread_id = cx.update(|_, cx| {
 6729        ThreadMetadataStore::global(cx)
 6730            .read(cx)
 6731            .entries()
 6732            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6733            .map(|e| e.thread_id)
 6734    });
 6735    assert_eq!(
 6736        mapped_thread_id,
 6737        Some(original_thread_id),
 6738        "expected session mapping to remain stable after opening the new workspace"
 6739    );
 6740
 6741    let entries = visible_entries_as_strings(&sidebar, cx);
 6742    let real_thread_rows = entries
 6743        .iter()
 6744        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 6745        .filter(|entry| !entry.contains("Draft"))
 6746        .count();
 6747    assert_eq!(
 6748        real_thread_rows, 1,
 6749        "expected exactly one visible real thread row after restore into a new workspace, got entries: {entries:?}"
 6750    );
 6751    assert!(
 6752        entries
 6753            .iter()
 6754            .any(|entry| entry.contains("Unarchived Thread")),
 6755        "expected restored thread row to be visible, got entries: {entries:?}"
 6756    );
 6757}
 6758
 6759#[gpui::test]
 6760async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
 6761    // When a workspace already exists with an empty draft and a thread
 6762    // is unarchived into it, the draft should be replaced — not kept
 6763    // alongside the loaded thread.
 6764    agent_ui::test_support::init_test(cx);
 6765    cx.update(|cx| {
 6766        ThreadStore::init_global(cx);
 6767        ThreadMetadataStore::init_global(cx);
 6768        language_model::LanguageModelRegistry::test(cx);
 6769        prompt_store::init(cx);
 6770    });
 6771
 6772    let fs = FakeFs::new(cx.executor());
 6773    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 6774        .await;
 6775    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6776
 6777    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 6778    let (multi_workspace, cx) =
 6779        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 6780    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 6781    cx.run_until_parked();
 6782
 6783    // Create a thread and send a message so it's no longer a draft.
 6784    let connection = acp_thread::StubAgentConnection::new();
 6785    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6786        acp::ContentChunk::new("Done".into()),
 6787    )]);
 6788    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 6789    agent_ui::test_support::send_message(&panel, cx);
 6790    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 6791    cx.run_until_parked();
 6792
 6793    // Archive the thread — the group is left empty (no draft created).
 6794    sidebar.update_in(cx, |sidebar, window, cx| {
 6795        sidebar.archive_thread(&session_id, window, cx);
 6796    });
 6797    cx.run_until_parked();
 6798
 6799    // Un-archive the thread.
 6800    let thread_id = cx.update(|_, cx| {
 6801        ThreadMetadataStore::global(cx)
 6802            .read(cx)
 6803            .entries()
 6804            .find(|e| e.session_id.as_ref() == Some(&session_id))
 6805            .map(|e| e.thread_id)
 6806            .expect("thread should exist in store")
 6807    });
 6808    let metadata = cx.update(|_, cx| {
 6809        ThreadMetadataStore::global(cx)
 6810            .read(cx)
 6811            .entry(thread_id)
 6812            .cloned()
 6813            .expect("metadata should exist")
 6814    });
 6815
 6816    sidebar.update_in(cx, |sidebar, window, cx| {
 6817        sidebar.open_thread_from_archive(metadata, window, cx);
 6818    });
 6819    cx.run_until_parked();
 6820
 6821    // The draft should be gone — only the unarchived thread remains.
 6822    let entries = visible_entries_as_strings(&sidebar, cx);
 6823    let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
 6824    assert_eq!(
 6825        draft_count, 0,
 6826        "expected no drafts after unarchiving, got entries: {entries:?}"
 6827    );
 6828}
 6829
 6830#[gpui::test]
 6831async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
 6832    cx: &mut TestAppContext,
 6833) {
 6834    agent_ui::test_support::init_test(cx);
 6835    cx.update(|cx| {
 6836        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6837        ThreadStore::init_global(cx);
 6838        ThreadMetadataStore::init_global(cx);
 6839        language_model::LanguageModelRegistry::test(cx);
 6840        prompt_store::init(cx);
 6841    });
 6842
 6843    let fs = FakeFs::new(cx.executor());
 6844    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6845        .await;
 6846    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6847        .await;
 6848    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6849
 6850    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6851    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6852
 6853    let (multi_workspace, cx) =
 6854        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6855    let sidebar = setup_sidebar(&multi_workspace, cx);
 6856
 6857    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 6858    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6859        mw.test_add_workspace(project_b.clone(), window, cx)
 6860    });
 6861    let _panel_b = add_agent_panel(&workspace_b, cx);
 6862    cx.run_until_parked();
 6863
 6864    multi_workspace.update_in(cx, |mw, window, cx| {
 6865        mw.activate(workspace_a.clone(), None, window, cx);
 6866    });
 6867    cx.run_until_parked();
 6868
 6869    let session_id = acp::SessionId::new(Arc::from("unarchive-into-inactive-existing-workspace"));
 6870    let thread_id = ThreadId::new();
 6871    cx.update(|_, cx| {
 6872        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 6873            store.save(
 6874                ThreadMetadata {
 6875                    thread_id,
 6876                    session_id: Some(session_id.clone()),
 6877                    agent_id: agent::ZED_AGENT_ID.clone(),
 6878                    title: Some("Restored In Inactive Workspace".into()),
 6879                    updated_at: Utc::now(),
 6880                    created_at: None,
 6881                    interacted_at: None,
 6882                    worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[
 6883                        PathBuf::from("/project-b"),
 6884                    ])),
 6885                    archived: true,
 6886                    remote_connection: None,
 6887                },
 6888                cx,
 6889            )
 6890        });
 6891    });
 6892    cx.run_until_parked();
 6893
 6894    let metadata = cx.update(|_, cx| {
 6895        ThreadMetadataStore::global(cx)
 6896            .read(cx)
 6897            .entry(thread_id)
 6898            .cloned()
 6899            .expect("archived metadata should exist before restore")
 6900    });
 6901
 6902    sidebar.update_in(cx, |sidebar, window, cx| {
 6903        sidebar.open_thread_from_archive(metadata, window, cx);
 6904    });
 6905
 6906    let panel_b_before_settle = workspace_b.read_with(cx, |workspace, cx| {
 6907        workspace.panel::<AgentPanel>(cx).expect(
 6908            "target workspace should still have an agent panel immediately after activation",
 6909        )
 6910    });
 6911    let immediate_active_thread_id =
 6912        panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
 6913
 6914    cx.run_until_parked();
 6915
 6916    sidebar.read_with(cx, |sidebar, _cx| {
 6917        assert_active_thread(
 6918            sidebar,
 6919            &session_id,
 6920            "unarchiving into an inactive existing workspace should end on the restored thread",
 6921        );
 6922    });
 6923
 6924    let panel_b = workspace_b.read_with(cx, |workspace, cx| {
 6925        workspace
 6926            .panel::<AgentPanel>(cx)
 6927            .expect("target workspace should still have an agent panel")
 6928    });
 6929    assert_eq!(
 6930        panel_b.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 6931        Some(thread_id),
 6932        "expected target panel to activate the restored thread id"
 6933    );
 6934    assert!(
 6935        immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
 6936        "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
 6937    );
 6938
 6939    let entries = visible_entries_as_strings(&sidebar, cx);
 6940    let target_rows: Vec<_> = entries
 6941        .iter()
 6942        .filter(|entry| entry.contains("Restored In Inactive Workspace") || entry.contains("Draft"))
 6943        .cloned()
 6944        .collect();
 6945    assert_eq!(
 6946        target_rows.len(),
 6947        1,
 6948        "expected only the restored row and no surviving draft in the target group, got entries: {entries:?}"
 6949    );
 6950    assert!(
 6951        target_rows[0].contains("Restored In Inactive Workspace"),
 6952        "expected the remaining row to be the restored thread, got entries: {entries:?}"
 6953    );
 6954    assert!(
 6955        !target_rows[0].contains("Draft"),
 6956        "expected no surviving draft row after unarchive into inactive existing workspace, got entries: {entries:?}"
 6957    );
 6958}
 6959
 6960#[gpui::test]
 6961async fn test_unarchive_after_removing_parent_project_group_restores_real_thread(
 6962    cx: &mut TestAppContext,
 6963) {
 6964    agent_ui::test_support::init_test(cx);
 6965    cx.update(|cx| {
 6966        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 6967        ThreadStore::init_global(cx);
 6968        ThreadMetadataStore::init_global(cx);
 6969        language_model::LanguageModelRegistry::test(cx);
 6970        prompt_store::init(cx);
 6971    });
 6972
 6973    let fs = FakeFs::new(cx.executor());
 6974    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 6975        .await;
 6976    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 6977        .await;
 6978    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 6979
 6980    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 6981    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 6982
 6983    let (multi_workspace, cx) =
 6984        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 6985    let sidebar = setup_sidebar(&multi_workspace, cx);
 6986
 6987    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 6988        mw.test_add_workspace(project_b.clone(), window, cx)
 6989    });
 6990    let panel_b = add_agent_panel(&workspace_b, cx);
 6991    cx.run_until_parked();
 6992
 6993    let connection = acp_thread::StubAgentConnection::new();
 6994    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 6995        acp::ContentChunk::new("Done".into()),
 6996    )]);
 6997    agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
 6998    agent_ui::test_support::send_message(&panel_b, cx);
 6999    let session_id = agent_ui::test_support::active_session_id(&panel_b, cx);
 7000    save_test_thread_metadata(&session_id, &project_b, cx).await;
 7001    cx.run_until_parked();
 7002
 7003    sidebar.update_in(cx, |sidebar, window, cx| {
 7004        sidebar.archive_thread(&session_id, window, cx);
 7005    });
 7006
 7007    cx.run_until_parked();
 7008
 7009    let archived_metadata = cx.update(|_, cx| {
 7010        let store = ThreadMetadataStore::global(cx).read(cx);
 7011        let thread_id = store
 7012            .entries()
 7013            .find(|e| e.session_id.as_ref() == Some(&session_id))
 7014            .map(|e| e.thread_id)
 7015            .expect("archived thread should still exist in metadata store");
 7016        let metadata = store
 7017            .entry(thread_id)
 7018            .cloned()
 7019            .expect("archived metadata should still exist after archive");
 7020        assert!(
 7021            metadata.archived,
 7022            "thread should be archived before project removal"
 7023        );
 7024        metadata
 7025    });
 7026
 7027    let group_key_b =
 7028        project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx));
 7029    let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
 7030        mw.remove_project_group(&group_key_b, window, cx)
 7031    });
 7032    remove_task
 7033        .await
 7034        .expect("remove project group task should complete");
 7035    cx.run_until_parked();
 7036
 7037    assert_eq!(
 7038        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7039        1,
 7040        "removing the archived thread's parent project group should remove its workspace"
 7041    );
 7042
 7043    sidebar.update_in(cx, |sidebar, window, cx| {
 7044        sidebar.open_thread_from_archive(archived_metadata.clone(), window, cx);
 7045    });
 7046    cx.run_until_parked();
 7047
 7048    let restored_workspace = multi_workspace.read_with(cx, |mw, cx| {
 7049        mw.workspaces()
 7050            .find(|workspace| {
 7051                PathList::new(&workspace.read(cx).root_paths(cx))
 7052                    == PathList::new(&[PathBuf::from("/project-b")])
 7053            })
 7054            .cloned()
 7055            .expect("expected unarchive to recreate the removed project workspace")
 7056    });
 7057    let restored_panel = restored_workspace.read_with(cx, |workspace, cx| {
 7058        workspace
 7059            .panel::<AgentPanel>(cx)
 7060            .expect("expected restored workspace to bootstrap an agent panel")
 7061    });
 7062
 7063    let restored_thread_id = cx.update(|_, cx| {
 7064        ThreadMetadataStore::global(cx)
 7065            .read(cx)
 7066            .entries()
 7067            .find(|e| e.session_id.as_ref() == Some(&session_id))
 7068            .map(|e| e.thread_id)
 7069            .expect("session should still map to restored thread id")
 7070    });
 7071    assert_eq!(
 7072        restored_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx)),
 7073        Some(restored_thread_id),
 7074        "expected unarchive after project removal to activate the restored real thread"
 7075    );
 7076
 7077    sidebar.read_with(cx, |sidebar, _cx| {
 7078        assert_active_thread(
 7079            sidebar,
 7080            &session_id,
 7081            "expected sidebar active entry to track the restored thread after project removal",
 7082        );
 7083    });
 7084
 7085    let entries = visible_entries_as_strings(&sidebar, cx);
 7086    let restored_title = archived_metadata.display_title().to_string();
 7087    let matching_rows: Vec<_> = entries
 7088        .iter()
 7089        .filter(|entry| entry.contains(&restored_title) || entry.contains("Draft"))
 7090        .cloned()
 7091        .collect();
 7092    assert_eq!(
 7093        matching_rows.len(),
 7094        1,
 7095        "expected only one restored row and no surviving draft after unarchive following project removal, got entries: {entries:?}"
 7096    );
 7097    assert!(
 7098        !matching_rows[0].contains("Draft"),
 7099        "expected no draft row after unarchive following project removal, got entries: {entries:?}"
 7100    );
 7101}
 7102
 7103#[gpui::test]
 7104async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut TestAppContext) {
 7105    agent_ui::test_support::init_test(cx);
 7106    cx.update(|cx| {
 7107        ThreadStore::init_global(cx);
 7108        ThreadMetadataStore::init_global(cx);
 7109        language_model::LanguageModelRegistry::test(cx);
 7110        prompt_store::init(cx);
 7111    });
 7112
 7113    let fs = FakeFs::new(cx.executor());
 7114    fs.insert_tree("/my-project", serde_json::json!({ "src": {} }))
 7115        .await;
 7116    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7117
 7118    let project = project::Project::test(fs.clone(), ["/my-project".as_ref()], cx).await;
 7119    let (multi_workspace, cx) =
 7120        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 7121    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 7122    cx.run_until_parked();
 7123
 7124    let connection = acp_thread::StubAgentConnection::new();
 7125    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 7126        acp::ContentChunk::new("Done".into()),
 7127    )]);
 7128    agent_ui::test_support::open_thread_with_connection(&panel, connection, cx);
 7129    agent_ui::test_support::send_message(&panel, cx);
 7130    let session_id = agent_ui::test_support::active_session_id(&panel, cx);
 7131    cx.run_until_parked();
 7132
 7133    let original_thread_id = cx.update(|_, cx| {
 7134        ThreadMetadataStore::global(cx)
 7135            .read(cx)
 7136            .entries()
 7137            .find(|e| e.session_id.as_ref() == Some(&session_id))
 7138            .map(|e| e.thread_id)
 7139            .expect("thread should exist in store before archiving")
 7140    });
 7141
 7142    sidebar.update_in(cx, |sidebar, window, cx| {
 7143        sidebar.archive_thread(&session_id, window, cx);
 7144    });
 7145    cx.run_until_parked();
 7146
 7147    let metadata = cx.update(|_, cx| {
 7148        ThreadMetadataStore::global(cx)
 7149            .read(cx)
 7150            .entry(original_thread_id)
 7151            .cloned()
 7152            .expect("metadata should exist after archiving")
 7153    });
 7154
 7155    sidebar.update_in(cx, |sidebar, window, cx| {
 7156        sidebar.open_thread_from_archive(metadata, window, cx);
 7157    });
 7158    cx.run_until_parked();
 7159
 7160    let session_entries = cx.update(|_, cx| {
 7161        ThreadMetadataStore::global(cx)
 7162            .read(cx)
 7163            .entries()
 7164            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7165            .cloned()
 7166            .collect::<Vec<_>>()
 7167    });
 7168
 7169    assert_eq!(
 7170        session_entries.len(),
 7171        1,
 7172        "expected exactly one metadata row for the restored session, got: {session_entries:?}"
 7173    );
 7174    assert_eq!(
 7175        session_entries[0].thread_id, original_thread_id,
 7176        "expected unarchive to reuse the original thread id instead of creating a duplicate row"
 7177    );
 7178    assert!(
 7179        session_entries[0].session_id.is_some(),
 7180        "expected restored metadata to be a real thread, got: {:?}",
 7181        session_entries[0]
 7182    );
 7183
 7184    let entries = visible_entries_as_strings(&sidebar, cx);
 7185    let real_thread_rows = entries
 7186        .iter()
 7187        .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7188        .filter(|entry| !entry.contains("Draft"))
 7189        .count();
 7190    assert_eq!(
 7191        real_thread_rows, 1,
 7192        "expected exactly one visible real thread row after unarchive, got entries: {entries:?}"
 7193    );
 7194    assert!(
 7195        !entries.iter().any(|entry| entry.contains("Draft")),
 7196        "expected no draft rows after restoring, got entries: {entries:?}"
 7197    );
 7198}
 7199
 7200#[gpui::test]
 7201async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
 7202    cx: &mut TestAppContext,
 7203) {
 7204    // When a thread is archived while the user is in a different workspace,
 7205    // clear_base_view creates a draft on the archived workspace's panel.
 7206    // Switching back to that workspace shows the draft as active_entry.
 7207    agent_ui::test_support::init_test(cx);
 7208    cx.update(|cx| {
 7209        ThreadStore::init_global(cx);
 7210        ThreadMetadataStore::init_global(cx);
 7211        language_model::LanguageModelRegistry::test(cx);
 7212        prompt_store::init(cx);
 7213    });
 7214
 7215    let fs = FakeFs::new(cx.executor());
 7216    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
 7217        .await;
 7218    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
 7219        .await;
 7220    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7221
 7222    let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
 7223    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
 7224
 7225    let (multi_workspace, cx) =
 7226        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 7227    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 7228
 7229    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 7230        mw.test_add_workspace(project_b.clone(), window, cx)
 7231    });
 7232    let _panel_b = add_agent_panel(&workspace_b, cx);
 7233    cx.run_until_parked();
 7234
 7235    // Create a thread in project-a's panel (currently non-active).
 7236    let connection = acp_thread::StubAgentConnection::new();
 7237    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 7238        acp::ContentChunk::new("Done".into()),
 7239    )]);
 7240    agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
 7241    agent_ui::test_support::send_message(&panel_a, cx);
 7242    let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
 7243    cx.run_until_parked();
 7244
 7245    // Archive it while project-b is active.
 7246    sidebar.update_in(cx, |sidebar, window, cx| {
 7247        sidebar.archive_thread(&thread_a, window, cx);
 7248    });
 7249    cx.run_until_parked();
 7250
 7251    // Switch back to project-a. Its panel was cleared during archiving
 7252    // (clear_base_view activated a draft), so active_entry should point
 7253    // to the draft on workspace_a.
 7254    let workspace_a =
 7255        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7256    multi_workspace.update_in(cx, |mw, window, cx| {
 7257        mw.activate(workspace_a.clone(), None, window, cx);
 7258    });
 7259    cx.run_until_parked();
 7260
 7261    sidebar.update_in(cx, |sidebar, _window, cx| {
 7262        sidebar.update_entries(cx);
 7263    });
 7264    cx.run_until_parked();
 7265
 7266    sidebar.read_with(cx, |sidebar, _| {
 7267        assert_active_draft(
 7268            sidebar,
 7269            &workspace_a,
 7270            "after switching to workspace with archived thread, active_entry should be the draft",
 7271        );
 7272    });
 7273}
 7274
 7275#[gpui::test]
 7276async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
 7277    let project = init_test_project("/my-project", cx).await;
 7278    let (multi_workspace, cx) =
 7279        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 7280    let sidebar = setup_sidebar(&multi_workspace, cx);
 7281
 7282    save_thread_metadata(
 7283        acp::SessionId::new(Arc::from("visible-thread")),
 7284        Some("Visible Thread".into()),
 7285        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7286        None,
 7287        None,
 7288        &project,
 7289        cx,
 7290    );
 7291
 7292    let archived_thread_session_id = acp::SessionId::new(Arc::from("archived-thread"));
 7293    save_thread_metadata(
 7294        archived_thread_session_id.clone(),
 7295        Some("Archived Thread".into()),
 7296        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7297        None,
 7298        None,
 7299        &project,
 7300        cx,
 7301    );
 7302
 7303    cx.update(|_, cx| {
 7304        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7305            let thread_id = store
 7306                .entries()
 7307                .find(|e| e.session_id.as_ref() == Some(&archived_thread_session_id))
 7308                .map(|e| e.thread_id)
 7309                .unwrap();
 7310            store.archive(thread_id, None, cx)
 7311        })
 7312    });
 7313    cx.run_until_parked();
 7314
 7315    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 7316    cx.run_until_parked();
 7317
 7318    let entries = visible_entries_as_strings(&sidebar, cx);
 7319    assert!(
 7320        entries.iter().any(|e| e.contains("Visible Thread")),
 7321        "expected visible thread in sidebar, got: {entries:?}"
 7322    );
 7323    assert!(
 7324        !entries.iter().any(|e| e.contains("Archived Thread")),
 7325        "expected archived thread to be hidden from sidebar, got: {entries:?}"
 7326    );
 7327
 7328    cx.update(|_, cx| {
 7329        let store = ThreadMetadataStore::global(cx);
 7330        let all: Vec<_> = store.read(cx).entries().collect();
 7331        assert_eq!(
 7332            all.len(),
 7333            2,
 7334            "expected 2 total entries in the store, got: {}",
 7335            all.len()
 7336        );
 7337
 7338        let archived: Vec<_> = store.read(cx).archived_entries().collect();
 7339        assert_eq!(archived.len(), 1);
 7340        assert_eq!(
 7341            archived[0].session_id.as_ref().unwrap().0.as_ref(),
 7342            "archived-thread"
 7343        );
 7344    });
 7345}
 7346
 7347#[gpui::test]
 7348async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_on_worktree(
 7349    cx: &mut TestAppContext,
 7350) {
 7351    // When a linked worktree has a single thread and that thread is archived,
 7352    // the sidebar must NOT create a new thread on the same worktree (which
 7353    // would prevent the worktree from being cleaned up on disk). Instead,
 7354    // archive_thread switches to a sibling thread on the main workspace (or
 7355    // creates a draft there) before archiving the metadata.
 7356    agent_ui::test_support::init_test(cx);
 7357    cx.update(|cx| {
 7358        ThreadStore::init_global(cx);
 7359        ThreadMetadataStore::init_global(cx);
 7360        language_model::LanguageModelRegistry::test(cx);
 7361        prompt_store::init(cx);
 7362    });
 7363
 7364    let fs = FakeFs::new(cx.executor());
 7365
 7366    fs.insert_tree(
 7367        "/project",
 7368        serde_json::json!({
 7369            ".git": {},
 7370            "src": {},
 7371        }),
 7372    )
 7373    .await;
 7374
 7375    fs.add_linked_worktree_for_repo(
 7376        Path::new("/project/.git"),
 7377        false,
 7378        git::repository::Worktree {
 7379            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7380            ref_name: Some("refs/heads/ochre-drift".into()),
 7381            sha: "aaa".into(),
 7382            is_main: false,
 7383            is_bare: false,
 7384        },
 7385    )
 7386    .await;
 7387
 7388    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7389
 7390    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7391    let worktree_project =
 7392        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7393
 7394    main_project
 7395        .update(cx, |p, cx| p.git_scans_complete(cx))
 7396        .await;
 7397    worktree_project
 7398        .update(cx, |p, cx| p.git_scans_complete(cx))
 7399        .await;
 7400
 7401    let (multi_workspace, cx) =
 7402        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7403
 7404    let sidebar = setup_sidebar(&multi_workspace, cx);
 7405
 7406    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7407        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7408    });
 7409
 7410    // Set up both workspaces with agent panels.
 7411    let main_workspace =
 7412        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7413    let _main_panel = add_agent_panel(&main_workspace, cx);
 7414    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7415
 7416    // Activate the linked worktree workspace so the sidebar tracks it.
 7417    multi_workspace.update_in(cx, |mw, window, cx| {
 7418        mw.activate(worktree_workspace.clone(), None, window, cx);
 7419    });
 7420
 7421    // Open a thread in the linked worktree panel and send a message
 7422    // so it becomes the active thread.
 7423    let connection = StubAgentConnection::new();
 7424    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7425    send_message(&worktree_panel, cx);
 7426
 7427    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7428
 7429    // Give the thread a response chunk so it has content.
 7430    cx.update(|_, cx| {
 7431        connection.send_update(
 7432            worktree_thread_id.clone(),
 7433            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7434            cx,
 7435        );
 7436    });
 7437
 7438    // Save the worktree thread's metadata.
 7439    save_thread_metadata(
 7440        worktree_thread_id.clone(),
 7441        Some("Ochre Drift Thread".into()),
 7442        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7443        None,
 7444        None,
 7445        &worktree_project,
 7446        cx,
 7447    );
 7448
 7449    // Also save a thread on the main project so there's a sibling in the
 7450    // group that can be selected after archiving.
 7451    save_thread_metadata(
 7452        acp::SessionId::new(Arc::from("main-project-thread")),
 7453        Some("Main Project Thread".into()),
 7454        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7455        None,
 7456        None,
 7457        &main_project,
 7458        cx,
 7459    );
 7460
 7461    cx.run_until_parked();
 7462
 7463    // Verify the linked worktree thread appears with its chip.
 7464    // The live thread title comes from the message text ("Hello"), not
 7465    // the metadata title we saved.
 7466    let entries_before = visible_entries_as_strings(&sidebar, cx);
 7467    assert!(
 7468        entries_before
 7469            .iter()
 7470            .any(|s| s.contains("{wt-ochre-drift}")),
 7471        "expected worktree thread with chip before archiving, got: {entries_before:?}"
 7472    );
 7473    assert!(
 7474        entries_before
 7475            .iter()
 7476            .any(|s| s.contains("Main Project Thread")),
 7477        "expected main project thread before archiving, got: {entries_before:?}"
 7478    );
 7479
 7480    // Confirm the worktree thread is the active entry.
 7481    sidebar.read_with(cx, |s, _| {
 7482        assert_active_thread(
 7483            s,
 7484            &worktree_thread_id,
 7485            "worktree thread should be active before archiving",
 7486        );
 7487    });
 7488
 7489    // Archive the worktree thread — it's the only thread using ochre-drift.
 7490    sidebar.update_in(cx, |sidebar, window, cx| {
 7491        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7492    });
 7493
 7494    cx.run_until_parked();
 7495
 7496    // The archived thread should no longer appear in the sidebar.
 7497    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7498    assert!(
 7499        !entries_after
 7500            .iter()
 7501            .any(|s| s.contains("Ochre Drift Thread")),
 7502        "archived thread should be hidden, got: {entries_after:?}"
 7503    );
 7504
 7505    // No "+ New Thread" entry should appear with the ochre-drift worktree
 7506    // chip — that would keep the worktree alive and prevent cleanup.
 7507    assert!(
 7508        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7509        "no entry should reference the archived worktree, got: {entries_after:?}"
 7510    );
 7511
 7512    // The main project thread should still be visible.
 7513    assert!(
 7514        entries_after
 7515            .iter()
 7516            .any(|s| s.contains("Main Project Thread")),
 7517        "main project thread should still be visible, got: {entries_after:?}"
 7518    );
 7519}
 7520
 7521#[gpui::test]
 7522async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
 7523    cx: &mut TestAppContext,
 7524) {
 7525    // When a linked worktree thread is the ONLY thread in the project group
 7526    // (no threads on the main repo either), archiving it should leave the
 7527    // group empty with no active entry.
 7528    agent_ui::test_support::init_test(cx);
 7529    cx.update(|cx| {
 7530        ThreadStore::init_global(cx);
 7531        ThreadMetadataStore::init_global(cx);
 7532        language_model::LanguageModelRegistry::test(cx);
 7533        prompt_store::init(cx);
 7534    });
 7535
 7536    let fs = FakeFs::new(cx.executor());
 7537
 7538    fs.insert_tree(
 7539        "/project",
 7540        serde_json::json!({
 7541            ".git": {},
 7542            "src": {},
 7543        }),
 7544    )
 7545    .await;
 7546
 7547    fs.add_linked_worktree_for_repo(
 7548        Path::new("/project/.git"),
 7549        false,
 7550        git::repository::Worktree {
 7551            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7552            ref_name: Some("refs/heads/ochre-drift".into()),
 7553            sha: "aaa".into(),
 7554            is_main: false,
 7555            is_bare: false,
 7556        },
 7557    )
 7558    .await;
 7559
 7560    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7561
 7562    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7563    let worktree_project =
 7564        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7565
 7566    main_project
 7567        .update(cx, |p, cx| p.git_scans_complete(cx))
 7568        .await;
 7569    worktree_project
 7570        .update(cx, |p, cx| p.git_scans_complete(cx))
 7571        .await;
 7572
 7573    let (multi_workspace, cx) =
 7574        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7575
 7576    let sidebar = setup_sidebar(&multi_workspace, cx);
 7577
 7578    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7579        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7580    });
 7581
 7582    let main_workspace =
 7583        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7584    let _main_panel = add_agent_panel(&main_workspace, cx);
 7585    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7586
 7587    // Activate the linked worktree workspace.
 7588    multi_workspace.update_in(cx, |mw, window, cx| {
 7589        mw.activate(worktree_workspace.clone(), None, window, cx);
 7590    });
 7591
 7592    // Open a thread on the linked worktree — this is the ONLY thread.
 7593    let connection = StubAgentConnection::new();
 7594    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7595    send_message(&worktree_panel, cx);
 7596
 7597    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7598
 7599    cx.update(|_, cx| {
 7600        connection.send_update(
 7601            worktree_thread_id.clone(),
 7602            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7603            cx,
 7604        );
 7605    });
 7606
 7607    save_thread_metadata(
 7608        worktree_thread_id.clone(),
 7609        Some("Ochre Drift Thread".into()),
 7610        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7611        None,
 7612        None,
 7613        &worktree_project,
 7614        cx,
 7615    );
 7616
 7617    cx.run_until_parked();
 7618
 7619    // Archive it — there are no other threads in the group.
 7620    sidebar.update_in(cx, |sidebar, window, cx| {
 7621        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7622    });
 7623
 7624    cx.run_until_parked();
 7625
 7626    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7627
 7628    // No entry should reference the linked worktree.
 7629    assert!(
 7630        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7631        "no entry should reference the archived worktree, got: {entries_after:?}"
 7632    );
 7633
 7634    // The active entry should be None — no draft is created.
 7635    sidebar.read_with(cx, |s, _| {
 7636        assert!(
 7637            s.active_entry.is_none(),
 7638            "expected no active entry after archiving the last thread, got: {:?}",
 7639            s.active_entry,
 7640        );
 7641    });
 7642}
 7643
 7644#[gpui::test]
 7645async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_restored_real_thread(
 7646    cx: &mut TestAppContext,
 7647) {
 7648    // When an archived thread belongs to a linked worktree whose main repo is
 7649    // already open, unarchiving should reopen the linked workspace into the
 7650    // same project group and show only the restored real thread row.
 7651    agent_ui::test_support::init_test(cx);
 7652    cx.update(|cx| {
 7653        ThreadStore::init_global(cx);
 7654        ThreadMetadataStore::init_global(cx);
 7655        language_model::LanguageModelRegistry::test(cx);
 7656        prompt_store::init(cx);
 7657    });
 7658
 7659    let fs = FakeFs::new(cx.executor());
 7660
 7661    fs.insert_tree(
 7662        "/project",
 7663        serde_json::json!({
 7664            ".git": {},
 7665            "src": {},
 7666        }),
 7667    )
 7668    .await;
 7669
 7670    fs.insert_tree(
 7671        "/wt-ochre-drift",
 7672        serde_json::json!({
 7673            ".git": "gitdir: /project/.git/worktrees/ochre-drift",
 7674            "src": {},
 7675        }),
 7676    )
 7677    .await;
 7678
 7679    fs.add_linked_worktree_for_repo(
 7680        Path::new("/project/.git"),
 7681        false,
 7682        git::repository::Worktree {
 7683            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7684            ref_name: Some("refs/heads/ochre-drift".into()),
 7685            sha: "aaa".into(),
 7686            is_main: false,
 7687            is_bare: false,
 7688        },
 7689    )
 7690    .await;
 7691
 7692    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7693
 7694    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7695    let worktree_project =
 7696        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7697
 7698    main_project
 7699        .update(cx, |p, cx| p.git_scans_complete(cx))
 7700        .await;
 7701    worktree_project
 7702        .update(cx, |p, cx| p.git_scans_complete(cx))
 7703        .await;
 7704
 7705    let (multi_workspace, cx) =
 7706        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7707
 7708    let sidebar = setup_sidebar(&multi_workspace, cx);
 7709    let main_workspace =
 7710        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7711    let _main_panel = add_agent_panel(&main_workspace, cx);
 7712    cx.run_until_parked();
 7713
 7714    let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
 7715    let original_thread_id = ThreadId::new();
 7716    let main_paths = PathList::new(&[PathBuf::from("/project")]);
 7717    let folder_paths = PathList::new(&[PathBuf::from("/wt-ochre-drift")]);
 7718
 7719    cx.update(|_, cx| {
 7720        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
 7721            store.save(
 7722                ThreadMetadata {
 7723                    thread_id: original_thread_id,
 7724                    session_id: Some(session_id.clone()),
 7725                    agent_id: agent::ZED_AGENT_ID.clone(),
 7726                    title: Some("Unarchived Linked Thread".into()),
 7727                    updated_at: Utc::now(),
 7728                    created_at: None,
 7729                    interacted_at: None,
 7730                    worktree_paths: WorktreePaths::from_path_lists(
 7731                        main_paths.clone(),
 7732                        folder_paths.clone(),
 7733                    )
 7734                    .expect("main and folder paths should be well-formed"),
 7735                    archived: true,
 7736                    remote_connection: None,
 7737                },
 7738                cx,
 7739            )
 7740        });
 7741    });
 7742    cx.run_until_parked();
 7743
 7744    let metadata = cx.update(|_, cx| {
 7745        ThreadMetadataStore::global(cx)
 7746            .read(cx)
 7747            .entry(original_thread_id)
 7748            .cloned()
 7749            .expect("archived linked-worktree metadata should exist before restore")
 7750    });
 7751
 7752    sidebar.update_in(cx, |sidebar, window, cx| {
 7753        sidebar.open_thread_from_archive(metadata, window, cx);
 7754    });
 7755    cx.run_until_parked();
 7756
 7757    assert_eq!(
 7758        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 7759        2,
 7760        "expected unarchive to open the linked worktree workspace into the project group"
 7761    );
 7762
 7763    let session_entries = cx.update(|_, cx| {
 7764        ThreadMetadataStore::global(cx)
 7765            .read(cx)
 7766            .entries()
 7767            .filter(|entry| entry.session_id.as_ref() == Some(&session_id))
 7768            .cloned()
 7769            .collect::<Vec<_>>()
 7770    });
 7771    assert_eq!(
 7772        session_entries.len(),
 7773        1,
 7774        "expected exactly one metadata row for restored linked worktree session, got: {session_entries:?}"
 7775    );
 7776    assert_eq!(
 7777        session_entries[0].thread_id, original_thread_id,
 7778        "expected unarchive to reuse the original linked worktree thread id"
 7779    );
 7780    assert!(
 7781        !session_entries[0].archived,
 7782        "expected restored linked worktree metadata to be unarchived, got: {:?}",
 7783        session_entries[0]
 7784    );
 7785
 7786    let assert_no_extra_rows = |entries: &[String]| {
 7787        let real_thread_rows = entries
 7788            .iter()
 7789            .filter(|entry| !entry.starts_with("v ") && !entry.starts_with("> "))
 7790            .filter(|entry| !entry.contains("Draft"))
 7791            .count();
 7792        assert_eq!(
 7793            real_thread_rows, 1,
 7794            "expected exactly one visible real thread row after linked-worktree unarchive, got entries: {entries:?}"
 7795        );
 7796        assert!(
 7797            !entries.iter().any(|entry| entry.contains("Draft")),
 7798            "expected no draft rows after linked-worktree unarchive, got entries: {entries:?}"
 7799        );
 7800        assert!(
 7801            !entries
 7802                .iter()
 7803                .any(|entry| entry.contains(DEFAULT_THREAD_TITLE)),
 7804            "expected no default-titled real placeholder row after linked-worktree unarchive, got entries: {entries:?}"
 7805        );
 7806        assert!(
 7807            entries
 7808                .iter()
 7809                .any(|entry| entry.contains("Unarchived Linked Thread")),
 7810            "expected restored linked worktree thread row to be visible, got entries: {entries:?}"
 7811        );
 7812    };
 7813
 7814    let entries_after_restore = visible_entries_as_strings(&sidebar, cx);
 7815    assert_no_extra_rows(&entries_after_restore);
 7816
 7817    // The reported bug may only appear after an extra scheduling turn.
 7818    cx.run_until_parked();
 7819
 7820    let entries_after_extra_turns = visible_entries_as_strings(&sidebar, cx);
 7821    assert_no_extra_rows(&entries_after_extra_turns);
 7822}
 7823
 7824#[gpui::test]
 7825async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut TestAppContext) {
 7826    // When a linked worktree thread is archived but the group has other
 7827    // threads (e.g. on the main project), archive_thread should select
 7828    // the nearest sibling.
 7829    agent_ui::test_support::init_test(cx);
 7830    cx.update(|cx| {
 7831        ThreadStore::init_global(cx);
 7832        ThreadMetadataStore::init_global(cx);
 7833        language_model::LanguageModelRegistry::test(cx);
 7834        prompt_store::init(cx);
 7835    });
 7836
 7837    let fs = FakeFs::new(cx.executor());
 7838
 7839    fs.insert_tree(
 7840        "/project",
 7841        serde_json::json!({
 7842            ".git": {},
 7843            "src": {},
 7844        }),
 7845    )
 7846    .await;
 7847
 7848    fs.add_linked_worktree_for_repo(
 7849        Path::new("/project/.git"),
 7850        false,
 7851        git::repository::Worktree {
 7852            path: std::path::PathBuf::from("/wt-ochre-drift"),
 7853            ref_name: Some("refs/heads/ochre-drift".into()),
 7854            sha: "aaa".into(),
 7855            is_main: false,
 7856            is_bare: false,
 7857        },
 7858    )
 7859    .await;
 7860
 7861    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 7862
 7863    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 7864    let worktree_project =
 7865        project::Project::test(fs.clone(), ["/wt-ochre-drift".as_ref()], cx).await;
 7866
 7867    main_project
 7868        .update(cx, |p, cx| p.git_scans_complete(cx))
 7869        .await;
 7870    worktree_project
 7871        .update(cx, |p, cx| p.git_scans_complete(cx))
 7872        .await;
 7873
 7874    let (multi_workspace, cx) =
 7875        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 7876
 7877    let sidebar = setup_sidebar(&multi_workspace, cx);
 7878
 7879    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 7880        mw.test_add_workspace(worktree_project.clone(), window, cx)
 7881    });
 7882
 7883    let main_workspace =
 7884        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
 7885    let _main_panel = add_agent_panel(&main_workspace, cx);
 7886    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
 7887
 7888    // Activate the linked worktree workspace.
 7889    multi_workspace.update_in(cx, |mw, window, cx| {
 7890        mw.activate(worktree_workspace.clone(), None, window, cx);
 7891    });
 7892
 7893    // Open a thread on the linked worktree.
 7894    let connection = StubAgentConnection::new();
 7895    open_thread_with_connection(&worktree_panel, connection.clone(), cx);
 7896    send_message(&worktree_panel, cx);
 7897
 7898    let worktree_thread_id = active_session_id(&worktree_panel, cx);
 7899
 7900    cx.update(|_, cx| {
 7901        connection.send_update(
 7902            worktree_thread_id.clone(),
 7903            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
 7904            cx,
 7905        );
 7906    });
 7907
 7908    save_thread_metadata(
 7909        worktree_thread_id.clone(),
 7910        Some("Ochre Drift Thread".into()),
 7911        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
 7912        None,
 7913        None,
 7914        &worktree_project,
 7915        cx,
 7916    );
 7917
 7918    // Save a sibling thread on the main project.
 7919    let main_thread_id = acp::SessionId::new(Arc::from("main-project-thread"));
 7920    save_thread_metadata(
 7921        main_thread_id,
 7922        Some("Main Project Thread".into()),
 7923        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 7924        None,
 7925        None,
 7926        &main_project,
 7927        cx,
 7928    );
 7929
 7930    cx.run_until_parked();
 7931
 7932    // Confirm the worktree thread is active.
 7933    sidebar.read_with(cx, |s, _| {
 7934        assert_active_thread(
 7935            s,
 7936            &worktree_thread_id,
 7937            "worktree thread should be active before archiving",
 7938        );
 7939    });
 7940
 7941    // Archive the worktree thread.
 7942    sidebar.update_in(cx, |sidebar, window, cx| {
 7943        sidebar.archive_thread(&worktree_thread_id, window, cx);
 7944    });
 7945
 7946    cx.run_until_parked();
 7947
 7948    // The worktree workspace was removed and a draft was created on the
 7949    // main workspace. No entry should reference the linked worktree.
 7950    let entries_after = visible_entries_as_strings(&sidebar, cx);
 7951    assert!(
 7952        !entries_after.iter().any(|s| s.contains("{wt-ochre-drift}")),
 7953        "no entry should reference the archived worktree, got: {entries_after:?}"
 7954    );
 7955
 7956    // The main project thread should still be visible.
 7957    assert!(
 7958        entries_after
 7959            .iter()
 7960            .any(|s| s.contains("Main Project Thread")),
 7961        "main project thread should still be visible, got: {entries_after:?}"
 7962    );
 7963}
 7964
 7965// TODO: Restore this test once linked worktree draft entries are re-implemented.
 7966// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
 7967#[gpui::test]
 7968#[ignore = "linked worktree draft entries not yet implemented"]
 7969async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
 7970    init_test(cx);
 7971    let fs = FakeFs::new(cx.executor());
 7972
 7973    fs.insert_tree(
 7974        "/project",
 7975        serde_json::json!({
 7976            ".git": {
 7977                "worktrees": {
 7978                    "feature-a": {
 7979                        "commondir": "../../",
 7980                        "HEAD": "ref: refs/heads/feature-a",
 7981                    },
 7982                },
 7983            },
 7984            "src": {},
 7985        }),
 7986    )
 7987    .await;
 7988
 7989    fs.insert_tree(
 7990        "/wt-feature-a",
 7991        serde_json::json!({
 7992            ".git": "gitdir: /project/.git/worktrees/feature-a",
 7993            "src": {},
 7994        }),
 7995    )
 7996    .await;
 7997
 7998    fs.add_linked_worktree_for_repo(
 7999        Path::new("/project/.git"),
 8000        false,
 8001        git::repository::Worktree {
 8002            path: PathBuf::from("/wt-feature-a"),
 8003            ref_name: Some("refs/heads/feature-a".into()),
 8004            sha: "aaa".into(),
 8005            is_main: false,
 8006            is_bare: false,
 8007        },
 8008    )
 8009    .await;
 8010
 8011    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8012
 8013    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 8014    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8015
 8016    main_project
 8017        .update(cx, |p, cx| p.git_scans_complete(cx))
 8018        .await;
 8019    worktree_project
 8020        .update(cx, |p, cx| p.git_scans_complete(cx))
 8021        .await;
 8022
 8023    let (multi_workspace, cx) =
 8024        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 8025    let sidebar = setup_sidebar(&multi_workspace, cx);
 8026
 8027    // Open the linked worktree as a separate workspace (simulates cmd-o).
 8028    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8029        mw.test_add_workspace(worktree_project.clone(), window, cx)
 8030    });
 8031    add_agent_panel(&worktree_workspace, cx);
 8032    cx.run_until_parked();
 8033
 8034    // Explicitly create a draft thread from the linked worktree workspace.
 8035    // Auto-created drafts use the group's first workspace (the main one),
 8036    // so a user-created draft is needed to make the linked worktree reachable.
 8037    sidebar.update_in(cx, |sidebar, window, cx| {
 8038        sidebar.create_new_thread(&worktree_workspace, window, cx);
 8039    });
 8040    cx.run_until_parked();
 8041
 8042    // Switch back to the main workspace.
 8043    multi_workspace.update_in(cx, |mw, window, cx| {
 8044        let main_ws = mw.workspaces().next().unwrap().clone();
 8045        mw.activate(main_ws, None, window, cx);
 8046    });
 8047    cx.run_until_parked();
 8048
 8049    sidebar.update_in(cx, |sidebar, _window, cx| {
 8050        sidebar.update_entries(cx);
 8051    });
 8052    cx.run_until_parked();
 8053
 8054    // The linked worktree workspace must be reachable from some sidebar entry.
 8055    let worktree_ws_id = worktree_workspace.entity_id();
 8056    let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
 8057        let mw = multi_workspace.read(cx);
 8058        sidebar
 8059            .contents
 8060            .entries
 8061            .iter()
 8062            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 8063            .map(|ws| ws.entity_id())
 8064            .collect()
 8065    });
 8066    assert!(
 8067        reachable.contains(&worktree_ws_id),
 8068        "linked worktree workspace should be reachable, but reachable are: {reachable:?}"
 8069    );
 8070
 8071    // Find the draft Thread entry whose workspace is the linked worktree.
 8072    let _ = (worktree_ws_id, sidebar, multi_workspace);
 8073    // todo("re-implement once linked worktree draft entries exist");
 8074}
 8075
 8076#[gpui::test]
 8077async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
 8078    // When only a linked worktree workspace is open (not the main repo),
 8079    // threads saved against the main repo should still appear in the sidebar.
 8080    init_test(cx);
 8081    let fs = FakeFs::new(cx.executor());
 8082
 8083    // Create the main repo with a linked worktree.
 8084    fs.insert_tree(
 8085        "/project",
 8086        serde_json::json!({
 8087            ".git": {
 8088                "worktrees": {
 8089                    "feature-a": {
 8090                        "commondir": "../../",
 8091                        "HEAD": "ref: refs/heads/feature-a",
 8092                    },
 8093                },
 8094            },
 8095            "src": {},
 8096        }),
 8097    )
 8098    .await;
 8099
 8100    fs.insert_tree(
 8101        "/wt-feature-a",
 8102        serde_json::json!({
 8103            ".git": "gitdir: /project/.git/worktrees/feature-a",
 8104            "src": {},
 8105        }),
 8106    )
 8107    .await;
 8108
 8109    fs.add_linked_worktree_for_repo(
 8110        std::path::Path::new("/project/.git"),
 8111        false,
 8112        git::repository::Worktree {
 8113            path: std::path::PathBuf::from("/wt-feature-a"),
 8114            ref_name: Some("refs/heads/feature-a".into()),
 8115            sha: "abc".into(),
 8116            is_main: false,
 8117            is_bare: false,
 8118        },
 8119    )
 8120    .await;
 8121
 8122    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8123
 8124    // Only open the linked worktree as a workspace — NOT the main repo.
 8125    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8126    worktree_project
 8127        .update(cx, |p, cx| p.git_scans_complete(cx))
 8128        .await;
 8129
 8130    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
 8131    main_project
 8132        .update(cx, |p, cx| p.git_scans_complete(cx))
 8133        .await;
 8134
 8135    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 8136        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 8137    });
 8138    let sidebar = setup_sidebar(&multi_workspace, cx);
 8139
 8140    // Save a thread against the MAIN repo path.
 8141    save_named_thread_metadata("main-thread", "Main Repo Thread", &main_project, cx).await;
 8142
 8143    // Save a thread against the linked worktree path.
 8144    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
 8145
 8146    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 8147    cx.run_until_parked();
 8148
 8149    // Both threads should be visible: the worktree thread by direct lookup,
 8150    // and the main repo thread because the workspace is a linked worktree
 8151    // and we also query the main repo path.
 8152    let entries = visible_entries_as_strings(&sidebar, cx);
 8153    assert!(
 8154        entries.iter().any(|e| e.contains("Main Repo Thread")),
 8155        "expected main repo thread to be visible in linked worktree workspace, got: {entries:?}"
 8156    );
 8157    assert!(
 8158        entries.iter().any(|e| e.contains("Worktree Thread")),
 8159        "expected worktree thread to be visible, got: {entries:?}"
 8160    );
 8161}
 8162
 8163async fn init_multi_project_test(
 8164    paths: &[&str],
 8165    cx: &mut TestAppContext,
 8166) -> (Arc<FakeFs>, Entity<project::Project>) {
 8167    agent_ui::test_support::init_test(cx);
 8168    cx.update(|cx| {
 8169        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 8170        ThreadStore::init_global(cx);
 8171        ThreadMetadataStore::init_global(cx);
 8172        language_model::LanguageModelRegistry::test(cx);
 8173        prompt_store::init(cx);
 8174    });
 8175    let fs = FakeFs::new(cx.executor());
 8176    for path in paths {
 8177        fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} }))
 8178            .await;
 8179    }
 8180    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8181    let project =
 8182        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [paths[0].as_ref()], cx).await;
 8183    (fs, project)
 8184}
 8185
 8186async fn add_test_project(
 8187    path: &str,
 8188    fs: &Arc<FakeFs>,
 8189    multi_workspace: &Entity<MultiWorkspace>,
 8190    cx: &mut gpui::VisualTestContext,
 8191) -> Entity<Workspace> {
 8192    let project = project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [path.as_ref()], cx).await;
 8193    let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8194        mw.test_add_workspace(project, window, cx)
 8195    });
 8196    cx.run_until_parked();
 8197    workspace
 8198}
 8199
 8200#[gpui::test]
 8201async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) {
 8202    let (fs, project_a) =
 8203        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8204    let (multi_workspace, cx) =
 8205        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8206    let _sidebar = setup_sidebar_closed(&multi_workspace, cx);
 8207
 8208    // Sidebar starts closed. Initial workspace A is transient.
 8209    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8210    assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 8211    assert_eq!(
 8212        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8213        1
 8214    );
 8215    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a));
 8216
 8217    // Add B — replaces A as the transient workspace.
 8218    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8219    assert_eq!(
 8220        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8221        1
 8222    );
 8223    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8224
 8225    // Add C — replaces B as the transient workspace.
 8226    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8227    assert_eq!(
 8228        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8229        1
 8230    );
 8231    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8232}
 8233
 8234#[gpui::test]
 8235async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
 8236    let (fs, project_a) = init_multi_project_test(
 8237        &["/project-a", "/project-b", "/project-c", "/project-d"],
 8238        cx,
 8239    )
 8240    .await;
 8241    let (multi_workspace, cx) =
 8242        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8243    let _sidebar = setup_sidebar(&multi_workspace, cx);
 8244    assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open()));
 8245
 8246    // Add B — retained since sidebar is open.
 8247    let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8248    assert_eq!(
 8249        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8250        2
 8251    );
 8252
 8253    // Switch to A — B survives. (Switching from one internal workspace, to another)
 8254    multi_workspace.update_in(cx, |mw, window, cx| {
 8255        mw.activate(workspace_a, None, window, cx)
 8256    });
 8257    cx.run_until_parked();
 8258    assert_eq!(
 8259        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8260        2
 8261    );
 8262
 8263    // Close sidebar — both A and B remain retained.
 8264    multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx));
 8265    cx.run_until_parked();
 8266    assert_eq!(
 8267        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8268        2
 8269    );
 8270
 8271    // Add C — added as new transient workspace. (switching from retained, to transient)
 8272    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8273    assert_eq!(
 8274        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8275        3
 8276    );
 8277    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8278
 8279    // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped)
 8280    let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await;
 8281    assert_eq!(
 8282        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8283        3
 8284    );
 8285    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d));
 8286}
 8287
 8288#[gpui::test]
 8289async fn test_transient_workspace_promotion(cx: &mut TestAppContext) {
 8290    let (fs, project_a) =
 8291        init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await;
 8292    let (multi_workspace, cx) =
 8293        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
 8294    setup_sidebar_closed(&multi_workspace, cx);
 8295
 8296    // Add B — replaces A as the transient workspace (A is discarded).
 8297    let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await;
 8298    assert_eq!(
 8299        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8300        1
 8301    );
 8302    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b));
 8303
 8304    // Open sidebar — promotes the transient B to retained.
 8305    multi_workspace.update_in(cx, |mw, window, cx| {
 8306        mw.toggle_sidebar(window, cx);
 8307    });
 8308    cx.run_until_parked();
 8309    assert_eq!(
 8310        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8311        1
 8312    );
 8313    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b)));
 8314
 8315    // Close sidebar — the retained B remains.
 8316    multi_workspace.update_in(cx, |mw, window, cx| {
 8317        mw.toggle_sidebar(window, cx);
 8318    });
 8319
 8320    // Add C — added as new transient workspace.
 8321    let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await;
 8322    assert_eq!(
 8323        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8324        2
 8325    );
 8326    assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c));
 8327}
 8328
 8329#[gpui::test]
 8330async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) {
 8331    init_test(cx);
 8332    let fs = FakeFs::new(cx.executor());
 8333
 8334    fs.insert_tree(
 8335        "/project",
 8336        serde_json::json!({
 8337            ".git": {
 8338                "worktrees": {
 8339                    "feature-a": {
 8340                        "commondir": "../../",
 8341                        "HEAD": "ref: refs/heads/feature-a",
 8342                    },
 8343                },
 8344            },
 8345            "src": {},
 8346        }),
 8347    )
 8348    .await;
 8349
 8350    fs.insert_tree(
 8351        "/wt-feature-a",
 8352        serde_json::json!({
 8353            ".git": "gitdir: /project/.git/worktrees/feature-a",
 8354            "src": {},
 8355        }),
 8356    )
 8357    .await;
 8358
 8359    fs.add_linked_worktree_for_repo(
 8360        Path::new("/project/.git"),
 8361        false,
 8362        git::repository::Worktree {
 8363            path: PathBuf::from("/wt-feature-a"),
 8364            ref_name: Some("refs/heads/feature-a".into()),
 8365            sha: "abc".into(),
 8366            is_main: false,
 8367            is_bare: false,
 8368        },
 8369    )
 8370    .await;
 8371
 8372    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8373
 8374    // Only a linked worktree workspace is open — no workspace for /project.
 8375    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
 8376    worktree_project
 8377        .update(cx, |p, cx| p.git_scans_complete(cx))
 8378        .await;
 8379
 8380    let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
 8381        MultiWorkspace::test_new(worktree_project.clone(), window, cx)
 8382    });
 8383    let sidebar = setup_sidebar(&multi_workspace, cx);
 8384
 8385    // Save a legacy thread: folder_paths = main repo, main_worktree_paths = empty.
 8386    let legacy_session = acp::SessionId::new(Arc::from("legacy-main-thread"));
 8387    cx.update(|_, cx| {
 8388        let metadata = ThreadMetadata {
 8389            thread_id: ThreadId::new(),
 8390            session_id: Some(legacy_session.clone()),
 8391            agent_id: agent::ZED_AGENT_ID.clone(),
 8392            title: Some("Legacy Main Thread".into()),
 8393            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8394            created_at: None,
 8395            interacted_at: None,
 8396            worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
 8397                "/project",
 8398            )])),
 8399            archived: false,
 8400            remote_connection: None,
 8401        };
 8402        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
 8403    });
 8404    cx.run_until_parked();
 8405
 8406    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
 8407    cx.run_until_parked();
 8408
 8409    // The legacy thread should appear in the sidebar under the project group.
 8410    let entries = visible_entries_as_strings(&sidebar, cx);
 8411    assert!(
 8412        entries.iter().any(|e| e.contains("Legacy Main Thread")),
 8413        "legacy thread should be visible: {entries:?}",
 8414    );
 8415
 8416    // Verify only 1 workspace before clicking.
 8417    assert_eq!(
 8418        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
 8419        1,
 8420    );
 8421
 8422    // Focus and select the legacy thread, then confirm.
 8423    focus_sidebar(&sidebar, cx);
 8424    let thread_index = sidebar.read_with(cx, |sidebar, _| {
 8425        sidebar
 8426            .contents
 8427            .entries
 8428            .iter()
 8429            .position(|e| e.session_id().is_some_and(|id| id == &legacy_session))
 8430            .expect("legacy thread should be in entries")
 8431    });
 8432    sidebar.update_in(cx, |sidebar, _window, _cx| {
 8433        sidebar.selection = Some(thread_index);
 8434    });
 8435    cx.dispatch_action(Confirm);
 8436    cx.run_until_parked();
 8437
 8438    let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8439    let new_path_list =
 8440        new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
 8441    assert_eq!(
 8442        new_path_list,
 8443        PathList::new(&[PathBuf::from("/project")]),
 8444        "the new workspace should be for the main repo, not the linked worktree",
 8445    );
 8446}
 8447
 8448#[gpui::test]
 8449async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project(
 8450    cx: &mut TestAppContext,
 8451) {
 8452    // Regression test for a property-test finding:
 8453    //   AddLinkedWorktree { project_group_index: 0 }
 8454    //   AddProject { use_worktree: true }
 8455    //   AddProject { use_worktree: false }
 8456    // After these three steps, the linked-worktree workspace was not
 8457    // reachable from any sidebar entry.
 8458    agent_ui::test_support::init_test(cx);
 8459    cx.update(|cx| {
 8460        ThreadStore::init_global(cx);
 8461        ThreadMetadataStore::init_global(cx);
 8462        language_model::LanguageModelRegistry::test(cx);
 8463        prompt_store::init(cx);
 8464
 8465        cx.observe_new(
 8466            |workspace: &mut Workspace,
 8467             window: Option<&mut Window>,
 8468             cx: &mut gpui::Context<Workspace>| {
 8469                if let Some(window) = window {
 8470                    let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
 8471                    workspace.add_panel(panel, window, cx);
 8472                }
 8473            },
 8474        )
 8475        .detach();
 8476    });
 8477
 8478    let fs = FakeFs::new(cx.executor());
 8479    fs.insert_tree(
 8480        "/my-project",
 8481        serde_json::json!({
 8482            ".git": {},
 8483            "src": {},
 8484        }),
 8485    )
 8486    .await;
 8487    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8488    let project =
 8489        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx).await;
 8490    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 8491
 8492    let (multi_workspace, cx) =
 8493        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8494    let sidebar = setup_sidebar(&multi_workspace, cx);
 8495
 8496    // Step 1: Create a linked worktree for the main project.
 8497    let worktree_name = "wt-0";
 8498    let worktree_path = "/worktrees/wt-0";
 8499
 8500    fs.insert_tree(
 8501        worktree_path,
 8502        serde_json::json!({
 8503            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 8504            "src": {},
 8505        }),
 8506    )
 8507    .await;
 8508    fs.insert_tree(
 8509        "/my-project/.git/worktrees/wt-0",
 8510        serde_json::json!({
 8511            "commondir": "../../",
 8512            "HEAD": "ref: refs/heads/wt-0",
 8513        }),
 8514    )
 8515    .await;
 8516    fs.add_linked_worktree_for_repo(
 8517        Path::new("/my-project/.git"),
 8518        false,
 8519        git::repository::Worktree {
 8520            path: PathBuf::from(worktree_path),
 8521            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 8522            sha: "aaa".into(),
 8523            is_main: false,
 8524            is_bare: false,
 8525        },
 8526    )
 8527    .await;
 8528
 8529    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8530    let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 8531    main_project
 8532        .update(cx, |p, cx| p.git_scans_complete(cx))
 8533        .await;
 8534    cx.run_until_parked();
 8535
 8536    // Step 2: Open the linked worktree as its own workspace.
 8537    let worktree_project =
 8538        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, [worktree_path.as_ref()], cx).await;
 8539    worktree_project
 8540        .update(cx, |p, cx| p.git_scans_complete(cx))
 8541        .await;
 8542    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
 8543        mw.test_add_workspace(worktree_project.clone(), window, cx)
 8544    });
 8545    cx.run_until_parked();
 8546
 8547    // Step 3: Add an unrelated project.
 8548    fs.insert_tree(
 8549        "/other-project",
 8550        serde_json::json!({
 8551            ".git": {},
 8552            "src": {},
 8553        }),
 8554    )
 8555    .await;
 8556    let other_project = project::Project::test(
 8557        fs.clone() as Arc<dyn fs::Fs>,
 8558        ["/other-project".as_ref()],
 8559        cx,
 8560    )
 8561    .await;
 8562    other_project
 8563        .update(cx, |p, cx| p.git_scans_complete(cx))
 8564        .await;
 8565    multi_workspace.update_in(cx, |mw, window, cx| {
 8566        mw.test_add_workspace(other_project.clone(), window, cx);
 8567    });
 8568    cx.run_until_parked();
 8569
 8570    // Force a full sidebar rebuild with all groups expanded.
 8571    sidebar.update_in(cx, |sidebar, _window, cx| {
 8572        if let Some(mw) = sidebar.multi_workspace.upgrade() {
 8573            mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 8574        }
 8575        sidebar.update_entries(cx);
 8576    });
 8577    cx.run_until_parked();
 8578
 8579    // The linked-worktree workspace must be reachable from at least one
 8580    // sidebar entry — otherwise the user has no way to navigate to it.
 8581    let worktree_ws_id = worktree_workspace.entity_id();
 8582    let (all_ids, reachable_ids) = sidebar.read_with(cx, |sidebar, cx| {
 8583        let mw = multi_workspace.read(cx);
 8584
 8585        let all: HashSet<gpui::EntityId> = mw.workspaces().map(|ws| ws.entity_id()).collect();
 8586        let reachable: HashSet<gpui::EntityId> = sidebar
 8587            .contents
 8588            .entries
 8589            .iter()
 8590            .flat_map(|entry| entry.reachable_workspaces(mw, cx))
 8591            .map(|ws| ws.entity_id())
 8592            .collect();
 8593        (all, reachable)
 8594    });
 8595
 8596    let unreachable = &all_ids - &reachable_ids;
 8597    eprintln!("{}", visible_entries_as_strings(&sidebar, cx).join("\n"));
 8598
 8599    assert!(
 8600        unreachable.is_empty(),
 8601        "workspaces not reachable from any sidebar entry: {:?}\n\
 8602         (linked-worktree workspace id: {:?})",
 8603        unreachable,
 8604        worktree_ws_id,
 8605    );
 8606}
 8607
 8608#[gpui::test]
 8609async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
 8610    // Empty project groups no longer auto-create drafts via reconciliation.
 8611    // A fresh startup with no restorable thread should show only the header.
 8612    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8613    let (multi_workspace, cx) =
 8614        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8615    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8616
 8617    let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8618
 8619    let entries = visible_entries_as_strings(&sidebar, cx);
 8620    assert_eq!(
 8621        entries,
 8622        vec!["v [my-project]"],
 8623        "empty group should show only the header, no auto-created draft"
 8624    );
 8625}
 8626
 8627#[gpui::test]
 8628async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
 8629    // Rule 5: When the app starts and the AgentPanel successfully loads
 8630    // a thread, no spurious draft should appear.
 8631    let project = init_test_project_with_agent_panel("/my-project", cx).await;
 8632    let (multi_workspace, cx) =
 8633        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8634    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8635
 8636    // Create and send a message to make a real thread.
 8637    let connection = StubAgentConnection::new();
 8638    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8639        acp::ContentChunk::new("Done".into()),
 8640    )]);
 8641    open_thread_with_connection(&panel, connection, cx);
 8642    send_message(&panel, cx);
 8643    let session_id = active_session_id(&panel, cx);
 8644    save_test_thread_metadata(&session_id, &project, cx).await;
 8645    cx.run_until_parked();
 8646
 8647    // Should show the thread, NOT a spurious draft.
 8648    let entries = visible_entries_as_strings(&sidebar, cx);
 8649    assert_eq!(entries, vec!["v [my-project]", "  Hello *"]);
 8650
 8651    // active_entry should be Thread, not Draft.
 8652    sidebar.read_with(cx, |sidebar, _| {
 8653        assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
 8654    });
 8655}
 8656
 8657#[gpui::test]
 8658async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
 8659    // Rule 9: Clicking a project header should restore whatever the
 8660    // user was last looking at in that group, not create new drafts
 8661    // or jump to the first entry.
 8662    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
 8663    let (multi_workspace, cx) =
 8664        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8665    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 8666
 8667    // Create two threads in project-a.
 8668    let conn1 = StubAgentConnection::new();
 8669    conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8670        acp::ContentChunk::new("Done".into()),
 8671    )]);
 8672    open_thread_with_connection(&panel_a, conn1, cx);
 8673    send_message(&panel_a, cx);
 8674    let thread_a1 = active_session_id(&panel_a, cx);
 8675    save_test_thread_metadata(&thread_a1, &project_a, cx).await;
 8676
 8677    let conn2 = StubAgentConnection::new();
 8678    conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
 8679        acp::ContentChunk::new("Done".into()),
 8680    )]);
 8681    open_thread_with_connection(&panel_a, conn2, cx);
 8682    send_message(&panel_a, cx);
 8683    let thread_a2 = active_session_id(&panel_a, cx);
 8684    save_test_thread_metadata(&thread_a2, &project_a, cx).await;
 8685    cx.run_until_parked();
 8686
 8687    // The user is now looking at thread_a2.
 8688    sidebar.read_with(cx, |sidebar, _| {
 8689        assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
 8690    });
 8691
 8692    // Add project-b and switch to it.
 8693    let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
 8694    fs.as_fake()
 8695        .insert_tree("/project-b", serde_json::json!({ "src": {} }))
 8696        .await;
 8697    let project_b =
 8698        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8699    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8700        mw.test_add_workspace(project_b.clone(), window, cx)
 8701    });
 8702    let _panel_b = add_agent_panel(&workspace_b, cx);
 8703    cx.run_until_parked();
 8704
 8705    // Now switch BACK to project-a by activating its workspace.
 8706    let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
 8707        mw.workspaces()
 8708            .find(|ws| {
 8709                ws.read(cx)
 8710                    .project()
 8711                    .read(cx)
 8712                    .visible_worktrees(cx)
 8713                    .any(|wt| {
 8714                        wt.read(cx)
 8715                            .abs_path()
 8716                            .to_string_lossy()
 8717                            .contains("project-a")
 8718                    })
 8719            })
 8720            .unwrap()
 8721            .clone()
 8722    });
 8723    multi_workspace.update_in(cx, |mw, window, cx| {
 8724        mw.activate(workspace_a.clone(), None, window, cx);
 8725    });
 8726    cx.run_until_parked();
 8727
 8728    // The panel should still show thread_a2 (the last thing the user
 8729    // was viewing in project-a), not a draft or thread_a1.
 8730    sidebar.read_with(cx, |sidebar, _| {
 8731        assert_active_thread(
 8732            sidebar,
 8733            &thread_a2,
 8734            "switching back to project-a should restore thread_a2",
 8735        );
 8736    });
 8737
 8738    // No spurious draft entries should have been created in
 8739    // project-a's group (project-b may have a placeholder).
 8740    let entries = visible_entries_as_strings(&sidebar, cx);
 8741    // Find project-a's section and check it has no drafts.
 8742    let project_a_start = entries
 8743        .iter()
 8744        .position(|e| e.contains("project-a"))
 8745        .unwrap();
 8746    let project_a_end = entries[project_a_start + 1..]
 8747        .iter()
 8748        .position(|e| e.starts_with("v "))
 8749        .map(|i| i + project_a_start + 1)
 8750        .unwrap_or(entries.len());
 8751    let project_a_drafts = entries[project_a_start..project_a_end]
 8752        .iter()
 8753        .filter(|e| e.contains("Draft"))
 8754        .count();
 8755    assert_eq!(
 8756        project_a_drafts, 0,
 8757        "switching back to project-a should not create drafts in its group"
 8758    );
 8759}
 8760
 8761#[gpui::test]
 8762async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
 8763    // When a workspace has a draft (from the panel's load fallback)
 8764    // and the user activates it (e.g. by clicking the placeholder or
 8765    // the project header), no extra drafts should be created.
 8766    init_test(cx);
 8767    let fs = FakeFs::new(cx.executor());
 8768    fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
 8769        .await;
 8770    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8771        .await;
 8772    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 8773
 8774    let project_a =
 8775        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
 8776    let (multi_workspace, cx) =
 8777        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
 8778    let sidebar = setup_sidebar(&multi_workspace, cx);
 8779    let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 8780    let _panel_a = add_agent_panel(&workspace_a, cx);
 8781    cx.run_until_parked();
 8782
 8783    // Add project-b with its own workspace and agent panel.
 8784    let project_b =
 8785        project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
 8786    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
 8787        mw.test_add_workspace(project_b.clone(), window, cx)
 8788    });
 8789    let _panel_b = add_agent_panel(&workspace_b, cx);
 8790    cx.run_until_parked();
 8791
 8792    // Explicitly create a draft on workspace_b so the sidebar tracks one.
 8793    sidebar.update_in(cx, |sidebar, window, cx| {
 8794        sidebar.create_new_thread(&workspace_b, window, cx);
 8795    });
 8796    cx.run_until_parked();
 8797
 8798    // Count project-b's drafts.
 8799    let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
 8800        let entries = visible_entries_as_strings(&sidebar, cx);
 8801        entries
 8802            .iter()
 8803            .skip_while(|e| !e.contains("project-b"))
 8804            .take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
 8805            .filter(|e| e.contains("Draft"))
 8806            .count()
 8807    };
 8808    let drafts_before = count_b_drafts(cx);
 8809
 8810    // Switch away from project-b, then back.
 8811    multi_workspace.update_in(cx, |mw, window, cx| {
 8812        mw.activate(workspace_a.clone(), None, window, cx);
 8813    });
 8814    cx.run_until_parked();
 8815    multi_workspace.update_in(cx, |mw, window, cx| {
 8816        mw.activate(workspace_b.clone(), None, window, cx);
 8817    });
 8818    cx.run_until_parked();
 8819
 8820    let drafts_after = count_b_drafts(cx);
 8821    assert_eq!(
 8822        drafts_before, drafts_after,
 8823        "activating workspace should not create extra drafts"
 8824    );
 8825
 8826    // The draft should be highlighted as active after switching back.
 8827    sidebar.read_with(cx, |sidebar, _| {
 8828        assert_active_draft(
 8829            sidebar,
 8830            &workspace_b,
 8831            "draft should be active after switching back to its workspace",
 8832        );
 8833    });
 8834}
 8835
 8836#[gpui::test]
 8837async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &mut TestAppContext) {
 8838    // Historical threads (not open in any agent panel) should have their
 8839    // worktree paths updated when a folder is added to or removed from the
 8840    // project.
 8841    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
 8842    let (multi_workspace, cx) =
 8843        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 8844    let sidebar = setup_sidebar(&multi_workspace, cx);
 8845
 8846    // Save two threads directly into the metadata store (not via the agent
 8847    // panel), so they are purely historical — no open views hold them.
 8848    // Use different timestamps so sort order is deterministic.
 8849    save_thread_metadata(
 8850        acp::SessionId::new(Arc::from("hist-1")),
 8851        Some("Historical 1".into()),
 8852        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
 8853        None,
 8854        None,
 8855        &project,
 8856        cx,
 8857    );
 8858    save_thread_metadata(
 8859        acp::SessionId::new(Arc::from("hist-2")),
 8860        Some("Historical 2".into()),
 8861        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
 8862        None,
 8863        None,
 8864        &project,
 8865        cx,
 8866    );
 8867    cx.run_until_parked();
 8868    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8869    cx.run_until_parked();
 8870
 8871    // Sanity-check: both threads exist under the initial key [/project-a].
 8872    let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
 8873    cx.update(|_window, cx| {
 8874        let store = ThreadMetadataStore::global(cx).read(cx);
 8875        assert_eq!(
 8876            store
 8877                .entries_for_main_worktree_path(&old_key_paths, None)
 8878                .count(),
 8879            2,
 8880            "should have 2 historical threads under old key before worktree add"
 8881        );
 8882    });
 8883
 8884    // Add a second worktree to the project.
 8885    project
 8886        .update(cx, |project, cx| {
 8887            project.find_or_create_worktree("/project-b", true, cx)
 8888        })
 8889        .await
 8890        .expect("should add worktree");
 8891    cx.run_until_parked();
 8892
 8893    // The historical threads should now be indexed under the new combined
 8894    // key [/project-a, /project-b].
 8895    let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
 8896    cx.update(|_window, cx| {
 8897        let store = ThreadMetadataStore::global(cx).read(cx);
 8898        assert_eq!(
 8899            store
 8900                .entries_for_main_worktree_path(&old_key_paths, None)
 8901                .count(),
 8902            0,
 8903            "should have 0 historical threads under old key after worktree add"
 8904        );
 8905        assert_eq!(
 8906            store
 8907                .entries_for_main_worktree_path(&new_key_paths, None)
 8908                .count(),
 8909            2,
 8910            "should have 2 historical threads under new key after worktree add"
 8911        );
 8912    });
 8913
 8914    // Sidebar should show threads under the new header.
 8915    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8916    cx.run_until_parked();
 8917    assert_eq!(
 8918        visible_entries_as_strings(&sidebar, cx),
 8919        vec![
 8920            "v [project-a, project-b]",
 8921            "  Historical 2",
 8922            "  Historical 1",
 8923        ]
 8924    );
 8925
 8926    // Now remove the second worktree.
 8927    let worktree_id = project.read_with(cx, |project, cx| {
 8928        project
 8929            .visible_worktrees(cx)
 8930            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
 8931            .map(|wt| wt.read(cx).id())
 8932            .expect("should find project-b worktree")
 8933    });
 8934    project.update(cx, |project, cx| {
 8935        project.remove_worktree(worktree_id, cx);
 8936    });
 8937    cx.run_until_parked();
 8938
 8939    // Historical threads should migrate back to the original key.
 8940    cx.update(|_window, cx| {
 8941        let store = ThreadMetadataStore::global(cx).read(cx);
 8942        assert_eq!(
 8943            store
 8944                .entries_for_main_worktree_path(&new_key_paths, None)
 8945                .count(),
 8946            0,
 8947            "should have 0 historical threads under new key after worktree remove"
 8948        );
 8949        assert_eq!(
 8950            store
 8951                .entries_for_main_worktree_path(&old_key_paths, None)
 8952                .count(),
 8953            2,
 8954            "should have 2 historical threads under old key after worktree remove"
 8955        );
 8956    });
 8957
 8958    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 8959    cx.run_until_parked();
 8960    assert_eq!(
 8961        visible_entries_as_strings(&sidebar, cx),
 8962        vec!["v [project-a]", "  Historical 2", "  Historical 1",]
 8963    );
 8964}
 8965
 8966#[gpui::test]
 8967async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
 8968    // When two workspaces share the same project group (same main path)
 8969    // but have different folder paths (main repo vs linked worktree),
 8970    // adding a worktree to the main workspace should regroup only that
 8971    // workspace and its threads into the new project group. Threads for the
 8972    // linked worktree workspace should remain under the original group.
 8973    agent_ui::test_support::init_test(cx);
 8974    cx.update(|cx| {
 8975        cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
 8976        ThreadStore::init_global(cx);
 8977        ThreadMetadataStore::init_global(cx);
 8978        language_model::LanguageModelRegistry::test(cx);
 8979        prompt_store::init(cx);
 8980    });
 8981
 8982    let fs = FakeFs::new(cx.executor());
 8983    fs.insert_tree("/project", serde_json::json!({ ".git": {}, "src": {} }))
 8984        .await;
 8985    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
 8986        .await;
 8987    fs.add_linked_worktree_for_repo(
 8988        Path::new("/project/.git"),
 8989        false,
 8990        git::repository::Worktree {
 8991            path: std::path::PathBuf::from("/wt-feature"),
 8992            ref_name: Some("refs/heads/feature".into()),
 8993            sha: "aaa".into(),
 8994            is_main: false,
 8995            is_bare: false,
 8996        },
 8997    )
 8998    .await;
 8999    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 9000
 9001    // Workspace A: main repo at /project.
 9002    let main_project =
 9003        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/project".as_ref()], cx).await;
 9004    // Workspace B: linked worktree of the same repo (same group, different folder).
 9005    let worktree_project =
 9006        project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/wt-feature".as_ref()], cx).await;
 9007
 9008    main_project
 9009        .update(cx, |p, cx| p.git_scans_complete(cx))
 9010        .await;
 9011    worktree_project
 9012        .update(cx, |p, cx| p.git_scans_complete(cx))
 9013        .await;
 9014
 9015    let (multi_workspace, cx) =
 9016        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
 9017    let sidebar = setup_sidebar(&multi_workspace, cx);
 9018    multi_workspace.update_in(cx, |mw, window, cx| {
 9019        mw.test_add_workspace(worktree_project.clone(), window, cx);
 9020    });
 9021    cx.run_until_parked();
 9022
 9023    // Save a thread for each workspace's folder paths.
 9024    let time_main = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap();
 9025    let time_wt = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 2).unwrap();
 9026    save_thread_metadata(
 9027        acp::SessionId::new(Arc::from("thread-main")),
 9028        Some("Main Thread".into()),
 9029        time_main,
 9030        Some(time_main),
 9031        None,
 9032        &main_project,
 9033        cx,
 9034    );
 9035    save_thread_metadata(
 9036        acp::SessionId::new(Arc::from("thread-wt")),
 9037        Some("Worktree Thread".into()),
 9038        time_wt,
 9039        Some(time_wt),
 9040        None,
 9041        &worktree_project,
 9042        cx,
 9043    );
 9044    cx.run_until_parked();
 9045
 9046    let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
 9047    let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
 9048
 9049    // Sanity-check: each thread is indexed under its own folder paths, but
 9050    // both appear under the shared sidebar group keyed by the main worktree.
 9051    cx.update(|_window, cx| {
 9052        let store = ThreadMetadataStore::global(cx).read(cx);
 9053        assert_eq!(
 9054            store.entries_for_path(&folder_paths_main, None).count(),
 9055            1,
 9056            "one thread under [/project]"
 9057        );
 9058        assert_eq!(
 9059            store.entries_for_path(&folder_paths_wt, None).count(),
 9060            1,
 9061            "one thread under [/wt-feature]"
 9062        );
 9063    });
 9064    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9065    cx.run_until_parked();
 9066    assert_eq!(
 9067        visible_entries_as_strings(&sidebar, cx),
 9068        vec![
 9069            "v [project]",
 9070            "  Worktree Thread {wt-feature}",
 9071            "  Main Thread",
 9072        ]
 9073    );
 9074
 9075    // Add /project-b to the main project only.
 9076    main_project
 9077        .update(cx, |project, cx| {
 9078            project.find_or_create_worktree("/project-b", true, cx)
 9079        })
 9080        .await
 9081        .expect("should add worktree");
 9082    cx.run_until_parked();
 9083
 9084    // Main Thread (folder paths [/project]) should be regrouped to
 9085    // [/project, /project-b]. Worktree Thread should remain under the
 9086    // original [/project] group.
 9087    let folder_paths_main_b =
 9088        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
 9089    cx.update(|_window, cx| {
 9090        let store = ThreadMetadataStore::global(cx).read(cx);
 9091        assert_eq!(
 9092            store.entries_for_path(&folder_paths_main, None).count(),
 9093            0,
 9094            "main thread should no longer be under old folder paths [/project]"
 9095        );
 9096        assert_eq!(
 9097            store.entries_for_path(&folder_paths_main_b, None).count(),
 9098            1,
 9099            "main thread should now be under [/project, /project-b]"
 9100        );
 9101        assert_eq!(
 9102            store.entries_for_path(&folder_paths_wt, None).count(),
 9103            1,
 9104            "worktree thread should remain unchanged under [/wt-feature]"
 9105        );
 9106    });
 9107
 9108    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9109    cx.run_until_parked();
 9110    assert_eq!(
 9111        visible_entries_as_strings(&sidebar, cx),
 9112        vec![
 9113            "v [project]",
 9114            "  Worktree Thread {wt-feature}",
 9115            "v [project, project-b]",
 9116            "  Main Thread",
 9117        ]
 9118    );
 9119}
 9120
 9121#[gpui::test]
 9122async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
 9123    cx: &mut TestAppContext,
 9124) {
 9125    // When a linked worktree is opened as its own workspace and then a new
 9126    // folder is added to the main project group, the linked worktree
 9127    // workspace must still be reachable from some sidebar entry.
 9128    let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
 9129    let fs = _fs.clone();
 9130
 9131    // Set up git worktree infrastructure.
 9132    fs.insert_tree(
 9133        "/my-project/.git/worktrees/wt-0",
 9134        serde_json::json!({
 9135            "commondir": "../../",
 9136            "HEAD": "ref: refs/heads/wt-0",
 9137        }),
 9138    )
 9139    .await;
 9140    fs.insert_tree(
 9141        "/worktrees/wt-0",
 9142        serde_json::json!({
 9143            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
 9144            "src": {},
 9145        }),
 9146    )
 9147    .await;
 9148    fs.add_linked_worktree_for_repo(
 9149        Path::new("/my-project/.git"),
 9150        false,
 9151        git::repository::Worktree {
 9152            path: PathBuf::from("/worktrees/wt-0"),
 9153            ref_name: Some("refs/heads/wt-0".into()),
 9154            sha: "aaa".into(),
 9155            is_main: false,
 9156            is_bare: false,
 9157        },
 9158    )
 9159    .await;
 9160
 9161    // Re-scan so the main project discovers the linked worktree.
 9162    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9163
 9164    let (multi_workspace, cx) =
 9165        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 9166    let sidebar = setup_sidebar(&multi_workspace, cx);
 9167
 9168    // Open the linked worktree as its own workspace.
 9169    let worktree_project = project::Project::test(
 9170        fs.clone() as Arc<dyn fs::Fs>,
 9171        ["/worktrees/wt-0".as_ref()],
 9172        cx,
 9173    )
 9174    .await;
 9175    worktree_project
 9176        .update(cx, |p, cx| p.git_scans_complete(cx))
 9177        .await;
 9178    multi_workspace.update_in(cx, |mw, window, cx| {
 9179        mw.test_add_workspace(worktree_project.clone(), window, cx);
 9180    });
 9181    cx.run_until_parked();
 9182
 9183    // Both workspaces should be reachable.
 9184    let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
 9185    assert_eq!(workspace_count, 2, "should have 2 workspaces");
 9186
 9187    // Add a new folder to the main project, changing the project group key.
 9188    fs.insert_tree(
 9189        "/other-project",
 9190        serde_json::json!({ ".git": {}, "src": {} }),
 9191    )
 9192    .await;
 9193    project
 9194        .update(cx, |project, cx| {
 9195            project.find_or_create_worktree("/other-project", true, cx)
 9196        })
 9197        .await
 9198        .expect("should add worktree");
 9199    cx.run_until_parked();
 9200
 9201    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
 9202    cx.run_until_parked();
 9203
 9204    // The linked worktree workspace must still be reachable.
 9205    let entries = visible_entries_as_strings(&sidebar, cx);
 9206    let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
 9207        mw.workspaces().map(|ws| ws.entity_id()).collect()
 9208    });
 9209    sidebar.read_with(cx, |sidebar, cx| {
 9210        let multi_workspace = multi_workspace.read(cx);
 9211        let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
 9212            .contents
 9213            .entries
 9214            .iter()
 9215            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
 9216            .map(|ws| ws.entity_id())
 9217            .collect();
 9218        let all: std::collections::HashSet<gpui::EntityId> =
 9219            mw_workspaces.iter().copied().collect();
 9220        let unreachable = &all - &reachable;
 9221        assert!(
 9222            unreachable.is_empty(),
 9223            "all workspaces should be reachable after adding folder; \
 9224             unreachable: {:?}, entries: {:?}",
 9225            unreachable,
 9226            entries,
 9227        );
 9228    });
 9229}
 9230
 9231mod property_test {
 9232    use super::*;
 9233    use gpui::proptest::prelude::*;
 9234
 9235    struct UnopenedWorktree {
 9236        path: String,
 9237        main_workspace_path: String,
 9238    }
 9239
 9240    struct TestState {
 9241        fs: Arc<FakeFs>,
 9242        thread_counter: u32,
 9243        workspace_counter: u32,
 9244        worktree_counter: u32,
 9245        saved_thread_ids: Vec<acp::SessionId>,
 9246        unopened_worktrees: Vec<UnopenedWorktree>,
 9247    }
 9248
 9249    impl TestState {
 9250        fn new(fs: Arc<FakeFs>) -> Self {
 9251            Self {
 9252                fs,
 9253                thread_counter: 0,
 9254                workspace_counter: 1,
 9255                worktree_counter: 0,
 9256                saved_thread_ids: Vec::new(),
 9257                unopened_worktrees: Vec::new(),
 9258            }
 9259        }
 9260
 9261        fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
 9262            let id = self.thread_counter;
 9263            self.thread_counter += 1;
 9264            acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
 9265        }
 9266
 9267        fn next_workspace_path(&mut self) -> String {
 9268            let id = self.workspace_counter;
 9269            self.workspace_counter += 1;
 9270            format!("/prop-project-{id}")
 9271        }
 9272
 9273        fn next_worktree_name(&mut self) -> String {
 9274            let id = self.worktree_counter;
 9275            self.worktree_counter += 1;
 9276            format!("wt-{id}")
 9277        }
 9278    }
 9279
 9280    #[derive(Debug)]
 9281    enum Operation {
 9282        SaveThread { project_group_index: usize },
 9283        SaveWorktreeThread { worktree_index: usize },
 9284        ToggleAgentPanel,
 9285        CreateDraftThread,
 9286        AddProject { use_worktree: bool },
 9287        ArchiveThread { index: usize },
 9288        SwitchToThread { index: usize },
 9289        SwitchToProjectGroup { index: usize },
 9290        AddLinkedWorktree { project_group_index: usize },
 9291        AddWorktreeToProject { project_group_index: usize },
 9292        RemoveWorktreeFromProject { project_group_index: usize },
 9293    }
 9294
 9295    // Distribution (out of 24 slots):
 9296    //   SaveThread:                5 slots (~21%)
 9297    //   SaveWorktreeThread:        2 slots (~8%)
 9298    //   ToggleAgentPanel:          1 slot  (~4%)
 9299    //   CreateDraftThread:         1 slot  (~4%)
 9300    //   AddProject:                1 slot  (~4%)
 9301    //   ArchiveThread:             2 slots (~8%)
 9302    //   SwitchToThread:            2 slots (~8%)
 9303    //   SwitchToProjectGroup:      2 slots (~8%)
 9304    //   AddLinkedWorktree:         4 slots (~17%)
 9305    //   AddWorktreeToProject:      2 slots (~8%)
 9306    //   RemoveWorktreeFromProject: 2 slots (~8%)
 9307    const DISTRIBUTION_SLOTS: u32 = 24;
 9308
 9309    impl TestState {
 9310        fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
 9311            let extra = (raw / DISTRIBUTION_SLOTS) as usize;
 9312
 9313            match raw % DISTRIBUTION_SLOTS {
 9314                0..=4 => Operation::SaveThread {
 9315                    project_group_index: extra % project_group_count,
 9316                },
 9317                5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
 9318                    worktree_index: extra % self.unopened_worktrees.len(),
 9319                },
 9320                5..=6 => Operation::SaveThread {
 9321                    project_group_index: extra % project_group_count,
 9322                },
 9323                7 => Operation::ToggleAgentPanel,
 9324                8 => Operation::CreateDraftThread,
 9325                9 => Operation::AddProject {
 9326                    use_worktree: !self.unopened_worktrees.is_empty(),
 9327                },
 9328                10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
 9329                    index: extra % self.saved_thread_ids.len(),
 9330                },
 9331                10..=11 => Operation::AddProject {
 9332                    use_worktree: !self.unopened_worktrees.is_empty(),
 9333                },
 9334                12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
 9335                    index: extra % self.saved_thread_ids.len(),
 9336                },
 9337                12..=13 => Operation::SwitchToProjectGroup {
 9338                    index: extra % project_group_count,
 9339                },
 9340                14..=15 => Operation::SwitchToProjectGroup {
 9341                    index: extra % project_group_count,
 9342                },
 9343                16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
 9344                    project_group_index: extra % project_group_count,
 9345                },
 9346                16..=19 => Operation::SaveThread {
 9347                    project_group_index: extra % project_group_count,
 9348                },
 9349                20..=21 if project_group_count > 0 => Operation::AddWorktreeToProject {
 9350                    project_group_index: extra % project_group_count,
 9351                },
 9352                20..=21 => Operation::SaveThread {
 9353                    project_group_index: extra % project_group_count,
 9354                },
 9355                22..=23 if project_group_count > 0 => Operation::RemoveWorktreeFromProject {
 9356                    project_group_index: extra % project_group_count,
 9357                },
 9358                22..=23 => Operation::SaveThread {
 9359                    project_group_index: extra % project_group_count,
 9360                },
 9361                _ => unreachable!(),
 9362            }
 9363        }
 9364    }
 9365
 9366    fn save_thread_to_path_with_main(
 9367        state: &mut TestState,
 9368        path_list: PathList,
 9369        main_worktree_paths: PathList,
 9370        cx: &mut gpui::VisualTestContext,
 9371    ) {
 9372        let session_id = state.next_metadata_only_thread_id();
 9373        let title: SharedString = format!("Thread {}", session_id).into();
 9374        let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9375            .unwrap()
 9376            + chrono::Duration::seconds(state.thread_counter as i64);
 9377        let metadata = ThreadMetadata {
 9378            thread_id: ThreadId::new(),
 9379            session_id: Some(session_id),
 9380            agent_id: agent::ZED_AGENT_ID.clone(),
 9381            title: Some(title),
 9382            updated_at,
 9383            created_at: None,
 9384            interacted_at: None,
 9385            worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, path_list).unwrap(),
 9386            archived: false,
 9387            remote_connection: None,
 9388        };
 9389        cx.update(|_, cx| {
 9390            ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
 9391        });
 9392        cx.run_until_parked();
 9393    }
 9394
 9395    async fn perform_operation(
 9396        operation: Operation,
 9397        state: &mut TestState,
 9398        multi_workspace: &Entity<MultiWorkspace>,
 9399        sidebar: &Entity<Sidebar>,
 9400        cx: &mut gpui::VisualTestContext,
 9401    ) {
 9402        match operation {
 9403            Operation::SaveThread {
 9404                project_group_index,
 9405            } => {
 9406                // Find a workspace for this project group and create a real
 9407                // thread via its agent panel.
 9408                let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
 9409                    let keys = mw.project_group_keys();
 9410                    let key = &keys[project_group_index];
 9411                    let ws = mw
 9412                        .workspaces_for_project_group(key, cx)
 9413                        .and_then(|ws| ws.first().cloned())
 9414                        .unwrap_or_else(|| mw.workspace().clone());
 9415                    let project = ws.read(cx).project().clone();
 9416                    (ws, project)
 9417                });
 9418
 9419                let panel =
 9420                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9421                if let Some(panel) = panel {
 9422                    let connection = StubAgentConnection::new();
 9423                    connection.set_next_prompt_updates(vec![
 9424                        acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
 9425                            "Done".into(),
 9426                        )),
 9427                    ]);
 9428                    open_thread_with_connection(&panel, connection, cx);
 9429                    send_message(&panel, cx);
 9430                    let session_id = active_session_id(&panel, cx);
 9431                    state.saved_thread_ids.push(session_id.clone());
 9432
 9433                    let title: SharedString = format!("Thread {}", state.thread_counter).into();
 9434                    state.thread_counter += 1;
 9435                    let updated_at =
 9436                        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
 9437                            .unwrap()
 9438                            + chrono::Duration::seconds(state.thread_counter as i64);
 9439                    save_thread_metadata(
 9440                        session_id,
 9441                        Some(title),
 9442                        updated_at,
 9443                        None,
 9444                        None,
 9445                        &project,
 9446                        cx,
 9447                    );
 9448                }
 9449            }
 9450            Operation::SaveWorktreeThread { worktree_index } => {
 9451                let worktree = &state.unopened_worktrees[worktree_index];
 9452                let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
 9453                let main_worktree_paths =
 9454                    PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
 9455                save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
 9456            }
 9457
 9458            Operation::ToggleAgentPanel => {
 9459                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9460                let panel_open =
 9461                    workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
 9462                workspace.update_in(cx, |workspace, window, cx| {
 9463                    if panel_open {
 9464                        workspace.close_panel::<AgentPanel>(window, cx);
 9465                    } else {
 9466                        workspace.open_panel::<AgentPanel>(window, cx);
 9467                    }
 9468                });
 9469            }
 9470            Operation::CreateDraftThread => {
 9471                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 9472                let panel =
 9473                    workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
 9474                if let Some(panel) = panel {
 9475                    panel.update_in(cx, |panel, window, cx| {
 9476                        panel.new_thread(&NewThread, window, cx);
 9477                    });
 9478                    cx.run_until_parked();
 9479                }
 9480                workspace.update_in(cx, |workspace, window, cx| {
 9481                    workspace.focus_panel::<AgentPanel>(window, cx);
 9482                });
 9483            }
 9484            Operation::AddProject { use_worktree } => {
 9485                let path = if use_worktree {
 9486                    // Open an existing linked worktree as a project (simulates Cmd+O
 9487                    // on a worktree directory).
 9488                    state.unopened_worktrees.remove(0).path
 9489                } else {
 9490                    // Create a brand new project.
 9491                    let path = state.next_workspace_path();
 9492                    state
 9493                        .fs
 9494                        .insert_tree(
 9495                            &path,
 9496                            serde_json::json!({
 9497                                ".git": {},
 9498                                "src": {},
 9499                            }),
 9500                        )
 9501                        .await;
 9502                    path
 9503                };
 9504                let project = project::Project::test(
 9505                    state.fs.clone() as Arc<dyn fs::Fs>,
 9506                    [path.as_ref()],
 9507                    cx,
 9508                )
 9509                .await;
 9510                project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
 9511                multi_workspace.update_in(cx, |mw, window, cx| {
 9512                    mw.test_add_workspace(project.clone(), window, cx)
 9513                });
 9514            }
 9515
 9516            Operation::ArchiveThread { index } => {
 9517                let session_id = state.saved_thread_ids[index].clone();
 9518                sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
 9519                    sidebar.archive_thread(&session_id, window, cx);
 9520                });
 9521                cx.run_until_parked();
 9522                state.saved_thread_ids.remove(index);
 9523            }
 9524            Operation::SwitchToThread { index } => {
 9525                let session_id = state.saved_thread_ids[index].clone();
 9526                // Find the thread's position in the sidebar entries and select it.
 9527                let thread_index = sidebar.read_with(cx, |sidebar, _| {
 9528                    sidebar.contents.entries.iter().position(|entry| {
 9529                        matches!(
 9530                            entry,
 9531                            ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(&session_id)
 9532                        )
 9533                    })
 9534                });
 9535                if let Some(ix) = thread_index {
 9536                    sidebar.update_in(cx, |sidebar, window, cx| {
 9537                        sidebar.selection = Some(ix);
 9538                        sidebar.confirm(&Confirm, window, cx);
 9539                    });
 9540                    cx.run_until_parked();
 9541                }
 9542            }
 9543            Operation::SwitchToProjectGroup { index } => {
 9544                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9545                    let keys = mw.project_group_keys();
 9546                    let key = &keys[index];
 9547                    mw.workspaces_for_project_group(key, cx)
 9548                        .and_then(|ws| ws.first().cloned())
 9549                        .unwrap_or_else(|| mw.workspace().clone())
 9550                });
 9551                multi_workspace.update_in(cx, |mw, window, cx| {
 9552                    mw.activate(workspace, None, window, cx);
 9553                });
 9554            }
 9555            Operation::AddLinkedWorktree {
 9556                project_group_index,
 9557            } => {
 9558                // Get the main worktree path from the project group key.
 9559                let main_path = multi_workspace.read_with(cx, |mw, _| {
 9560                    let keys = mw.project_group_keys();
 9561                    let key = &keys[project_group_index];
 9562                    key.path_list()
 9563                        .paths()
 9564                        .first()
 9565                        .unwrap()
 9566                        .to_string_lossy()
 9567                        .to_string()
 9568                });
 9569                let dot_git = format!("{}/.git", main_path);
 9570                let worktree_name = state.next_worktree_name();
 9571                let worktree_path = format!("/worktrees/{}", worktree_name);
 9572
 9573                state.fs
 9574                    .insert_tree(
 9575                        &worktree_path,
 9576                        serde_json::json!({
 9577                            ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name),
 9578                            "src": {},
 9579                        }),
 9580                    )
 9581                    .await;
 9582
 9583                // Also create the worktree metadata dir inside the main repo's .git
 9584                state
 9585                    .fs
 9586                    .insert_tree(
 9587                        &format!("{}/.git/worktrees/{}", main_path, worktree_name),
 9588                        serde_json::json!({
 9589                            "commondir": "../../",
 9590                            "HEAD": format!("ref: refs/heads/{}", worktree_name),
 9591                        }),
 9592                    )
 9593                    .await;
 9594
 9595                let dot_git_path = std::path::Path::new(&dot_git);
 9596                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
 9597                state
 9598                    .fs
 9599                    .add_linked_worktree_for_repo(
 9600                        dot_git_path,
 9601                        false,
 9602                        git::repository::Worktree {
 9603                            path: worktree_pathbuf,
 9604                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
 9605                            sha: "aaa".into(),
 9606                            is_main: false,
 9607                            is_bare: false,
 9608                        },
 9609                    )
 9610                    .await;
 9611
 9612                // Re-scan the main workspace's project so it discovers the new worktree.
 9613                let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
 9614                    let keys = mw.project_group_keys();
 9615                    let key = &keys[project_group_index];
 9616                    mw.workspaces_for_project_group(key, cx)
 9617                        .and_then(|ws| ws.first().cloned())
 9618                        .unwrap()
 9619                });
 9620                let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
 9621                main_project
 9622                    .update(cx, |p, cx| p.git_scans_complete(cx))
 9623                    .await;
 9624
 9625                state.unopened_worktrees.push(UnopenedWorktree {
 9626                    path: worktree_path,
 9627                    main_workspace_path: main_path.clone(),
 9628                });
 9629            }
 9630            Operation::AddWorktreeToProject {
 9631                project_group_index,
 9632            } => {
 9633                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9634                    let keys = mw.project_group_keys();
 9635                    let key = &keys[project_group_index];
 9636                    mw.workspaces_for_project_group(key, cx)
 9637                        .and_then(|ws| ws.first().cloned())
 9638                });
 9639                let Some(workspace) = workspace else { return };
 9640                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9641
 9642                let new_path = state.next_workspace_path();
 9643                state
 9644                    .fs
 9645                    .insert_tree(&new_path, serde_json::json!({ ".git": {}, "src": {} }))
 9646                    .await;
 9647
 9648                let result = project
 9649                    .update(cx, |project, cx| {
 9650                        project.find_or_create_worktree(&new_path, true, cx)
 9651                    })
 9652                    .await;
 9653                if result.is_err() {
 9654                    return;
 9655                }
 9656                cx.run_until_parked();
 9657            }
 9658            Operation::RemoveWorktreeFromProject {
 9659                project_group_index,
 9660            } => {
 9661                let workspace = multi_workspace.read_with(cx, |mw, cx| {
 9662                    let keys = mw.project_group_keys();
 9663                    let key = &keys[project_group_index];
 9664                    mw.workspaces_for_project_group(key, cx)
 9665                        .and_then(|ws| ws.first().cloned())
 9666                });
 9667                let Some(workspace) = workspace else { return };
 9668                let project = workspace.read_with(cx, |ws, _| ws.project().clone());
 9669
 9670                let worktree_count = project.read_with(cx, |p, cx| p.visible_worktrees(cx).count());
 9671                if worktree_count <= 1 {
 9672                    return;
 9673                }
 9674
 9675                let worktree_id = project.read_with(cx, |p, cx| {
 9676                    p.visible_worktrees(cx).last().map(|wt| wt.read(cx).id())
 9677                });
 9678                if let Some(worktree_id) = worktree_id {
 9679                    project.update(cx, |project, cx| {
 9680                        project.remove_worktree(worktree_id, cx);
 9681                    });
 9682                    cx.run_until_parked();
 9683                }
 9684            }
 9685        }
 9686    }
 9687
 9688    fn update_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
 9689        sidebar.update_in(cx, |sidebar, _window, cx| {
 9690            if let Some(mw) = sidebar.multi_workspace.upgrade() {
 9691                mw.update(cx, |mw, _cx| mw.test_expand_all_groups());
 9692            }
 9693            sidebar.update_entries(cx);
 9694        });
 9695    }
 9696
 9697    fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9698        verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
 9699        verify_no_duplicate_threads(sidebar)?;
 9700        verify_all_threads_are_shown(sidebar, cx)?;
 9701        verify_active_state_matches_current_workspace(sidebar, cx)?;
 9702        verify_all_workspaces_are_reachable(sidebar, cx)?;
 9703        verify_workspace_group_key_integrity(sidebar, cx)?;
 9704        Ok(())
 9705    }
 9706
 9707    fn verify_no_duplicate_threads(sidebar: &Sidebar) -> anyhow::Result<()> {
 9708        let mut seen: HashSet<acp::SessionId> = HashSet::default();
 9709        let mut duplicates: Vec<(acp::SessionId, String)> = Vec::new();
 9710
 9711        for entry in &sidebar.contents.entries {
 9712            if let Some(session_id) = entry.session_id() {
 9713                if !seen.insert(session_id.clone()) {
 9714                    let title = match entry {
 9715                        ListEntry::Thread(thread) => thread.metadata.display_title().to_string(),
 9716                        _ => "<unknown>".to_string(),
 9717                    };
 9718                    duplicates.push((session_id.clone(), title));
 9719                }
 9720            }
 9721        }
 9722
 9723        anyhow::ensure!(
 9724            duplicates.is_empty(),
 9725            "threads appear more than once in sidebar: {:?}",
 9726            duplicates,
 9727        );
 9728        Ok(())
 9729    }
 9730
 9731    fn verify_every_group_in_multiworkspace_is_shown(
 9732        sidebar: &Sidebar,
 9733        cx: &App,
 9734    ) -> anyhow::Result<()> {
 9735        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9736            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9737        };
 9738
 9739        let mw = multi_workspace.read(cx);
 9740
 9741        // Every project group key in the multi-workspace that has a
 9742        // non-empty path list should appear as a ProjectHeader in the
 9743        // sidebar.
 9744        let all_keys = mw.project_group_keys();
 9745        let expected_keys: HashSet<&ProjectGroupKey> = all_keys
 9746            .iter()
 9747            .filter(|k| !k.path_list().paths().is_empty())
 9748            .collect();
 9749
 9750        let sidebar_keys: HashSet<&ProjectGroupKey> = sidebar
 9751            .contents
 9752            .entries
 9753            .iter()
 9754            .filter_map(|entry| match entry {
 9755                ListEntry::ProjectHeader { key, .. } => Some(key),
 9756                _ => None,
 9757            })
 9758            .collect();
 9759
 9760        let missing = &expected_keys - &sidebar_keys;
 9761        let stray = &sidebar_keys - &expected_keys;
 9762
 9763        anyhow::ensure!(
 9764            missing.is_empty() && stray.is_empty(),
 9765            "sidebar project groups don't match multi-workspace.\n\
 9766             Only in multi-workspace (missing): {:?}\n\
 9767             Only in sidebar (stray): {:?}",
 9768            missing,
 9769            stray,
 9770        );
 9771
 9772        Ok(())
 9773    }
 9774
 9775    fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
 9776        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9777            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9778        };
 9779        let workspaces = multi_workspace
 9780            .read(cx)
 9781            .workspaces()
 9782            .cloned()
 9783            .collect::<Vec<_>>();
 9784        let thread_store = ThreadMetadataStore::global(cx);
 9785
 9786        let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
 9787            .contents
 9788            .entries
 9789            .iter()
 9790            .filter_map(|entry| entry.session_id().cloned())
 9791            .collect();
 9792
 9793        let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
 9794
 9795        // Query using the same approach as the sidebar: iterate project
 9796        // group keys, then do main + legacy queries per group.
 9797        let mw = multi_workspace.read(cx);
 9798        let mut workspaces_by_group: HashMap<ProjectGroupKey, Vec<Entity<Workspace>>> =
 9799            HashMap::default();
 9800        for workspace in &workspaces {
 9801            let key = workspace.read(cx).project_group_key(cx);
 9802            workspaces_by_group
 9803                .entry(key)
 9804                .or_default()
 9805                .push(workspace.clone());
 9806        }
 9807
 9808        for group_key in mw.project_group_keys() {
 9809            let path_list = group_key.path_list().clone();
 9810            if path_list.paths().is_empty() {
 9811                continue;
 9812            }
 9813
 9814            let group_workspaces = workspaces_by_group
 9815                .get(&group_key)
 9816                .map(|ws| ws.as_slice())
 9817                .unwrap_or_default();
 9818
 9819            // Main code path queries (run for all groups, even without workspaces).
 9820            // Skip drafts (session_id: None) — they are not shown in the
 9821            // sidebar entries.
 9822            for metadata in thread_store
 9823                .read(cx)
 9824                .entries_for_main_worktree_path(&path_list, None)
 9825            {
 9826                if let Some(sid) = metadata.session_id.clone() {
 9827                    metadata_thread_ids.insert(sid);
 9828                }
 9829            }
 9830            for metadata in thread_store.read(cx).entries_for_path(&path_list, None) {
 9831                if let Some(sid) = metadata.session_id.clone() {
 9832                    metadata_thread_ids.insert(sid);
 9833                }
 9834            }
 9835
 9836            // Legacy: per-workspace queries for different root paths.
 9837            let covered_paths: HashSet<std::path::PathBuf> = group_workspaces
 9838                .iter()
 9839                .flat_map(|ws| {
 9840                    ws.read(cx)
 9841                        .root_paths(cx)
 9842                        .into_iter()
 9843                        .map(|p| p.to_path_buf())
 9844                })
 9845                .collect();
 9846
 9847            for workspace in group_workspaces {
 9848                let ws_path_list = workspace_path_list(workspace, cx);
 9849                if ws_path_list != path_list {
 9850                    for metadata in thread_store.read(cx).entries_for_path(&ws_path_list, None) {
 9851                        if let Some(sid) = metadata.session_id.clone() {
 9852                            metadata_thread_ids.insert(sid);
 9853                        }
 9854                    }
 9855                }
 9856            }
 9857
 9858            for workspace in group_workspaces {
 9859                for snapshot in root_repository_snapshots(workspace, cx) {
 9860                    let Some(main_worktree_abs_path) = snapshot.main_worktree_abs_path() else {
 9861                        continue;
 9862                    };
 9863                    let repo_path_list = PathList::new(&[main_worktree_abs_path.to_path_buf()]);
 9864                    if repo_path_list != path_list {
 9865                        continue;
 9866                    }
 9867                    for linked_worktree in snapshot.linked_worktrees() {
 9868                        if covered_paths.contains(&*linked_worktree.path) {
 9869                            continue;
 9870                        }
 9871                        let worktree_path_list =
 9872                            PathList::new(std::slice::from_ref(&linked_worktree.path));
 9873                        for metadata in thread_store
 9874                            .read(cx)
 9875                            .entries_for_path(&worktree_path_list, None)
 9876                        {
 9877                            if let Some(sid) = metadata.session_id.clone() {
 9878                                metadata_thread_ids.insert(sid);
 9879                            }
 9880                        }
 9881                    }
 9882                }
 9883            }
 9884        }
 9885
 9886        anyhow::ensure!(
 9887            sidebar_thread_ids == metadata_thread_ids,
 9888            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
 9889            sidebar_thread_ids,
 9890            metadata_thread_ids,
 9891        );
 9892        Ok(())
 9893    }
 9894
 9895    fn verify_active_state_matches_current_workspace(
 9896        sidebar: &Sidebar,
 9897        cx: &App,
 9898    ) -> anyhow::Result<()> {
 9899        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
 9900            anyhow::bail!("sidebar should still have an associated multi-workspace");
 9901        };
 9902
 9903        let active_workspace = multi_workspace.read(cx).workspace();
 9904
 9905        // 1. active_entry should be Some when the panel has content.
 9906        //    It may be None when the panel is uninitialized (no drafts,
 9907        //    no threads), which is fine.
 9908        //    It may also temporarily point at a different workspace
 9909        //    when the workspace just changed and the new panel has no
 9910        //    content yet.
 9911        let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
 9912        let panel_has_content = panel.read(cx).active_thread_id(cx).is_some()
 9913            || panel.read(cx).active_conversation_view().is_some();
 9914
 9915        let Some(entry) = sidebar.active_entry.as_ref() else {
 9916            if panel_has_content {
 9917                anyhow::bail!("active_entry is None but panel has content (draft or thread)");
 9918            }
 9919            return Ok(());
 9920        };
 9921
 9922        // If the entry workspace doesn't match the active workspace
 9923        // and the panel has no content, this is a transient state that
 9924        // will resolve when the panel gets content.
 9925        if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
 9926            return Ok(());
 9927        }
 9928
 9929        // 2. The entry's workspace must agree with the multi-workspace's
 9930        //    active workspace.
 9931        anyhow::ensure!(
 9932            entry.workspace().entity_id() == active_workspace.entity_id(),
 9933            "active_entry workspace ({:?}) != active workspace ({:?})",
 9934            entry.workspace().entity_id(),
 9935            active_workspace.entity_id(),
 9936        );
 9937
 9938        // 3. The entry must match the agent panel's current state.
 9939        if panel.read(cx).active_thread_id(cx).is_some() {
 9940            anyhow::ensure!(
 9941                matches!(entry, ActiveEntry::Thread { .. }),
 9942                "panel shows a tracked draft but active_entry is {:?}",
 9943                entry,
 9944            );
 9945        } else if let Some(thread_id) = panel
 9946            .read(cx)
 9947            .active_conversation_view()
 9948            .map(|cv| cv.read(cx).parent_id())
 9949        {
 9950            anyhow::ensure!(
 9951                matches!(entry, ActiveEntry::Thread { thread_id: tid, .. } if *tid == thread_id),
 9952                "panel has thread {:?} but active_entry is {:?}",
 9953                thread_id,
 9954                entry,
 9955            );
 9956        }
 9957
 9958        // 4. Exactly one entry in sidebar contents must be uniquely
 9959        //    identified by the active_entry — unless the panel is showing
 9960        //    a draft, which is represented by the + button's active state
 9961        //    rather than a sidebar row.
 9962        // TODO: Make this check more complete
 9963        // Active terminals must still match a row, so don't treat the absence
 9964        // of a conversation view as "draft" when a terminal is active.
 9965        let is_draft = panel.read(cx).active_terminal_id().is_none()
 9966            && (panel.read(cx).active_thread_is_draft(cx)
 9967                || panel.read(cx).active_conversation_view().is_none());
 9968        if is_draft {
 9969            return Ok(());
 9970        }
 9971        let matching_count = sidebar
 9972            .contents
 9973            .entries
 9974            .iter()
 9975            .filter(|e| entry.matches_entry(e))
 9976            .count();
 9977        if matching_count != 1 {
 9978            let thread_entries: Vec<_> = sidebar
 9979                .contents
 9980                .entries
 9981                .iter()
 9982                .filter_map(|e| match e {
 9983                    ListEntry::Thread(t) => Some(format!(
 9984                        "tid={:?} sid={:?}",
 9985                        t.metadata.thread_id, t.metadata.session_id
 9986                    )),
 9987                    _ => None,
 9988                })
 9989                .collect();
 9990            let store = agent_ui::thread_metadata_store::ThreadMetadataStore::global(cx).read(cx);
 9991            let store_entries: Vec<_> = store
 9992                .entries()
 9993                .map(|m| {
 9994                    format!(
 9995                        "tid={:?} sid={:?} archived={} paths={:?}",
 9996                        m.thread_id,
 9997                        m.session_id,
 9998                        m.archived,
 9999                        m.folder_paths()
10000                    )
10001                })
10002                .collect();
10003            anyhow::bail!(
10004                "expected exactly 1 sidebar entry matching active_entry {:?}, found {}. sidebar threads: {:?}. store: {:?}",
10005                entry,
10006                matching_count,
10007                thread_entries,
10008                store_entries,
10009            );
10010        }
10011
10012        Ok(())
10013    }
10014
10015    /// Every workspace in the multi-workspace should be "reachable" from
10016    /// the sidebar — meaning there is at least one entry (thread, draft,
10017    /// new-thread, or project header) that, when clicked, would activate
10018    /// that workspace.
10019    fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
10020        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
10021            anyhow::bail!("sidebar should still have an associated multi-workspace");
10022        };
10023
10024        let multi_workspace = multi_workspace.read(cx);
10025
10026        let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
10027            .contents
10028            .entries
10029            .iter()
10030            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
10031            .map(|ws| ws.entity_id())
10032            .collect();
10033
10034        let all_workspace_ids: HashSet<gpui::EntityId> = multi_workspace
10035            .workspaces()
10036            .map(|ws| ws.entity_id())
10037            .collect();
10038
10039        let unreachable = &all_workspace_ids - &reachable_workspaces;
10040
10041        anyhow::ensure!(
10042            unreachable.is_empty(),
10043            "The following workspaces are not reachable from any sidebar entry: {:?}",
10044            unreachable,
10045        );
10046
10047        Ok(())
10048    }
10049
10050    fn verify_workspace_group_key_integrity(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
10051        let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
10052            anyhow::bail!("sidebar should still have an associated multi-workspace");
10053        };
10054        multi_workspace
10055            .read(cx)
10056            .assert_project_group_key_integrity(cx)
10057    }
10058
10059    #[gpui::property_test(config = ProptestConfig {
10060        cases: 20,
10061        ..Default::default()
10062    })]
10063    async fn test_sidebar_invariants(
10064        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
10065        raw_operations: Vec<u32>,
10066        cx: &mut TestAppContext,
10067    ) {
10068        use std::sync::atomic::{AtomicUsize, Ordering};
10069        static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0);
10070
10071        agent_ui::test_support::init_test(cx);
10072        cx.update(|cx| {
10073            cx.set_global(db::AppDatabase::test_new());
10074            cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
10075            cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
10076                format!(
10077                    "PROPTEST_THREAD_METADATA_{}",
10078                    NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst)
10079                ),
10080            ));
10081
10082            ThreadStore::init_global(cx);
10083            ThreadMetadataStore::init_global(cx);
10084            language_model::LanguageModelRegistry::test(cx);
10085            prompt_store::init(cx);
10086
10087            // Auto-add an AgentPanel to every workspace so that implicitly
10088            // created workspaces (e.g. from thread activation) also have one.
10089            cx.observe_new(
10090                |workspace: &mut Workspace,
10091                 window: Option<&mut Window>,
10092                 cx: &mut gpui::Context<Workspace>| {
10093                    if let Some(window) = window {
10094                        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
10095                        workspace.add_panel(panel, window, cx);
10096                    }
10097                },
10098            )
10099            .detach();
10100        });
10101
10102        let fs = FakeFs::new(cx.executor());
10103        fs.insert_tree(
10104            "/my-project",
10105            serde_json::json!({
10106                ".git": {},
10107                "src": {},
10108            }),
10109        )
10110        .await;
10111        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10112        let project =
10113            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/my-project".as_ref()], cx)
10114                .await;
10115        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10116
10117        let (multi_workspace, cx) =
10118            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10119        let sidebar = setup_sidebar(&multi_workspace, cx);
10120
10121        let mut state = TestState::new(fs);
10122        let mut executed: Vec<String> = Vec::new();
10123
10124        for &raw_op in &raw_operations {
10125            let project_group_count =
10126                multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().len());
10127            let operation = state.generate_operation(raw_op, project_group_count);
10128            executed.push(format!("{:?}", operation));
10129            perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
10130            cx.run_until_parked();
10131
10132            update_sidebar(&sidebar, cx);
10133            cx.run_until_parked();
10134
10135            let result =
10136                sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx));
10137            if let Err(err) = result {
10138                let log = executed.join("\n  ");
10139                panic!(
10140                    "Property violation after step {}:\n{err}\n\nOperations:\n  {log}",
10141                    executed.len(),
10142                );
10143            }
10144        }
10145    }
10146}
10147
10148#[gpui::test]
10149async fn test_remote_project_integration_does_not_briefly_render_as_separate_project(
10150    cx: &mut TestAppContext,
10151    server_cx: &mut TestAppContext,
10152) {
10153    init_test(cx);
10154
10155    cx.update(|cx| {
10156        release_channel::init(semver::Version::new(0, 0, 0), cx);
10157    });
10158
10159    let app_state = cx.update(|cx| {
10160        let app_state = workspace::AppState::test(cx);
10161        workspace::init(app_state.clone(), cx);
10162        app_state
10163    });
10164
10165    // Set up the remote server side.
10166    let server_fs = FakeFs::new(server_cx.executor());
10167    server_fs
10168        .insert_tree(
10169            "/project",
10170            serde_json::json!({
10171                ".git": {},
10172                "src": { "main.rs": "fn main() {}" }
10173            }),
10174        )
10175        .await;
10176    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10177
10178    // Create the linked worktree checkout path on the remote server,
10179    // but do not yet register it as a git-linked worktree. The real
10180    // regrouping update in this test should happen only after the
10181    // sidebar opens the closed remote thread.
10182    server_fs
10183        .insert_tree(
10184            "/project-wt-1",
10185            serde_json::json!({
10186                "src": { "main.rs": "fn main() {}" }
10187            }),
10188        )
10189        .await;
10190
10191    server_cx.update(|cx| {
10192        release_channel::init(semver::Version::new(0, 0, 0), cx);
10193    });
10194
10195    let (original_opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
10196
10197    server_cx.update(remote_server::HeadlessProject::init);
10198    let server_executor = server_cx.executor();
10199    let _headless = server_cx.new(|cx| {
10200        remote_server::HeadlessProject::new(
10201            remote_server::HeadlessAppState {
10202                session: server_session,
10203                fs: server_fs.clone(),
10204                http_client: Arc::new(http_client::BlockedHttpClient),
10205                node_runtime: node_runtime::NodeRuntime::unavailable(),
10206                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10207                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10208                startup_time: std::time::Instant::now(),
10209            },
10210            false,
10211            cx,
10212        )
10213    });
10214
10215    // Connect the client side and build a remote project.
10216    let remote_client = remote::RemoteClient::connect_mock(original_opts.clone(), cx).await;
10217    let project = cx.update(|cx| {
10218        let project_client = client::Client::new(
10219            Arc::new(clock::FakeSystemClock::new()),
10220            http_client::FakeHttpClient::with_404_response(),
10221            cx,
10222        );
10223        let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
10224        project::Project::remote(
10225            remote_client,
10226            project_client,
10227            node_runtime::NodeRuntime::unavailable(),
10228            user_store,
10229            app_state.languages.clone(),
10230            app_state.fs.clone(),
10231            false,
10232            cx,
10233        )
10234    });
10235
10236    // Open the remote worktree.
10237    project
10238        .update(cx, |project, cx| {
10239            project.find_or_create_worktree(Path::new("/project"), true, cx)
10240        })
10241        .await
10242        .expect("should open remote worktree");
10243    cx.run_until_parked();
10244
10245    // Verify the project is remote.
10246    project.read_with(cx, |project, cx| {
10247        assert!(!project.is_local(), "project should be remote");
10248        assert!(
10249            project.remote_connection_options(cx).is_some(),
10250            "project should have remote connection options"
10251        );
10252    });
10253
10254    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10255
10256    // Create MultiWorkspace with the remote project.
10257    let (multi_workspace, cx) =
10258        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10259    let sidebar = setup_sidebar(&multi_workspace, cx);
10260
10261    cx.run_until_parked();
10262
10263    // Save a thread for the main remote workspace (folder_paths match
10264    // the open workspace, so it will be classified as Open).
10265    let main_thread_id = acp::SessionId::new(Arc::from("main-thread"));
10266    save_thread_metadata(
10267        main_thread_id.clone(),
10268        Some("Main Thread".into()),
10269        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10270        None,
10271        None,
10272        &project,
10273        cx,
10274    );
10275    cx.run_until_parked();
10276
10277    // Save a thread whose folder_paths point to a linked worktree path
10278    // that doesn't have an open workspace ("/project-wt-1"), but whose
10279    // main_worktree_paths match the project group key so it appears
10280    // in the sidebar under the same remote group. This simulates a
10281    // linked worktree workspace that was closed.
10282    let remote_thread_id = acp::SessionId::new(Arc::from("remote-thread"));
10283    let (main_worktree_paths, remote_connection) = project.read_with(cx, |p, cx| {
10284        (
10285            p.project_group_key(cx).path_list().clone(),
10286            p.remote_connection_options(cx),
10287        )
10288    });
10289    cx.update(|_window, cx| {
10290        let metadata = ThreadMetadata {
10291            thread_id: ThreadId::new(),
10292            session_id: Some(remote_thread_id.clone()),
10293            agent_id: agent::ZED_AGENT_ID.clone(),
10294            title: Some("Worktree Thread".into()),
10295            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
10296            created_at: None,
10297            interacted_at: None,
10298            worktree_paths: WorktreePaths::from_path_lists(
10299                main_worktree_paths,
10300                PathList::new(&[PathBuf::from("/project-wt-1")]),
10301            )
10302            .unwrap(),
10303            archived: false,
10304            remote_connection,
10305        };
10306        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
10307    });
10308    cx.run_until_parked();
10309
10310    focus_sidebar(&sidebar, cx);
10311    sidebar.update_in(cx, |sidebar, _window, _cx| {
10312        sidebar.selection = sidebar.contents.entries.iter().position(|entry| {
10313            matches!(
10314                entry,
10315                ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&remote_thread_id)
10316            )
10317        });
10318    });
10319
10320    let saw_separate_project_header = Arc::new(std::sync::atomic::AtomicBool::new(false));
10321    let saw_separate_project_header_for_observer = saw_separate_project_header.clone();
10322
10323    sidebar
10324        .update(cx, |_, cx| {
10325            cx.observe_self(move |sidebar, _cx| {
10326                let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
10327                    if let ListEntry::ProjectHeader { label, .. } = entry {
10328                        Some(label.as_ref())
10329                    } else {
10330                        None
10331                    }
10332                });
10333
10334                let Some(project_header) = project_headers.next() else {
10335                    saw_separate_project_header_for_observer
10336                        .store(true, std::sync::atomic::Ordering::SeqCst);
10337                    return;
10338                };
10339
10340                if project_header != "project" || project_headers.next().is_some() {
10341                    saw_separate_project_header_for_observer
10342                        .store(true, std::sync::atomic::Ordering::SeqCst);
10343                }
10344            })
10345        })
10346        .detach();
10347
10348    multi_workspace.update(cx, |multi_workspace, cx| {
10349        let workspace = multi_workspace.workspace().clone();
10350        workspace.update(cx, |workspace: &mut Workspace, cx| {
10351            let remote_client = workspace
10352                .project()
10353                .read(cx)
10354                .remote_client()
10355                .expect("main remote project should have a remote client");
10356            remote_client.update(cx, |remote_client: &mut remote::RemoteClient, cx| {
10357                remote_client.force_server_not_running(cx);
10358            });
10359        });
10360    });
10361    cx.run_until_parked();
10362
10363    let (server_session_2, connect_guard_2) =
10364        remote::RemoteClient::fake_server_with_opts(&original_opts, cx, server_cx);
10365    let _headless_2 = server_cx.new(|cx| {
10366        remote_server::HeadlessProject::new(
10367            remote_server::HeadlessAppState {
10368                session: server_session_2,
10369                fs: server_fs.clone(),
10370                http_client: Arc::new(http_client::BlockedHttpClient),
10371                node_runtime: node_runtime::NodeRuntime::unavailable(),
10372                languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
10373                extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
10374                startup_time: std::time::Instant::now(),
10375            },
10376            false,
10377            cx,
10378        )
10379    });
10380    drop(connect_guard_2);
10381
10382    let window = cx.windows()[0];
10383    cx.update_window(window, |_, window, cx| {
10384        window.dispatch_action(Confirm.boxed_clone(), cx);
10385    })
10386    .unwrap();
10387
10388    cx.run_until_parked();
10389
10390    let new_workspace = multi_workspace.read_with(cx, |mw, _| {
10391        assert_eq!(
10392            mw.workspaces().count(),
10393            2,
10394            "confirming a closed remote thread should open a second workspace"
10395        );
10396        mw.workspaces()
10397            .find(|workspace| workspace.entity_id() != mw.workspace().entity_id())
10398            .unwrap()
10399            .clone()
10400    });
10401
10402    server_fs
10403        .add_linked_worktree_for_repo(
10404            Path::new("/project/.git"),
10405            true,
10406            git::repository::Worktree {
10407                path: PathBuf::from("/project-wt-1"),
10408                ref_name: Some("refs/heads/feature-wt".into()),
10409                sha: "abc123".into(),
10410                is_main: false,
10411                is_bare: false,
10412            },
10413        )
10414        .await;
10415
10416    server_cx.run_until_parked();
10417    cx.run_until_parked();
10418    server_cx.run_until_parked();
10419    cx.run_until_parked();
10420
10421    let entries_after_update = visible_entries_as_strings(&sidebar, cx);
10422    let group_after_update = new_workspace.read_with(cx, |workspace, cx| {
10423        workspace.project().read(cx).project_group_key(cx)
10424    });
10425
10426    assert_eq!(
10427        group_after_update,
10428        project.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)),
10429        "expected the remote worktree workspace to be grouped under the main remote project after the real update; \
10430         final sidebar entries: {:?}",
10431        entries_after_update,
10432    );
10433
10434    sidebar.update(cx, |sidebar, _cx| {
10435        assert_remote_project_integration_sidebar_state(
10436            sidebar,
10437            &main_thread_id,
10438            &remote_thread_id,
10439        );
10440    });
10441
10442    assert!(
10443        !saw_separate_project_header.load(std::sync::atomic::Ordering::SeqCst),
10444        "sidebar briefly rendered the remote worktree as a separate project during the real remote open/update sequence; \
10445         final group: {:?}; final sidebar entries: {:?}",
10446        group_after_update,
10447        entries_after_update,
10448    );
10449}
10450
10451#[gpui::test]
10452async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) {
10453    // When the thread's folder_paths don't exactly match any workspace's
10454    // root paths (e.g. because a folder was added to the workspace after
10455    // the thread was created), workspace_to_remove is None. But the linked
10456    // worktree workspace still needs to be removed so that its worktree
10457    // entities are released, allowing git worktree removal to proceed.
10458    //
10459    // With the fix, archive_thread scans roots_to_archive for any linked
10460    // worktree workspaces and includes them in the removal set, even when
10461    // the thread's folder_paths don't match the workspace's root paths.
10462    init_test(cx);
10463    let fs = FakeFs::new(cx.executor());
10464
10465    fs.insert_tree(
10466        "/project",
10467        serde_json::json!({
10468            ".git": {
10469                "worktrees": {
10470                    "feature-a": {
10471                        "commondir": "../../",
10472                        "HEAD": "ref: refs/heads/feature-a",
10473                    },
10474                },
10475            },
10476            "src": {},
10477        }),
10478    )
10479    .await;
10480
10481    fs.insert_tree(
10482        "/worktrees/project/feature-a/project",
10483        serde_json::json!({
10484            ".git": "gitdir: /project/.git/worktrees/feature-a",
10485            "src": {
10486                "main.rs": "fn main() {}",
10487            },
10488        }),
10489    )
10490    .await;
10491
10492    fs.add_linked_worktree_for_repo(
10493        Path::new("/project/.git"),
10494        false,
10495        git::repository::Worktree {
10496            path: PathBuf::from("/worktrees/project/feature-a/project"),
10497            ref_name: Some("refs/heads/feature-a".into()),
10498            sha: "abc".into(),
10499            is_main: false,
10500            is_bare: false,
10501        },
10502    )
10503    .await;
10504
10505    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10506
10507    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
10508    let worktree_project = project::Project::test(
10509        fs.clone(),
10510        ["/worktrees/project/feature-a/project".as_ref()],
10511        cx,
10512    )
10513    .await;
10514
10515    main_project
10516        .update(cx, |p, cx| p.git_scans_complete(cx))
10517        .await;
10518    worktree_project
10519        .update(cx, |p, cx| p.git_scans_complete(cx))
10520        .await;
10521
10522    let (multi_workspace, cx) =
10523        cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
10524    let sidebar = setup_sidebar(&multi_workspace, cx);
10525
10526    multi_workspace.update_in(cx, |mw, window, cx| {
10527        mw.test_add_workspace(worktree_project.clone(), window, cx)
10528    });
10529
10530    // Save thread metadata using folder_paths that DON'T match the
10531    // workspace's root paths. This simulates the case where the workspace's
10532    // paths diverged (e.g. a folder was added after thread creation).
10533    // This causes workspace_to_remove to be None because
10534    // workspace_for_paths can't find a workspace with these exact paths.
10535    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
10536    save_thread_metadata_with_main_paths(
10537        "worktree-thread",
10538        "Worktree Thread",
10539        PathList::new(&[
10540            PathBuf::from("/worktrees/project/feature-a/project"),
10541            PathBuf::from("/nonexistent"),
10542        ]),
10543        PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]),
10544        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10545        cx,
10546    );
10547
10548    // Also save a main thread so the sidebar has something to show.
10549    save_thread_metadata(
10550        acp::SessionId::new(Arc::from("main-thread")),
10551        Some("Main Thread".into()),
10552        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10553        None,
10554        None,
10555        &main_project,
10556        cx,
10557    );
10558    cx.run_until_parked();
10559
10560    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10561    cx.run_until_parked();
10562
10563    assert_eq!(
10564        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10565        2,
10566        "should start with 2 workspaces (main + linked worktree)"
10567    );
10568
10569    // Archive the worktree thread.
10570    sidebar.update_in(cx, |sidebar, window, cx| {
10571        sidebar.archive_thread(&wt_thread_id, window, cx);
10572    });
10573
10574    cx.run_until_parked();
10575
10576    // The linked worktree workspace should have been removed, even though
10577    // workspace_to_remove was None (paths didn't match).
10578    assert_eq!(
10579        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10580        1,
10581        "linked worktree workspace should be removed after archiving, \
10582         even when folder_paths don't match workspace root paths"
10583    );
10584
10585    // The thread should still be archived (not unarchived due to an error).
10586    let still_archived = cx.update(|_, cx| {
10587        ThreadMetadataStore::global(cx)
10588            .read(cx)
10589            .entry_by_session(&wt_thread_id)
10590            .map(|t| t.archived)
10591    });
10592    assert_eq!(
10593        still_archived,
10594        Some(true),
10595        "thread should still be archived (not rolled back due to error)"
10596    );
10597
10598    // The linked worktree directory should be removed from disk.
10599    assert!(
10600        !fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
10601            .await,
10602        "linked worktree directory should be removed from disk"
10603    );
10604}
10605
10606#[gpui::test]
10607async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &mut TestAppContext) {
10608    // When a workspace contains both a worktree being archived and other
10609    // worktrees that should remain, only the editor items referencing the
10610    // archived worktree should be closed — the workspace itself must be
10611    // preserved.
10612    init_test(cx);
10613    let fs = FakeFs::new(cx.executor());
10614
10615    fs.insert_tree(
10616        "/main-repo",
10617        serde_json::json!({
10618            ".git": {
10619                "worktrees": {
10620                    "feature-b": {
10621                        "commondir": "../../",
10622                        "HEAD": "ref: refs/heads/feature-b",
10623                    },
10624                },
10625            },
10626            "src": {
10627                "lib.rs": "pub fn hello() {}",
10628            },
10629        }),
10630    )
10631    .await;
10632
10633    fs.insert_tree(
10634        "/worktrees/main-repo/feature-b/main-repo",
10635        serde_json::json!({
10636            ".git": "gitdir: /main-repo/.git/worktrees/feature-b",
10637            "src": {
10638                "main.rs": "fn main() { hello(); }",
10639            },
10640        }),
10641    )
10642    .await;
10643
10644    fs.add_linked_worktree_for_repo(
10645        Path::new("/main-repo/.git"),
10646        false,
10647        git::repository::Worktree {
10648            path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10649            ref_name: Some("refs/heads/feature-b".into()),
10650            sha: "def".into(),
10651            is_main: false,
10652            is_bare: false,
10653        },
10654    )
10655    .await;
10656
10657    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
10658
10659    // Create a single project that contains BOTH the main repo and the
10660    // linked worktree — this makes it a "mixed" workspace.
10661    let mixed_project = project::Project::test(
10662        fs.clone(),
10663        [
10664            "/main-repo".as_ref(),
10665            "/worktrees/main-repo/feature-b/main-repo".as_ref(),
10666        ],
10667        cx,
10668    )
10669    .await;
10670
10671    mixed_project
10672        .update(cx, |p, cx| p.git_scans_complete(cx))
10673        .await;
10674
10675    let (multi_workspace, cx) = cx
10676        .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx));
10677    let sidebar = setup_sidebar(&multi_workspace, cx);
10678
10679    // Open editor items in both worktrees so we can verify which ones
10680    // get closed.
10681    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
10682
10683    let worktree_ids: Vec<(WorktreeId, Arc<Path>)> = workspace.read_with(cx, |ws, cx| {
10684        ws.project()
10685            .read(cx)
10686            .visible_worktrees(cx)
10687            .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
10688            .collect()
10689    });
10690
10691    let main_repo_wt_id = worktree_ids
10692        .iter()
10693        .find(|(_, path)| path.as_ref() == Path::new("/main-repo"))
10694        .map(|(id, _)| *id)
10695        .expect("should find main-repo worktree");
10696
10697    let feature_b_wt_id = worktree_ids
10698        .iter()
10699        .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo"))
10700        .map(|(id, _)| *id)
10701        .expect("should find feature-b worktree");
10702
10703    // Open files from both worktrees.
10704    let main_repo_path = project::ProjectPath {
10705        worktree_id: main_repo_wt_id,
10706        path: Arc::from(rel_path("src/lib.rs")),
10707    };
10708    let feature_b_path = project::ProjectPath {
10709        worktree_id: feature_b_wt_id,
10710        path: Arc::from(rel_path("src/main.rs")),
10711    };
10712
10713    workspace
10714        .update_in(cx, |ws, window, cx| {
10715            ws.open_path(main_repo_path.clone(), None, true, window, cx)
10716        })
10717        .await
10718        .expect("should open main-repo file");
10719    workspace
10720        .update_in(cx, |ws, window, cx| {
10721            ws.open_path(feature_b_path.clone(), None, true, window, cx)
10722        })
10723        .await
10724        .expect("should open feature-b file");
10725
10726    cx.run_until_parked();
10727
10728    // Verify both items are open.
10729    let open_paths_before: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10730        ws.panes()
10731            .iter()
10732            .flat_map(|pane| {
10733                pane.read(cx)
10734                    .items()
10735                    .filter_map(|item| item.project_path(cx))
10736            })
10737            .collect()
10738    });
10739    assert!(
10740        open_paths_before
10741            .iter()
10742            .any(|pp| pp.worktree_id == main_repo_wt_id),
10743        "main-repo file should be open"
10744    );
10745    assert!(
10746        open_paths_before
10747            .iter()
10748            .any(|pp| pp.worktree_id == feature_b_wt_id),
10749        "feature-b file should be open"
10750    );
10751
10752    // Save thread metadata for the linked worktree with deliberately
10753    // mismatched folder_paths to trigger the scan-based detection.
10754    save_thread_metadata_with_main_paths(
10755        "feature-b-thread",
10756        "Feature B Thread",
10757        PathList::new(&[
10758            PathBuf::from("/worktrees/main-repo/feature-b/main-repo"),
10759            PathBuf::from("/nonexistent"),
10760        ]),
10761        PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/nonexistent")]),
10762        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
10763        cx,
10764    );
10765
10766    // Save another thread that references only the main repo (not the
10767    // linked worktree) so archiving the feature-b thread's worktree isn't
10768    // blocked by another unarchived thread referencing the same path.
10769    save_thread_metadata_with_main_paths(
10770        "other-thread",
10771        "Other Thread",
10772        PathList::new(&[PathBuf::from("/main-repo")]),
10773        PathList::new(&[PathBuf::from("/main-repo")]),
10774        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
10775        cx,
10776    );
10777    cx.run_until_parked();
10778
10779    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
10780    cx.run_until_parked();
10781
10782    // There should still be exactly 1 workspace.
10783    assert_eq!(
10784        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10785        1,
10786        "should have 1 workspace (the mixed workspace)"
10787    );
10788
10789    // Archive the feature-b thread.
10790    let fb_session_id = acp::SessionId::new(Arc::from("feature-b-thread"));
10791    sidebar.update_in(cx, |sidebar, window, cx| {
10792        sidebar.archive_thread(&fb_session_id, window, cx);
10793    });
10794
10795    cx.run_until_parked();
10796
10797    // The workspace should still exist (it's "mixed" — has non-archived worktrees).
10798    assert_eq!(
10799        multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
10800        1,
10801        "mixed workspace should be preserved"
10802    );
10803
10804    // Only the feature-b editor item should have been closed.
10805    let open_paths_after: Vec<project::ProjectPath> = workspace.read_with(cx, |ws, cx| {
10806        ws.panes()
10807            .iter()
10808            .flat_map(|pane| {
10809                pane.read(cx)
10810                    .items()
10811                    .filter_map(|item| item.project_path(cx))
10812            })
10813            .collect()
10814    });
10815    assert!(
10816        open_paths_after
10817            .iter()
10818            .any(|pp| pp.worktree_id == main_repo_wt_id),
10819        "main-repo file should still be open"
10820    );
10821    assert!(
10822        !open_paths_after
10823            .iter()
10824            .any(|pp| pp.worktree_id == feature_b_wt_id),
10825        "feature-b file should have been closed"
10826    );
10827}
10828
10829#[test]
10830fn test_worktree_info_branch_names_for_main_worktrees() {
10831    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10832    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10833
10834    let branch_by_path: HashMap<PathBuf, SharedString> =
10835        [(PathBuf::from("/projects/myapp"), "feature-x".into())]
10836            .into_iter()
10837            .collect();
10838
10839    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10840    assert_eq!(infos.len(), 1);
10841    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10842    assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
10843    assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10844}
10845
10846#[test]
10847fn test_worktree_info_branch_names_for_linked_worktrees() {
10848    let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10849    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
10850    let worktree_paths =
10851        WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
10852
10853    let branch_by_path: HashMap<PathBuf, SharedString> = [(
10854        PathBuf::from("/projects/myapp-feature"),
10855        "feature-branch".into(),
10856    )]
10857    .into_iter()
10858    .collect();
10859
10860    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10861    assert_eq!(infos.len(), 1);
10862    assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
10863    assert_eq!(
10864        infos[0].branch_name,
10865        Some(SharedString::from("feature-branch"))
10866    );
10867}
10868
10869#[test]
10870fn test_worktree_info_missing_branch_returns_none() {
10871    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
10872    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
10873
10874    let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
10875
10876    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
10877    assert_eq!(infos.len(), 1);
10878    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
10879    assert_eq!(infos[0].branch_name, None);
10880    assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
10881}
10882
10883#[gpui::test]
10884async fn test_remote_archive_thread_with_active_connection(
10885    cx: &mut TestAppContext,
10886    server_cx: &mut TestAppContext,
10887) {
10888    // End-to-end test of archiving a remote thread tied to a linked git
10889    // worktree. Archival should:
10890    //  1. Persist the worktree's git state via the remote repository RPCs
10891    //     (head_sha / create_archive_checkpoint / update_ref).
10892    //  2. Remove the linked worktree directory from the *remote* filesystem
10893    //     via the GitRemoveWorktree RPC.
10894    //  3. Mark the thread metadata archived and hide it from the sidebar.
10895    //
10896    // The mock remote transport only supports one live `RemoteClient` per
10897    // connection at a time (each client's `start_proxy` replaces the
10898    // previous server channel), so we can't split the main repo and the
10899    // linked worktree across two remote projects the way Zed does in
10900    // production. Opening both as visible worktrees of a single remote
10901    // project still exercises every interesting path of the archive flow
10902    // while staying within the mock's multiplexing limits.
10903    init_test(cx);
10904
10905    cx.update(|cx| {
10906        release_channel::init(semver::Version::new(0, 0, 0), cx);
10907    });
10908
10909    let app_state = cx.update(|cx| {
10910        let app_state = workspace::AppState::test(cx);
10911        workspace::init(app_state.clone(), cx);
10912        app_state
10913    });
10914
10915    server_cx.update(|cx| {
10916        release_channel::init(semver::Version::new(0, 0, 0), cx);
10917    });
10918
10919    // Set up the remote filesystem with a main repo and one linked worktree.
10920    let server_fs = FakeFs::new(server_cx.executor());
10921    server_fs
10922        .insert_tree(
10923            "/project",
10924            serde_json::json!({
10925                ".git": {
10926                    "worktrees": {
10927                        "feature-a": {
10928                            "commondir": "../../",
10929                            "HEAD": "ref: refs/heads/feature-a",
10930                        },
10931                    },
10932                },
10933                "src": { "main.rs": "fn main() {}" },
10934            }),
10935        )
10936        .await;
10937    server_fs
10938        .insert_tree(
10939            "/worktrees/project/feature-a/project",
10940            serde_json::json!({
10941                ".git": "gitdir: /project/.git/worktrees/feature-a",
10942                "src": { "lib.rs": "// feature" },
10943            }),
10944        )
10945        .await;
10946    server_fs
10947        .add_linked_worktree_for_repo(
10948            Path::new("/project/.git"),
10949            false,
10950            git::repository::Worktree {
10951                path: PathBuf::from("/worktrees/project/feature-a/project"),
10952                ref_name: Some("refs/heads/feature-a".into()),
10953                sha: "abc".into(),
10954                is_main: false,
10955                is_bare: false,
10956            },
10957        )
10958        .await;
10959    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
10960    server_fs.set_head_for_repo(
10961        Path::new("/project/.git"),
10962        &[("src/main.rs", "fn main() {}".into())],
10963        "head-sha",
10964    );
10965
10966    // Open a single remote project with both the main repo and the linked
10967    // worktree as visible worktrees. The mock transport doesn't multiplex
10968    // multiple `RemoteClient`s over one pooled connection cleanly (each
10969    // client's `start_proxy` clobbers the previous one's server channel),
10970    // so we can't build two separate `Project::remote` instances in this
10971    // test. Folding both worktrees into one project still exercises the
10972    // archive flow's interesting paths: `build_root_plan` classifies the
10973    // linked worktree correctly, and `find_or_create_repository` finds
10974    // the main repo live on that same project — avoiding the temp-project
10975    // fallback that would also run into the multiplexing limitation.
10976    let (project, _headless, _opts) = start_remote_project(
10977        &server_fs,
10978        Path::new("/project"),
10979        &app_state,
10980        None,
10981        cx,
10982        server_cx,
10983    )
10984    .await;
10985    project
10986        .update(cx, |project, cx| {
10987            project.find_or_create_worktree(
10988                Path::new("/worktrees/project/feature-a/project"),
10989                true,
10990                cx,
10991            )
10992        })
10993        .await
10994        .expect("should open linked worktree on remote");
10995    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
10996    cx.run_until_parked();
10997
10998    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
10999
11000    let (multi_workspace, cx) =
11001        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11002    let sidebar = setup_sidebar(&multi_workspace, cx);
11003
11004    // The worktree thread's (main_worktree_path, folder_path) pair points
11005    // the folder at the linked worktree checkout and the main at the
11006    // parent repo, so `build_root_plan` targets the linked worktree
11007    // specifically and knows which main repo owns it.
11008    let remote_connection = project.read_with(cx, |p, cx| p.remote_connection_options(cx));
11009    let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
11010    cx.update(|_window, cx| {
11011        let metadata = ThreadMetadata {
11012            thread_id: ThreadId::new(),
11013            session_id: Some(wt_thread_id.clone()),
11014            agent_id: agent::ZED_AGENT_ID.clone(),
11015            title: Some("Worktree Thread".into()),
11016            updated_at: chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
11017                .unwrap(),
11018            created_at: None,
11019            interacted_at: None,
11020            worktree_paths: WorktreePaths::from_path_lists(
11021                PathList::new(&[PathBuf::from("/project")]),
11022                PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]),
11023            )
11024            .unwrap(),
11025            archived: false,
11026            remote_connection,
11027        };
11028        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
11029    });
11030    cx.run_until_parked();
11031
11032    assert!(
11033        server_fs
11034            .is_dir(Path::new("/worktrees/project/feature-a/project"))
11035            .await,
11036        "linked worktree directory should exist on remote before archiving"
11037    );
11038
11039    sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
11040        sidebar.archive_thread(&wt_thread_id, window, cx);
11041    });
11042    cx.run_until_parked();
11043    server_cx.run_until_parked();
11044
11045    let is_archived = cx.update(|_window, cx| {
11046        ThreadMetadataStore::global(cx)
11047            .read(cx)
11048            .entry_by_session(&wt_thread_id)
11049            .map(|t| t.archived)
11050            .unwrap_or(false)
11051    });
11052    assert!(is_archived, "worktree thread should be archived");
11053
11054    assert!(
11055        !server_fs
11056            .is_dir(Path::new("/worktrees/project/feature-a/project"))
11057            .await,
11058        "linked worktree directory should be removed from remote fs \
11059         (the GitRemoveWorktree RPC runs `Repository::remove_worktree` \
11060         on the headless server, which deletes the directory via `Fs::remove_dir` \
11061         before running `git worktree remove --force`)"
11062    );
11063
11064    let entries = visible_entries_as_strings(&sidebar, cx);
11065    assert!(
11066        !entries.iter().any(|e| e.contains("Worktree Thread")),
11067        "archived worktree thread should be hidden from sidebar: {entries:?}"
11068    );
11069}
11070
11071#[gpui::test]
11072async fn test_remote_archive_thread_with_disconnected_remote(
11073    cx: &mut TestAppContext,
11074    server_cx: &mut TestAppContext,
11075) {
11076    // When a remote thread has no linked-worktree state to archive (only
11077    // a main worktree), archival is a pure metadata operation: no RPCs
11078    // are issued against the remote server. This must succeed even when
11079    // the connection has dropped out, because losing connectivity should
11080    // not block users from cleaning up their thread list.
11081    //
11082    // Threads that *do* have linked-worktree state require a live
11083    // connection to run the git worktree removal on the server; that
11084    // path is covered by `test_remote_archive_thread_with_active_connection`.
11085    init_test(cx);
11086
11087    cx.update(|cx| {
11088        release_channel::init(semver::Version::new(0, 0, 0), cx);
11089    });
11090
11091    let app_state = cx.update(|cx| {
11092        let app_state = workspace::AppState::test(cx);
11093        workspace::init(app_state.clone(), cx);
11094        app_state
11095    });
11096
11097    server_cx.update(|cx| {
11098        release_channel::init(semver::Version::new(0, 0, 0), cx);
11099    });
11100
11101    let server_fs = FakeFs::new(server_cx.executor());
11102    server_fs
11103        .insert_tree(
11104            "/project",
11105            serde_json::json!({
11106                ".git": {},
11107                "src": { "main.rs": "fn main() {}" },
11108            }),
11109        )
11110        .await;
11111    server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
11112
11113    let (project, _headless, _opts) = start_remote_project(
11114        &server_fs,
11115        Path::new("/project"),
11116        &app_state,
11117        None,
11118        cx,
11119        server_cx,
11120    )
11121    .await;
11122    let remote_client = project
11123        .read_with(cx, |project, _cx| project.remote_client())
11124        .expect("remote project should expose its client");
11125
11126    cx.update(|cx| <dyn fs::Fs>::set_global(app_state.fs.clone(), cx));
11127
11128    let (multi_workspace, cx) =
11129        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11130    let sidebar = setup_sidebar(&multi_workspace, cx);
11131
11132    let thread_id = acp::SessionId::new(Arc::from("remote-thread"));
11133    save_thread_metadata(
11134        thread_id.clone(),
11135        Some("Remote Thread".into()),
11136        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
11137        None,
11138        None,
11139        &project,
11140        cx,
11141    );
11142    cx.run_until_parked();
11143
11144    // Sanity-check: there is nothing on the remote fs outside the main
11145    // repo, so archival should not need to touch the server.
11146    assert!(
11147        !server_fs.is_dir(Path::new("/worktrees")).await,
11148        "no linked worktrees on the server before archiving"
11149    );
11150
11151    // Disconnect the remote connection before archiving. We don't
11152    // `run_until_parked` here because the disconnect itself triggers
11153    // reconnection work that can't complete in the test environment.
11154    remote_client.update(cx, |client, cx| {
11155        client.simulate_disconnect(cx).detach();
11156    });
11157
11158    sidebar.update_in(cx, |sidebar, window, cx| {
11159        sidebar.archive_thread(&thread_id, window, cx);
11160    });
11161    cx.run_until_parked();
11162
11163    let is_archived = cx.update(|_window, cx| {
11164        ThreadMetadataStore::global(cx)
11165            .read(cx)
11166            .entry_by_session(&thread_id)
11167            .map(|t| t.archived)
11168            .unwrap_or(false)
11169    });
11170    assert!(
11171        is_archived,
11172        "thread should be archived even when remote is disconnected"
11173    );
11174
11175    let entries = visible_entries_as_strings(&sidebar, cx);
11176    assert!(
11177        !entries.iter().any(|e| e.contains("Remote Thread")),
11178        "archived thread should be hidden from sidebar: {entries:?}"
11179    );
11180}
11181
11182#[gpui::test]
11183async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) {
11184    init_test(cx);
11185    let fs = FakeFs::new(cx.executor());
11186    fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
11187        .await;
11188    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11189        .await;
11190    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11191    let project = project::Project::test(fs, ["/project-a".as_ref()], cx).await;
11192
11193    let (multi_workspace, cx) =
11194        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11195
11196    // Set up the sidebar while the project is local. This registers the
11197    // WorktreePathsChanged subscription for the project.
11198    let _sidebar = setup_sidebar(&multi_workspace, cx);
11199
11200    let session_id = acp::SessionId::new(Arc::from("test-thread"));
11201    save_named_thread_metadata("test-thread", "My Thread", &project, cx).await;
11202
11203    let thread_id = cx.update(|_window, cx| {
11204        ThreadMetadataStore::global(cx)
11205            .read(cx)
11206            .entry_by_session(&session_id)
11207            .map(|e| e.thread_id)
11208            .expect("thread must be in the store")
11209    });
11210
11211    cx.update(|_window, cx| {
11212        let store = ThreadMetadataStore::global(cx);
11213        let entry = store.read(cx).entry(thread_id).unwrap();
11214        assert_eq!(
11215            entry.folder_paths().paths(),
11216            &[PathBuf::from("/project-a")],
11217            "thread must be saved with /project-a before collab"
11218        );
11219    });
11220
11221    // Transition the project into collab mode. The sidebar's subscription is
11222    // still active from when the project was local.
11223    project.update(cx, |project, _cx| {
11224        project.mark_as_collab_for_testing();
11225    });
11226
11227    // Adding a worktree fires WorktreePathsChanged with old_paths = {/project-a}.
11228    // The sidebar's subscription is still active, so move_thread_paths is called.
11229    // Without the is_via_collab() guard inside move_thread_paths, this would
11230    // update the stored thread paths from {/project-a} to {/project-a, /project-b}.
11231    project
11232        .update(cx, |project, cx| {
11233            project.find_or_create_worktree("/project-b", true, cx)
11234        })
11235        .await
11236        .expect("should add worktree");
11237    cx.run_until_parked();
11238
11239    cx.update(|_window, cx| {
11240        let store = ThreadMetadataStore::global(cx);
11241        let entry = store
11242            .read(cx)
11243            .entry(thread_id)
11244            .expect("thread must still exist");
11245        assert_eq!(
11246            entry.folder_paths().paths(),
11247            &[PathBuf::from("/project-a")],
11248            "thread path must not change when project is via collab"
11249        );
11250    });
11251}
11252
11253#[gpui::test]
11254async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace(
11255    cx: &mut TestAppContext,
11256) {
11257    // Regression test for: cmd-clicking a project group header should return
11258    // the user to the workspace they most recently had active in that group,
11259    // including workspaces rooted at a linked worktree.
11260    init_test(cx);
11261    let fs = FakeFs::new(cx.executor());
11262
11263    fs.insert_tree(
11264        "/project-a",
11265        serde_json::json!({
11266            ".git": {},
11267            "src": {},
11268        }),
11269    )
11270    .await;
11271    fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
11272        .await;
11273
11274    fs.add_linked_worktree_for_repo(
11275        Path::new("/project-a/.git"),
11276        false,
11277        git::repository::Worktree {
11278            path: std::path::PathBuf::from("/wt-feature-a"),
11279            ref_name: Some("refs/heads/feature-a".into()),
11280            sha: "aaa".into(),
11281            is_main: false,
11282            is_bare: false,
11283        },
11284    )
11285    .await;
11286
11287    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
11288
11289    let main_project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
11290    let worktree_project_a =
11291        project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
11292    let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
11293
11294    main_project_a
11295        .update(cx, |p, cx| p.git_scans_complete(cx))
11296        .await;
11297    worktree_project_a
11298        .update(cx, |p, cx| p.git_scans_complete(cx))
11299        .await;
11300
11301    // The multi-workspace starts with the main-paths workspace of group A
11302    // as the initially active workspace.
11303    let (multi_workspace, cx) = cx
11304        .add_window_view(|window, cx| MultiWorkspace::test_new(main_project_a.clone(), window, cx));
11305
11306    let sidebar = setup_sidebar(&multi_workspace, cx);
11307
11308    // Capture the initially active workspace (group A's main-paths workspace)
11309    // *before* registering additional workspaces, since `workspaces()` returns
11310    // retained workspaces in registration order — not activation order — and
11311    // the multi-workspace's starting workspace may not be retained yet.
11312    let main_workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11313
11314    // Register the linked-worktree workspace (group A) and the group-B
11315    // workspace. Both get retained by the multi-workspace.
11316    let worktree_workspace_a = multi_workspace.update_in(cx, |mw, window, cx| {
11317        mw.test_add_workspace(worktree_project_a.clone(), window, cx)
11318    });
11319    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
11320        mw.test_add_workspace(project_b.clone(), window, cx)
11321    });
11322
11323    cx.run_until_parked();
11324
11325    // Step 1: activate the linked-worktree workspace. The MultiWorkspace
11326    // records this as the last-active workspace for group A on its
11327    // ProjectGroupState. (We don't assert on the initial active workspace
11328    // because `test_add_workspace` may auto-activate newly registered
11329    // workspaces — what matters for this test is the explicit sequence of
11330    // activations below.)
11331    multi_workspace.update_in(cx, |mw, window, cx| {
11332        mw.activate(worktree_workspace_a.clone(), None, window, cx);
11333    });
11334    cx.run_until_parked();
11335    assert_eq!(
11336        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11337        worktree_workspace_a,
11338        "linked-worktree workspace should be active after step 1"
11339    );
11340
11341    // Step 2: switch to the workspace for group B. Group A's last-active
11342    // workspace remains the linked-worktree one (group B getting activated
11343    // records *its own* last-active workspace, not group A's).
11344    multi_workspace.update_in(cx, |mw, window, cx| {
11345        mw.activate(workspace_b.clone(), None, window, cx);
11346    });
11347    cx.run_until_parked();
11348    assert_eq!(
11349        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()),
11350        workspace_b,
11351        "group B's workspace should be active after step 2"
11352    );
11353
11354    // Step 3: simulate cmd-click on group A's header. The project group key
11355    // for group A is derived from the *main-paths* workspace (linked-worktree
11356    // workspaces share the same key because it normalizes to main-worktree
11357    // paths).
11358    let group_a_key = main_workspace_a.read_with(cx, |ws, cx| ws.project_group_key(cx));
11359    sidebar.update_in(cx, |sidebar, window, cx| {
11360        sidebar.activate_or_open_workspace_for_group(&group_a_key, window, cx);
11361    });
11362    cx.run_until_parked();
11363
11364    // Expected: we're back in the linked-worktree workspace, not the
11365    // main-paths one.
11366    let active_after_cmd_click = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
11367    assert_eq!(
11368        active_after_cmd_click, worktree_workspace_a,
11369        "cmd-click on group A's header should return to the last-active \
11370         linked-worktree workspace, not the main-paths workspace"
11371    );
11372    assert_ne!(
11373        active_after_cmd_click, main_workspace_a,
11374        "cmd-click must not fall back to the main-paths workspace when a \
11375         linked-worktree workspace was the last-active one for the group"
11376    );
11377}