sidebar_tests.rs

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