sidebar.rs

   1use acp_thread::ThreadStatus;
   2use action_log::DiffStats;
   3use agent_client_protocol::{self as acp};
   4use agent_settings::AgentSettings;
   5use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata};
   6use agent_ui::threads_archive_view::{
   7    ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
   8};
   9use agent_ui::{
  10    Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
  11};
  12use chrono::Utc;
  13
  14use editor::Editor;
  15use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
  16use gpui::{
  17    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState,
  18    Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px,
  19};
  20
  21use menu::{
  22    Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
  23};
  24use project::{Event as ProjectEvent, linked_worktree_short_name};
  25use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
  26use ui::utils::platform_title_bar_height;
  27
  28use settings::Settings as _;
  29use std::collections::{HashMap, HashSet};
  30use std::mem;
  31use std::rc::Rc;
  32use theme::ActiveTheme;
  33use ui::{
  34    AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
  35    PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
  36};
  37use util::ResultExt as _;
  38use util::path_list::PathList;
  39use workspace::{
  40    AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open,
  41    Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
  42    sidebar_side_context_menu,
  43};
  44
  45use zed_actions::OpenRecent;
  46use zed_actions::editor::{MoveDown, MoveUp};
  47
  48use zed_actions::agents_sidebar::FocusSidebarFilter;
  49
  50use crate::project_group_builder::ProjectGroupBuilder;
  51
  52mod project_group_builder;
  53
  54gpui::actions!(
  55    agents_sidebar,
  56    [
  57        /// Creates a new thread in the currently selected or active project group.
  58        NewThreadInGroup,
  59        /// Toggles between the thread list and the archive view.
  60        ToggleArchive,
  61    ]
  62);
  63
  64const DEFAULT_WIDTH: Pixels = px(300.0);
  65const MIN_WIDTH: Pixels = px(200.0);
  66const MAX_WIDTH: Pixels = px(800.0);
  67const DEFAULT_THREADS_SHOWN: usize = 5;
  68
  69#[derive(Debug, Default)]
  70enum SidebarView {
  71    #[default]
  72    ThreadList,
  73    Archive(Entity<ThreadsArchiveView>),
  74}
  75
  76#[derive(Clone, Debug)]
  77struct ActiveThreadInfo {
  78    session_id: acp::SessionId,
  79    title: SharedString,
  80    status: AgentThreadStatus,
  81    icon: IconName,
  82    icon_from_external_svg: Option<SharedString>,
  83    is_background: bool,
  84    is_title_generating: bool,
  85    diff_stats: DiffStats,
  86}
  87
  88impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
  89    fn from(info: &ActiveThreadInfo) -> Self {
  90        Self {
  91            session_id: info.session_id.clone(),
  92            work_dirs: None,
  93            title: Some(info.title.clone()),
  94            updated_at: Some(Utc::now()),
  95            created_at: Some(Utc::now()),
  96            meta: None,
  97        }
  98    }
  99}
 100
 101#[derive(Clone)]
 102enum ThreadEntryWorkspace {
 103    Open(Entity<Workspace>),
 104    Closed(PathList),
 105}
 106
 107#[derive(Clone)]
 108struct ThreadEntry {
 109    agent: Agent,
 110    session_info: acp_thread::AgentSessionInfo,
 111    icon: IconName,
 112    icon_from_external_svg: Option<SharedString>,
 113    status: AgentThreadStatus,
 114    workspace: ThreadEntryWorkspace,
 115    is_live: bool,
 116    is_background: bool,
 117    is_title_generating: bool,
 118    highlight_positions: Vec<usize>,
 119    worktree_name: Option<SharedString>,
 120    worktree_full_path: Option<SharedString>,
 121    worktree_highlight_positions: Vec<usize>,
 122    diff_stats: DiffStats,
 123}
 124
 125impl ThreadEntry {
 126    /// Updates this thread entry with active thread information.
 127    ///
 128    /// The existing [`ThreadEntry`] was likely deserialized from the database
 129    /// but if we have a correspond thread already loaded we want to apply the
 130    /// live information.
 131    fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
 132        self.session_info.title = Some(info.title.clone());
 133        self.status = info.status;
 134        self.icon = info.icon;
 135        self.icon_from_external_svg = info.icon_from_external_svg.clone();
 136        self.is_live = true;
 137        self.is_background = info.is_background;
 138        self.is_title_generating = info.is_title_generating;
 139        self.diff_stats = info.diff_stats;
 140    }
 141}
 142
 143#[derive(Clone)]
 144enum ListEntry {
 145    ProjectHeader {
 146        path_list: PathList,
 147        label: SharedString,
 148        workspace: Entity<Workspace>,
 149        highlight_positions: Vec<usize>,
 150        has_running_threads: bool,
 151        waiting_thread_count: usize,
 152        is_active: bool,
 153    },
 154    Thread(ThreadEntry),
 155    ViewMore {
 156        path_list: PathList,
 157        is_fully_expanded: bool,
 158    },
 159    NewThread {
 160        path_list: PathList,
 161        workspace: Entity<Workspace>,
 162        is_active_draft: bool,
 163    },
 164}
 165
 166impl From<ThreadEntry> for ListEntry {
 167    fn from(thread: ThreadEntry) -> Self {
 168        ListEntry::Thread(thread)
 169    }
 170}
 171
 172#[derive(Default)]
 173struct SidebarContents {
 174    entries: Vec<ListEntry>,
 175    notified_threads: HashSet<acp::SessionId>,
 176    project_header_indices: Vec<usize>,
 177    has_open_projects: bool,
 178}
 179
 180impl SidebarContents {
 181    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
 182        self.notified_threads.contains(session_id)
 183    }
 184}
 185
 186fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 187    let mut positions = Vec::new();
 188    let mut query_chars = query.chars().peekable();
 189
 190    for (byte_idx, candidate_char) in candidate.char_indices() {
 191        if let Some(&query_char) = query_chars.peek() {
 192            if candidate_char.eq_ignore_ascii_case(&query_char) {
 193                positions.push(byte_idx);
 194                query_chars.next();
 195            }
 196        } else {
 197            break;
 198        }
 199    }
 200
 201    if query_chars.peek().is_none() {
 202        Some(positions)
 203    } else {
 204        None
 205    }
 206}
 207
 208// TODO: The mapping from workspace root paths to git repositories needs a
 209// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
 210// thread persistence (which PathList is saved to the database), and thread
 211// querying (which PathList is used to read threads back). All of these need
 212// to agree on how repos are resolved for a given workspace, especially in
 213// multi-root and nested-repo configurations.
 214fn root_repository_snapshots(
 215    workspace: &Entity<Workspace>,
 216    cx: &App,
 217) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
 218    let path_list = workspace_path_list(workspace, cx);
 219    let project = workspace.read(cx).project().read(cx);
 220    project.repositories(cx).values().filter_map(move |repo| {
 221        let snapshot = repo.read(cx).snapshot();
 222        let is_root = path_list
 223            .paths()
 224            .iter()
 225            .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
 226        is_root.then_some(snapshot)
 227    })
 228}
 229
 230fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
 231    PathList::new(&workspace.read(cx).root_paths(cx))
 232}
 233
 234/// The sidebar re-derives its entire entry list from scratch on every
 235/// change via `update_entries` → `rebuild_contents`. Avoid adding
 236/// incremental or inter-event coordination state — if something can
 237/// be computed from the current world state, compute it in the rebuild.
 238pub struct Sidebar {
 239    multi_workspace: WeakEntity<MultiWorkspace>,
 240    width: Pixels,
 241    focus_handle: FocusHandle,
 242    filter_editor: Entity<Editor>,
 243    list_state: ListState,
 244    contents: SidebarContents,
 245    /// The index of the list item that currently has the keyboard focus
 246    ///
 247    /// Note: This is NOT the same as the active item.
 248    selection: Option<usize>,
 249    /// Derived from the active panel's thread in `rebuild_contents`.
 250    /// Only updated when the panel returns `Some` — never cleared by
 251    /// derivation, since the panel may transiently return `None` while
 252    /// loading. User actions may write directly for immediate feedback.
 253    focused_thread: Option<acp::SessionId>,
 254    agent_panel_visible: bool,
 255    active_thread_is_draft: bool,
 256    hovered_thread_index: Option<usize>,
 257    collapsed_groups: HashSet<PathList>,
 258    expanded_groups: HashMap<PathList, usize>,
 259    view: SidebarView,
 260    recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
 261    project_header_menu_ix: Option<usize>,
 262    _subscriptions: Vec<gpui::Subscription>,
 263    _draft_observation: Option<gpui::Subscription>,
 264}
 265
 266impl Sidebar {
 267    pub fn new(
 268        multi_workspace: Entity<MultiWorkspace>,
 269        window: &mut Window,
 270        cx: &mut Context<Self>,
 271    ) -> Self {
 272        let focus_handle = cx.focus_handle();
 273        cx.on_focus_in(&focus_handle, window, Self::focus_in)
 274            .detach();
 275
 276        let filter_editor = cx.new(|cx| {
 277            let mut editor = Editor::single_line(window, cx);
 278            editor.set_use_modal_editing(true);
 279            editor.set_placeholder_text("Search…", window, cx);
 280            editor
 281        });
 282
 283        cx.subscribe_in(
 284            &multi_workspace,
 285            window,
 286            |this, multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
 287                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
 288                    this.observe_draft_editor(cx);
 289                    this.update_entries(cx);
 290                }
 291                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
 292                    this.subscribe_to_workspace(workspace, window, cx);
 293                    this.update_entries(cx);
 294                }
 295                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
 296                    this.update_entries(cx);
 297                }
 298                MultiWorkspaceEvent::SidebarCollapsedGroupsChanged => {
 299                    let groups: HashSet<PathList> = multi_workspace
 300                        .read(cx)
 301                        .sidebar_collapsed_groups()
 302                        .iter()
 303                        .map(|s| PathList::deserialize(s))
 304                        .collect();
 305                    if groups != this.collapsed_groups {
 306                        this.collapsed_groups = groups;
 307                        this.update_entries(cx);
 308                    }
 309                }
 310            },
 311        )
 312        .detach();
 313
 314        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 315            if let editor::EditorEvent::BufferEdited = event {
 316                let query = this.filter_editor.read(cx).text(cx);
 317                if !query.is_empty() {
 318                    this.selection.take();
 319                }
 320                this.update_entries(cx);
 321                if !query.is_empty() {
 322                    this.select_first_entry();
 323                }
 324            }
 325        })
 326        .detach();
 327
 328        cx.observe(
 329            &SidebarThreadMetadataStore::global(cx),
 330            |this, _store, cx| {
 331                this.update_entries(cx);
 332            },
 333        )
 334        .detach();
 335
 336        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
 337            this.update_entries(cx);
 338        })
 339        .detach();
 340
 341        let collapsed_groups: HashSet<PathList> = multi_workspace
 342            .read(cx)
 343            .sidebar_collapsed_groups()
 344            .iter()
 345            .map(|s| PathList::deserialize(s))
 346            .collect();
 347
 348        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 349        cx.defer_in(window, move |this, window, cx| {
 350            for workspace in &workspaces {
 351                this.subscribe_to_workspace(workspace, window, cx);
 352            }
 353            this.update_entries(cx);
 354        });
 355
 356        Self {
 357            multi_workspace: multi_workspace.downgrade(),
 358            width: DEFAULT_WIDTH,
 359            focus_handle,
 360            filter_editor,
 361            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 362            contents: SidebarContents::default(),
 363            selection: None,
 364            focused_thread: None,
 365            agent_panel_visible: false,
 366            active_thread_is_draft: false,
 367            hovered_thread_index: None,
 368            collapsed_groups,
 369            expanded_groups: HashMap::new(),
 370            view: SidebarView::default(),
 371            recent_projects_popover_handle: PopoverMenuHandle::default(),
 372            project_header_menu_ix: None,
 373            _subscriptions: Vec::new(),
 374            _draft_observation: None,
 375        }
 376    }
 377
 378    fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
 379        self.multi_workspace
 380            .upgrade()
 381            .map_or(false, |mw| mw.read(cx).workspace() == workspace)
 382    }
 383
 384    fn subscribe_to_workspace(
 385        &mut self,
 386        workspace: &Entity<Workspace>,
 387        window: &mut Window,
 388        cx: &mut Context<Self>,
 389    ) {
 390        let project = workspace.read(cx).project().clone();
 391        cx.subscribe_in(
 392            &project,
 393            window,
 394            |this, _project, event, _window, cx| match event {
 395                ProjectEvent::WorktreeAdded(_)
 396                | ProjectEvent::WorktreeRemoved(_)
 397                | ProjectEvent::WorktreeOrderChanged => {
 398                    this.update_entries(cx);
 399                }
 400                _ => {}
 401            },
 402        )
 403        .detach();
 404
 405        let git_store = workspace.read(cx).project().read(cx).git_store().clone();
 406        cx.subscribe_in(
 407            &git_store,
 408            window,
 409            |this, _, event: &project::git_store::GitStoreEvent, window, cx| {
 410                if matches!(
 411                    event,
 412                    project::git_store::GitStoreEvent::RepositoryUpdated(
 413                        _,
 414                        project::git_store::RepositoryEvent::GitWorktreeListChanged,
 415                        _,
 416                    )
 417                ) {
 418                    this.prune_stale_worktree_workspaces(window, cx);
 419                    this.update_entries(cx);
 420                }
 421            },
 422        )
 423        .detach();
 424
 425        cx.subscribe_in(
 426            workspace,
 427            window,
 428            |this, _workspace, event: &workspace::Event, window, cx| {
 429                if let workspace::Event::PanelAdded(view) = event {
 430                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
 431                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
 432                    }
 433                }
 434            },
 435        )
 436        .detach();
 437
 438        self.observe_docks(workspace, cx);
 439
 440        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 441            self.subscribe_to_agent_panel(&agent_panel, window, cx);
 442            if self.is_active_workspace(workspace, cx) {
 443                self.agent_panel_visible = AgentPanel::is_visible(workspace, cx);
 444            }
 445            self.observe_draft_editor(cx);
 446        }
 447    }
 448
 449    fn subscribe_to_agent_panel(
 450        &mut self,
 451        agent_panel: &Entity<AgentPanel>,
 452        window: &mut Window,
 453        cx: &mut Context<Self>,
 454    ) {
 455        cx.subscribe_in(
 456            agent_panel,
 457            window,
 458            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
 459                AgentPanelEvent::ActiveViewChanged => {
 460                    let is_new_draft = agent_panel
 461                        .read(cx)
 462                        .active_conversation_view()
 463                        .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
 464                    if is_new_draft {
 465                        this.focused_thread = None;
 466                    }
 467                    this.observe_draft_editor(cx);
 468                    this.update_entries(cx);
 469                }
 470                AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => {
 471                    this.update_entries(cx);
 472                }
 473            },
 474        )
 475        .detach();
 476    }
 477
 478    fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
 479        let docks: Vec<_> = workspace
 480            .read(cx)
 481            .all_docks()
 482            .into_iter()
 483            .cloned()
 484            .collect();
 485        let workspace = workspace.downgrade();
 486        for dock in docks {
 487            let workspace = workspace.clone();
 488            cx.observe(&dock, move |this, _dock, cx| {
 489                let Some(workspace) = workspace.upgrade() else {
 490                    return;
 491                };
 492                if !this.is_active_workspace(&workspace, cx) {
 493                    return;
 494                }
 495
 496                let is_visible = AgentPanel::is_visible(&workspace, cx);
 497
 498                if this.agent_panel_visible != is_visible {
 499                    this.agent_panel_visible = is_visible;
 500                    cx.notify();
 501                }
 502            })
 503            .detach();
 504        }
 505    }
 506
 507    fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
 508        self._draft_observation = self
 509            .multi_workspace
 510            .upgrade()
 511            .and_then(|mw| {
 512                let ws = mw.read(cx).workspace();
 513                ws.read(cx).panel::<AgentPanel>(cx)
 514            })
 515            .and_then(|panel| {
 516                let cv = panel.read(cx).active_conversation_view()?;
 517                let tv = cv.read(cx).active_thread()?;
 518                Some(tv.read(cx).message_editor.clone())
 519            })
 520            .map(|editor| {
 521                cx.observe(&editor, |_this, _editor, cx| {
 522                    cx.notify();
 523                })
 524            });
 525    }
 526
 527    fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
 528        let mw = self.multi_workspace.upgrade()?;
 529        let workspace = mw.read(cx).workspace();
 530        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
 531        let conversation_view = panel.read(cx).active_conversation_view()?;
 532        let thread_view = conversation_view.read(cx).active_thread()?;
 533        let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
 534        let cleaned = Self::clean_mention_links(&raw);
 535        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
 536        if text.is_empty() {
 537            None
 538        } else {
 539            const MAX_CHARS: usize = 250;
 540            if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
 541                text.truncate(truncate_at);
 542            }
 543            Some(text.into())
 544        }
 545    }
 546
 547    fn clean_mention_links(input: &str) -> String {
 548        let mut result = String::with_capacity(input.len());
 549        let mut remaining = input;
 550
 551        while let Some(start) = remaining.find("[@") {
 552            result.push_str(&remaining[..start]);
 553            let after_bracket = &remaining[start + 1..]; // skip '['
 554            if let Some(close_bracket) = after_bracket.find("](") {
 555                let mention = &after_bracket[..close_bracket]; // "@something"
 556                let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
 557                if let Some(close_paren) = after_link_start.find(')') {
 558                    result.push_str(mention);
 559                    remaining = &after_link_start[close_paren + 1..];
 560                    continue;
 561                }
 562            }
 563            // Couldn't parse full link syntax — emit the literal "[@" and move on.
 564            result.push_str("[@");
 565            remaining = &remaining[start + 2..];
 566        }
 567        result.push_str(remaining);
 568        result
 569    }
 570
 571    /// Rebuilds the sidebar contents from current workspace and thread state.
 572    ///
 573    /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
 574    /// repository, then populates thread entries from the metadata store and
 575    /// merges live thread info from active agent panels.
 576    ///
 577    /// Aim for a single forward pass over workspaces and threads plus an
 578    /// O(T log T) sort. Avoid adding extra scans over the data.
 579    ///
 580    /// Properties:
 581    ///
 582    /// - Should always show every workspace in the multiworkspace
 583    ///     - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
 584    /// - Should always show every thread, associated with each workspace in the multiworkspace
 585    /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
 586    fn rebuild_contents(&mut self, cx: &App) {
 587        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 588            return;
 589        };
 590        let mw = multi_workspace.read(cx);
 591        let workspaces = mw.workspaces().to_vec();
 592        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 593
 594        let agent_server_store = workspaces
 595            .first()
 596            .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
 597
 598        let query = self.filter_editor.read(cx).text(cx);
 599
 600        // Re-derive agent_panel_visible from the active workspace so it stays
 601        // correct after workspace switches.
 602        self.agent_panel_visible = active_workspace
 603            .as_ref()
 604            .map_or(false, |ws| AgentPanel::is_visible(ws, cx));
 605
 606        // Derive active_thread_is_draft BEFORE focused_thread so we can
 607        // use it as a guard below.
 608        self.active_thread_is_draft = active_workspace
 609            .as_ref()
 610            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
 611            .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
 612
 613        // Derive focused_thread from the active workspace's agent panel.
 614        // Only update when the panel gives us a positive signal — if the
 615        // panel returns None (e.g. still loading after a thread activation),
 616        // keep the previous value so eager writes from user actions survive.
 617        let panel_focused = active_workspace
 618            .as_ref()
 619            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
 620            .and_then(|panel| {
 621                panel
 622                    .read(cx)
 623                    .active_conversation_view()
 624                    .and_then(|cv| cv.read(cx).parent_id(cx))
 625            });
 626        if panel_focused.is_some() && !self.active_thread_is_draft {
 627            self.focused_thread = panel_focused;
 628        }
 629
 630        let previous = mem::take(&mut self.contents);
 631
 632        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
 633            .entries
 634            .iter()
 635            .filter_map(|entry| match entry {
 636                ListEntry::Thread(thread) if thread.is_live => {
 637                    Some((thread.session_info.session_id.clone(), thread.status))
 638                }
 639                _ => None,
 640            })
 641            .collect();
 642
 643        let mut entries = Vec::new();
 644        let mut notified_threads = previous.notified_threads;
 645        let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
 646        let mut project_header_indices: Vec<usize> = Vec::new();
 647
 648        // Use ProjectGroupBuilder to canonically group workspaces by their
 649        // main git repository. This replaces the manual absorbed-workspace
 650        // detection that was here before.
 651        let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
 652
 653        let has_open_projects = workspaces
 654            .iter()
 655            .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
 656
 657        let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option<SharedString>) {
 658            match &row.agent_id {
 659                None => (Agent::NativeAgent, IconName::ZedAgent, None),
 660                Some(id) => {
 661                    let custom_icon = agent_server_store
 662                        .as_ref()
 663                        .and_then(|store| store.read(cx).agent_icon(id));
 664                    (
 665                        Agent::Custom { id: id.clone() },
 666                        IconName::Terminal,
 667                        custom_icon,
 668                    )
 669                }
 670            }
 671        };
 672
 673        for (group_name, group) in project_groups.groups() {
 674            let path_list = group_name.path_list().clone();
 675            if path_list.paths().is_empty() {
 676                continue;
 677            }
 678
 679            let label = group_name.display_name();
 680
 681            let is_collapsed = self.collapsed_groups.contains(&path_list);
 682            let should_load_threads = !is_collapsed || !query.is_empty();
 683
 684            let is_active = active_workspace
 685                .as_ref()
 686                .is_some_and(|active| group.workspaces.contains(active));
 687
 688            // Pick a representative workspace for the group: prefer the active
 689            // workspace if it belongs to this group, otherwise use the first.
 690            //
 691            // This is the workspace that will be activated by the project group
 692            // header.
 693            let representative_workspace = active_workspace
 694                .as_ref()
 695                .filter(|_| is_active)
 696                .unwrap_or_else(|| group.first_workspace());
 697
 698            // Collect live thread infos from all workspaces in this group.
 699            let live_infos: Vec<_> = group
 700                .workspaces
 701                .iter()
 702                .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
 703                .collect();
 704
 705            let mut threads: Vec<ThreadEntry> = Vec::new();
 706            let mut has_running_threads = false;
 707            let mut waiting_thread_count: usize = 0;
 708
 709            if should_load_threads {
 710                let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
 711                let thread_store = SidebarThreadMetadataStore::global(cx);
 712
 713                // Load threads from each workspace in the group.
 714                for workspace in &group.workspaces {
 715                    let ws_path_list = workspace_path_list(workspace, cx);
 716
 717                    // Determine if this workspace covers a git worktree (its
 718                    // path canonicalizes to the main repo, not itself). If so,
 719                    // threads from it get a worktree chip in the sidebar.
 720                    let worktree_info: Option<(SharedString, SharedString)> =
 721                        ws_path_list.paths().first().and_then(|path| {
 722                            let canonical = project_groups.canonicalize_path(path);
 723                            if canonical != path.as_path() {
 724                                let name =
 725                                    linked_worktree_short_name(canonical, path).unwrap_or_default();
 726                                let full_path: SharedString = path.display().to_string().into();
 727                                Some((name, full_path))
 728                            } else {
 729                                None
 730                            }
 731                        });
 732
 733                    let workspace_threads: Vec<_> = thread_store
 734                        .read(cx)
 735                        .entries_for_path(&ws_path_list)
 736                        .collect();
 737                    for thread in workspace_threads {
 738                        if !seen_session_ids.insert(thread.session_id.clone()) {
 739                            continue;
 740                        }
 741                        let (agent, icon, icon_from_external_svg) = resolve_agent(&thread);
 742                        threads.push(ThreadEntry {
 743                            agent,
 744                            session_info: acp_thread::AgentSessionInfo {
 745                                session_id: thread.session_id.clone(),
 746                                work_dirs: None,
 747                                title: Some(thread.title.clone()),
 748                                updated_at: Some(thread.updated_at),
 749                                created_at: thread.created_at,
 750                                meta: None,
 751                            },
 752                            icon,
 753                            icon_from_external_svg,
 754                            status: AgentThreadStatus::default(),
 755                            workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 756                            is_live: false,
 757                            is_background: false,
 758                            is_title_generating: false,
 759                            highlight_positions: Vec::new(),
 760                            worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()),
 761                            worktree_full_path: worktree_info
 762                                .as_ref()
 763                                .map(|(_, path)| path.clone()),
 764                            worktree_highlight_positions: Vec::new(),
 765                            diff_stats: DiffStats::default(),
 766                        });
 767                    }
 768                }
 769
 770                // Load threads from linked git worktrees that don't have an
 771                // open workspace in this group. Only include worktrees that
 772                // belong to this group (not shared with another group).
 773                let linked_worktree_path_lists = group
 774                    .workspaces
 775                    .iter()
 776                    .flat_map(|ws| root_repository_snapshots(ws, cx))
 777                    .filter(|snapshot| !snapshot.is_linked_worktree())
 778                    .flat_map(|snapshot| {
 779                        snapshot
 780                            .linked_worktrees()
 781                            .iter()
 782                            .filter(|wt| {
 783                                project_groups.group_owns_worktree(group, &path_list, &wt.path)
 784                            })
 785                            .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
 786                            .collect::<Vec<_>>()
 787                    });
 788
 789                for worktree_path_list in linked_worktree_path_lists {
 790                    for row in thread_store.read(cx).entries_for_path(&worktree_path_list) {
 791                        if !seen_session_ids.insert(row.session_id.clone()) {
 792                            continue;
 793                        }
 794                        let worktree_info = row.folder_paths.paths().first().and_then(|path| {
 795                            let canonical = project_groups.canonicalize_path(path);
 796                            if canonical != path.as_path() {
 797                                let name =
 798                                    linked_worktree_short_name(canonical, path).unwrap_or_default();
 799                                let full_path: SharedString = path.display().to_string().into();
 800                                Some((name, full_path))
 801                            } else {
 802                                None
 803                            }
 804                        });
 805                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
 806                        threads.push(ThreadEntry {
 807                            agent,
 808                            session_info: acp_thread::AgentSessionInfo {
 809                                session_id: row.session_id.clone(),
 810                                work_dirs: None,
 811                                title: Some(row.title.clone()),
 812                                updated_at: Some(row.updated_at),
 813                                created_at: row.created_at,
 814                                meta: None,
 815                            },
 816                            icon,
 817                            icon_from_external_svg,
 818                            status: AgentThreadStatus::default(),
 819                            workspace: ThreadEntryWorkspace::Closed(row.folder_paths.clone()),
 820                            is_live: false,
 821                            is_background: false,
 822                            is_title_generating: false,
 823                            highlight_positions: Vec::new(),
 824                            worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()),
 825                            worktree_full_path: worktree_info.map(|(_, path)| path),
 826                            worktree_highlight_positions: Vec::new(),
 827                            diff_stats: DiffStats::default(),
 828                        });
 829                    }
 830                }
 831
 832                // Build a lookup from live_infos and compute running/waiting
 833                // counts in a single pass.
 834                let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
 835                    HashMap::new();
 836                for info in &live_infos {
 837                    live_info_by_session.insert(&info.session_id, info);
 838                    if info.status == AgentThreadStatus::Running {
 839                        has_running_threads = true;
 840                    }
 841                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 842                        waiting_thread_count += 1;
 843                    }
 844                }
 845
 846                // Merge live info into threads and update notification state
 847                // in a single pass.
 848                for thread in &mut threads {
 849                    if let Some(info) = live_info_by_session.get(&thread.session_info.session_id) {
 850                        thread.apply_active_info(info);
 851                    }
 852
 853                    let session_id = &thread.session_info.session_id;
 854
 855                    let is_thread_workspace_active = match &thread.workspace {
 856                        ThreadEntryWorkspace::Open(thread_workspace) => active_workspace
 857                            .as_ref()
 858                            .is_some_and(|active| active == thread_workspace),
 859                        ThreadEntryWorkspace::Closed(_) => false,
 860                    };
 861
 862                    if thread.status == AgentThreadStatus::Completed
 863                        && !is_thread_workspace_active
 864                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 865                    {
 866                        notified_threads.insert(session_id.clone());
 867                    }
 868
 869                    if is_thread_workspace_active && !thread.is_background {
 870                        notified_threads.remove(session_id);
 871                    }
 872                }
 873
 874                threads.sort_by(|a, b| {
 875                    let a_time = a.session_info.created_at.or(a.session_info.updated_at);
 876                    let b_time = b.session_info.created_at.or(b.session_info.updated_at);
 877                    b_time.cmp(&a_time)
 878                });
 879            } else {
 880                for info in live_infos {
 881                    if info.status == AgentThreadStatus::Running {
 882                        has_running_threads = true;
 883                    }
 884                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 885                        waiting_thread_count += 1;
 886                    }
 887                }
 888            }
 889
 890            if !query.is_empty() {
 891                let workspace_highlight_positions =
 892                    fuzzy_match_positions(&query, &label).unwrap_or_default();
 893                let workspace_matched = !workspace_highlight_positions.is_empty();
 894
 895                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
 896                for mut thread in threads {
 897                    let title = thread
 898                        .session_info
 899                        .title
 900                        .as_ref()
 901                        .map(|s| s.as_ref())
 902                        .unwrap_or("");
 903                    if let Some(positions) = fuzzy_match_positions(&query, title) {
 904                        thread.highlight_positions = positions;
 905                    }
 906                    if let Some(worktree_name) = &thread.worktree_name {
 907                        if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
 908                            thread.worktree_highlight_positions = positions;
 909                        }
 910                    }
 911                    let worktree_matched = !thread.worktree_highlight_positions.is_empty();
 912                    if workspace_matched
 913                        || !thread.highlight_positions.is_empty()
 914                        || worktree_matched
 915                    {
 916                        matched_threads.push(thread);
 917                    }
 918                }
 919
 920                if matched_threads.is_empty() && !workspace_matched {
 921                    continue;
 922                }
 923
 924                project_header_indices.push(entries.len());
 925                entries.push(ListEntry::ProjectHeader {
 926                    path_list: path_list.clone(),
 927                    label,
 928                    workspace: representative_workspace.clone(),
 929                    highlight_positions: workspace_highlight_positions,
 930                    has_running_threads,
 931                    waiting_thread_count,
 932                    is_active,
 933                });
 934
 935                for thread in matched_threads {
 936                    current_session_ids.insert(thread.session_info.session_id.clone());
 937                    entries.push(thread.into());
 938                }
 939            } else {
 940                let thread_count = threads.len();
 941                let is_draft_for_workspace = self.agent_panel_visible
 942                    && self.active_thread_is_draft
 943                    && self.focused_thread.is_none()
 944                    && is_active;
 945
 946                let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace;
 947
 948                project_header_indices.push(entries.len());
 949                entries.push(ListEntry::ProjectHeader {
 950                    path_list: path_list.clone(),
 951                    label,
 952                    workspace: representative_workspace.clone(),
 953                    highlight_positions: Vec::new(),
 954                    has_running_threads,
 955                    waiting_thread_count,
 956                    is_active,
 957                });
 958
 959                if is_collapsed {
 960                    continue;
 961                }
 962
 963                if show_new_thread_entry {
 964                    entries.push(ListEntry::NewThread {
 965                        path_list: path_list.clone(),
 966                        workspace: representative_workspace.clone(),
 967                        is_active_draft: is_draft_for_workspace,
 968                    });
 969                }
 970
 971                let total = threads.len();
 972
 973                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
 974                let threads_to_show =
 975                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
 976                let count = threads_to_show.min(total);
 977
 978                let mut promoted_threads: HashSet<acp::SessionId> = HashSet::new();
 979
 980                // Build visible entries in a single pass. Threads within
 981                // the cutoff are always shown. Threads beyond it are shown
 982                // only if they should be promoted (running, waiting, or
 983                // focused)
 984                for (index, thread) in threads.into_iter().enumerate() {
 985                    let is_hidden = index >= count;
 986
 987                    let session_id = &thread.session_info.session_id;
 988                    if is_hidden {
 989                        let is_promoted = thread.status == AgentThreadStatus::Running
 990                            || thread.status == AgentThreadStatus::WaitingForConfirmation
 991                            || notified_threads.contains(session_id)
 992                            || self
 993                                .focused_thread
 994                                .as_ref()
 995                                .is_some_and(|id| id == session_id);
 996                        if is_promoted {
 997                            promoted_threads.insert(session_id.clone());
 998                        }
 999                        if !promoted_threads.contains(session_id) {
1000                            continue;
1001                        }
1002                    }
1003
1004                    current_session_ids.insert(session_id.clone());
1005                    entries.push(thread.into());
1006                }
1007
1008                let visible = count + promoted_threads.len();
1009                let is_fully_expanded = visible >= total;
1010
1011                if total > DEFAULT_THREADS_SHOWN {
1012                    entries.push(ListEntry::ViewMore {
1013                        path_list: path_list.clone(),
1014                        is_fully_expanded,
1015                    });
1016                }
1017            }
1018        }
1019
1020        // Prune stale notifications using the session IDs we collected during
1021        // the build pass (no extra scan needed).
1022        notified_threads.retain(|id| current_session_ids.contains(id));
1023
1024        self.contents = SidebarContents {
1025            entries,
1026            notified_threads,
1027            project_header_indices,
1028            has_open_projects,
1029        };
1030    }
1031
1032    /// Rebuilds the sidebar's visible entries from already-cached state.
1033    fn update_entries(&mut self, cx: &mut Context<Self>) {
1034        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1035            return;
1036        };
1037        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1038            return;
1039        }
1040
1041        let had_notifications = self.has_notifications(cx);
1042        let scroll_position = self.list_state.logical_scroll_top();
1043
1044        self.rebuild_contents(cx);
1045
1046        self.list_state.reset(self.contents.entries.len());
1047        self.list_state.scroll_to(scroll_position);
1048
1049        if had_notifications != self.has_notifications(cx) {
1050            multi_workspace.update(cx, |_, cx| {
1051                cx.notify();
1052            });
1053        }
1054
1055        cx.notify();
1056    }
1057
1058    fn select_first_entry(&mut self) {
1059        self.selection = self
1060            .contents
1061            .entries
1062            .iter()
1063            .position(|entry| matches!(entry, ListEntry::Thread(_)))
1064            .or_else(|| {
1065                if self.contents.entries.is_empty() {
1066                    None
1067                } else {
1068                    Some(0)
1069                }
1070            });
1071    }
1072
1073    fn render_list_entry(
1074        &mut self,
1075        ix: usize,
1076        window: &mut Window,
1077        cx: &mut Context<Self>,
1078    ) -> AnyElement {
1079        let Some(entry) = self.contents.entries.get(ix) else {
1080            return div().into_any_element();
1081        };
1082        let is_focused = self.focus_handle.is_focused(window);
1083        // is_selected means the keyboard selector is here.
1084        let is_selected = is_focused && self.selection == Some(ix);
1085
1086        let is_group_header_after_first =
1087            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1088
1089        let rendered = match entry {
1090            ListEntry::ProjectHeader {
1091                path_list,
1092                label,
1093                workspace,
1094                highlight_positions,
1095                has_running_threads,
1096                waiting_thread_count,
1097                is_active,
1098            } => self.render_project_header(
1099                ix,
1100                false,
1101                path_list,
1102                label,
1103                workspace,
1104                highlight_positions,
1105                *has_running_threads,
1106                *waiting_thread_count,
1107                *is_active,
1108                is_selected,
1109                cx,
1110            ),
1111            ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
1112            ListEntry::ViewMore {
1113                path_list,
1114                is_fully_expanded,
1115            } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
1116            ListEntry::NewThread {
1117                path_list,
1118                workspace,
1119                is_active_draft,
1120            } => {
1121                self.render_new_thread(ix, path_list, workspace, *is_active_draft, is_selected, cx)
1122            }
1123        };
1124
1125        if is_group_header_after_first {
1126            v_flex()
1127                .w_full()
1128                .border_t_1()
1129                .border_color(cx.theme().colors().border.opacity(0.5))
1130                .child(rendered)
1131                .into_any_element()
1132        } else {
1133            rendered
1134        }
1135    }
1136
1137    fn render_project_header(
1138        &self,
1139        ix: usize,
1140        is_sticky: bool,
1141        path_list: &PathList,
1142        label: &SharedString,
1143        workspace: &Entity<Workspace>,
1144        highlight_positions: &[usize],
1145        has_running_threads: bool,
1146        waiting_thread_count: usize,
1147        is_active: bool,
1148        is_selected: bool,
1149        cx: &mut Context<Self>,
1150    ) -> AnyElement {
1151        let id_prefix = if is_sticky { "sticky-" } else { "" };
1152        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1153        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1154
1155        let is_collapsed = self.collapsed_groups.contains(path_list);
1156        let disclosure_icon = if is_collapsed {
1157            IconName::ChevronRight
1158        } else {
1159            IconName::ChevronDown
1160        };
1161
1162        let has_new_thread_entry = self
1163            .contents
1164            .entries
1165            .get(ix + 1)
1166            .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
1167        let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
1168
1169        let workspace_for_remove = workspace.clone();
1170        let workspace_for_menu = workspace.clone();
1171        let workspace_for_open = workspace.clone();
1172
1173        let path_list_for_toggle = path_list.clone();
1174        let path_list_for_collapse = path_list.clone();
1175        let view_more_expanded = self.expanded_groups.contains_key(path_list);
1176
1177        let label = if highlight_positions.is_empty() {
1178            Label::new(label.clone())
1179                .color(Color::Muted)
1180                .into_any_element()
1181        } else {
1182            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1183                .color(Color::Muted)
1184                .into_any_element()
1185        };
1186
1187        let color = cx.theme().colors();
1188        let hover_color = color
1189            .element_active
1190            .blend(color.element_background.opacity(0.2));
1191
1192        h_flex()
1193            .id(id)
1194            .group(&group_name)
1195            .h(Tab::content_height(cx))
1196            .w_full()
1197            .pl_1p5()
1198            .pr_1()
1199            .border_1()
1200            .map(|this| {
1201                if is_selected {
1202                    this.border_color(color.border_focused)
1203                } else {
1204                    this.border_color(gpui::transparent_black())
1205                }
1206            })
1207            .justify_between()
1208            .hover(|s| s.bg(hover_color))
1209            .child(
1210                h_flex()
1211                    .relative()
1212                    .min_w_0()
1213                    .w_full()
1214                    .gap_1p5()
1215                    .child(
1216                        h_flex().size_4().flex_none().justify_center().child(
1217                            Icon::new(disclosure_icon)
1218                                .size(IconSize::Small)
1219                                .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))),
1220                        ),
1221                    )
1222                    .child(label)
1223                    .when(is_collapsed, |this| {
1224                        this.when(has_running_threads, |this| {
1225                            this.child(
1226                                Icon::new(IconName::LoadCircle)
1227                                    .size(IconSize::XSmall)
1228                                    .color(Color::Muted)
1229                                    .with_rotate_animation(2),
1230                            )
1231                        })
1232                        .when(waiting_thread_count > 0, |this| {
1233                            let tooltip_text = if waiting_thread_count == 1 {
1234                                "1 thread is waiting for confirmation".to_string()
1235                            } else {
1236                                format!(
1237                                    "{waiting_thread_count} threads are waiting for confirmation",
1238                                )
1239                            };
1240                            this.child(
1241                                div()
1242                                    .id(format!("{id_prefix}waiting-indicator-{ix}"))
1243                                    .child(
1244                                        Icon::new(IconName::Warning)
1245                                            .size(IconSize::XSmall)
1246                                            .color(Color::Warning),
1247                                    )
1248                                    .tooltip(Tooltip::text(tooltip_text)),
1249                            )
1250                        })
1251                    }),
1252            )
1253            .child({
1254                let workspace_for_new_thread = workspace.clone();
1255                let path_list_for_new_thread = path_list.clone();
1256
1257                h_flex()
1258                    .when(self.project_header_menu_ix != Some(ix), |this| {
1259                        this.visible_on_hover(group_name)
1260                    })
1261                    .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1262                        cx.stop_propagation();
1263                    })
1264                    .child(self.render_project_header_menu(
1265                        ix,
1266                        id_prefix,
1267                        &workspace_for_menu,
1268                        &workspace_for_remove,
1269                        cx,
1270                    ))
1271                    .when(view_more_expanded && !is_collapsed, |this| {
1272                        this.child(
1273                            IconButton::new(
1274                                SharedString::from(format!(
1275                                    "{id_prefix}project-header-collapse-{ix}",
1276                                )),
1277                                IconName::ListCollapse,
1278                            )
1279                            .icon_size(IconSize::Small)
1280                            .icon_color(Color::Muted)
1281                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
1282                            .on_click(cx.listener({
1283                                let path_list_for_collapse = path_list_for_collapse.clone();
1284                                move |this, _, _window, cx| {
1285                                    this.selection = None;
1286                                    this.expanded_groups.remove(&path_list_for_collapse);
1287                                    this.update_entries(cx);
1288                                }
1289                            })),
1290                        )
1291                    })
1292                    .when(!is_active, |this| {
1293                        this.child(
1294                            IconButton::new(
1295                                SharedString::from(format!(
1296                                    "{id_prefix}project-header-open-workspace-{ix}",
1297                                )),
1298                                IconName::Focus,
1299                            )
1300                            .icon_size(IconSize::Small)
1301                            .icon_color(Color::Muted)
1302                            .tooltip(Tooltip::text("Activate Workspace"))
1303                            .on_click(cx.listener({
1304                                move |this, _, window, cx| {
1305                                    this.focused_thread = None;
1306                                    if let Some(multi_workspace) = this.multi_workspace.upgrade() {
1307                                        multi_workspace.update(cx, |multi_workspace, cx| {
1308                                            multi_workspace
1309                                                .activate(workspace_for_open.clone(), cx);
1310                                        });
1311                                    }
1312                                    if AgentPanel::is_visible(&workspace_for_open, cx) {
1313                                        workspace_for_open.update(cx, |workspace, cx| {
1314                                            workspace.focus_panel::<AgentPanel>(window, cx);
1315                                        });
1316                                    }
1317                                }
1318                            })),
1319                        )
1320                    })
1321                    .when(show_new_thread_button, |this| {
1322                        this.child(
1323                            IconButton::new(
1324                                SharedString::from(format!(
1325                                    "{id_prefix}project-header-new-thread-{ix}",
1326                                )),
1327                                IconName::Plus,
1328                            )
1329                            .icon_size(IconSize::Small)
1330                            .icon_color(Color::Muted)
1331                            .tooltip(Tooltip::text("New Thread"))
1332                            .on_click(cx.listener({
1333                                let workspace_for_new_thread = workspace_for_new_thread.clone();
1334                                let path_list_for_new_thread = path_list_for_new_thread.clone();
1335                                move |this, _, window, cx| {
1336                                    // Uncollapse the group if collapsed so
1337                                    // the new-thread entry becomes visible.
1338                                    this.set_group_collapsed(&path_list_for_new_thread, false, cx);
1339                                    this.selection = None;
1340                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
1341                                }
1342                            })),
1343                        )
1344                    })
1345            })
1346            .on_click(cx.listener(move |this, _, window, cx| {
1347                this.selection = None;
1348                this.toggle_collapse(&path_list_for_toggle, window, cx);
1349            }))
1350            .into_any_element()
1351    }
1352
1353    fn render_project_header_menu(
1354        &self,
1355        ix: usize,
1356        id_prefix: &str,
1357        workspace: &Entity<Workspace>,
1358        workspace_for_remove: &Entity<Workspace>,
1359        cx: &mut Context<Self>,
1360    ) -> impl IntoElement {
1361        let workspace_for_menu = workspace.clone();
1362        let workspace_for_remove = workspace_for_remove.clone();
1363        let multi_workspace = self.multi_workspace.clone();
1364        let this = cx.weak_entity();
1365
1366        PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1367            .on_open(Rc::new({
1368                let this = this.clone();
1369                move |_window, cx| {
1370                    this.update(cx, |sidebar, cx| {
1371                        sidebar.project_header_menu_ix = Some(ix);
1372                        cx.notify();
1373                    })
1374                    .ok();
1375                }
1376            }))
1377            .menu(move |window, cx| {
1378                let workspace = workspace_for_menu.clone();
1379                let workspace_for_remove = workspace_for_remove.clone();
1380                let multi_workspace = multi_workspace.clone();
1381
1382                let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
1383                    let worktrees: Vec<_> = workspace
1384                        .read(cx)
1385                        .visible_worktrees(cx)
1386                        .map(|worktree| {
1387                            let worktree_read = worktree.read(cx);
1388                            let id = worktree_read.id();
1389                            let name: SharedString =
1390                                worktree_read.root_name().as_unix_str().to_string().into();
1391                            (id, name)
1392                        })
1393                        .collect();
1394
1395                    let worktree_count = worktrees.len();
1396
1397                    let mut menu = menu
1398                        .header("Project Folders")
1399                        .end_slot_action(Box::new(menu::EndSlot));
1400
1401                    for (worktree_id, name) in &worktrees {
1402                        let worktree_id = *worktree_id;
1403                        let workspace_for_worktree = workspace.clone();
1404                        let workspace_for_remove_worktree = workspace_for_remove.clone();
1405                        let multi_workspace_for_worktree = multi_workspace.clone();
1406
1407                        let remove_handler = move |window: &mut Window, cx: &mut App| {
1408                            if worktree_count <= 1 {
1409                                if let Some(mw) = multi_workspace_for_worktree.upgrade() {
1410                                    let ws = workspace_for_remove_worktree.clone();
1411                                    mw.update(cx, |multi_workspace, cx| {
1412                                        if let Some(index) = multi_workspace
1413                                            .workspaces()
1414                                            .iter()
1415                                            .position(|w| *w == ws)
1416                                        {
1417                                            multi_workspace.remove_workspace(index, window, cx);
1418                                        }
1419                                    });
1420                                }
1421                            } else {
1422                                workspace_for_worktree.update(cx, |workspace, cx| {
1423                                    workspace.project().update(cx, |project, cx| {
1424                                        project.remove_worktree(worktree_id, cx);
1425                                    });
1426                                });
1427                            }
1428                        };
1429
1430                        menu = menu.entry_with_end_slot_on_hover(
1431                            name.clone(),
1432                            None,
1433                            |_, _| {},
1434                            IconName::Close,
1435                            "Remove Folder".into(),
1436                            remove_handler,
1437                        );
1438                    }
1439
1440                    let workspace_for_add = workspace.clone();
1441                    let multi_workspace_for_add = multi_workspace.clone();
1442                    let menu = menu.separator().entry(
1443                        "Add Folder to Project",
1444                        Some(Box::new(AddFolderToProject)),
1445                        move |window, cx| {
1446                            if let Some(mw) = multi_workspace_for_add.upgrade() {
1447                                mw.update(cx, |mw, cx| {
1448                                    mw.activate(workspace_for_add.clone(), cx);
1449                                });
1450                            }
1451                            workspace_for_add.update(cx, |workspace, cx| {
1452                                workspace.add_folder_to_project(&AddFolderToProject, window, cx);
1453                            });
1454                        },
1455                    );
1456
1457                    let workspace_count = multi_workspace
1458                        .upgrade()
1459                        .map_or(0, |mw| mw.read(cx).workspaces().len());
1460                    let menu = if workspace_count > 1 {
1461                        let workspace_for_move = workspace.clone();
1462                        let multi_workspace_for_move = multi_workspace.clone();
1463                        menu.entry(
1464                            "Move to New Window",
1465                            Some(Box::new(
1466                                zed_actions::agents_sidebar::MoveWorkspaceToNewWindow,
1467                            )),
1468                            move |window, cx| {
1469                                if let Some(mw) = multi_workspace_for_move.upgrade() {
1470                                    mw.update(cx, |multi_workspace, cx| {
1471                                        if let Some(index) = multi_workspace
1472                                            .workspaces()
1473                                            .iter()
1474                                            .position(|w| *w == workspace_for_move)
1475                                        {
1476                                            multi_workspace
1477                                                .move_workspace_to_new_window(index, window, cx);
1478                                        }
1479                                    });
1480                                }
1481                            },
1482                        )
1483                    } else {
1484                        menu
1485                    };
1486
1487                    let workspace_for_remove = workspace_for_remove.clone();
1488                    let multi_workspace_for_remove = multi_workspace.clone();
1489                    menu.separator()
1490                        .entry("Remove Project", None, move |window, cx| {
1491                            if let Some(mw) = multi_workspace_for_remove.upgrade() {
1492                                let ws = workspace_for_remove.clone();
1493                                mw.update(cx, |multi_workspace, cx| {
1494                                    if let Some(index) =
1495                                        multi_workspace.workspaces().iter().position(|w| *w == ws)
1496                                    {
1497                                        multi_workspace.remove_workspace(index, window, cx);
1498                                    }
1499                                });
1500                            }
1501                        })
1502                });
1503
1504                let this = this.clone();
1505                window
1506                    .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1507                        this.update(cx, |sidebar, cx| {
1508                            sidebar.project_header_menu_ix = None;
1509                            cx.notify();
1510                        })
1511                        .ok();
1512                    })
1513                    .detach();
1514
1515                Some(menu)
1516            })
1517            .trigger(
1518                IconButton::new(
1519                    SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1520                    IconName::Ellipsis,
1521                )
1522                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1523                .icon_size(IconSize::Small)
1524                .icon_color(Color::Muted),
1525            )
1526            .anchor(gpui::Corner::TopRight)
1527            .offset(gpui::Point {
1528                x: px(0.),
1529                y: px(1.),
1530            })
1531    }
1532
1533    fn render_sticky_header(
1534        &self,
1535        window: &mut Window,
1536        cx: &mut Context<Self>,
1537    ) -> Option<AnyElement> {
1538        let scroll_top = self.list_state.logical_scroll_top();
1539
1540        let &header_idx = self
1541            .contents
1542            .project_header_indices
1543            .iter()
1544            .rev()
1545            .find(|&&idx| idx <= scroll_top.item_ix)?;
1546
1547        let needs_sticky = header_idx < scroll_top.item_ix
1548            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1549
1550        if !needs_sticky {
1551            return None;
1552        }
1553
1554        let ListEntry::ProjectHeader {
1555            path_list,
1556            label,
1557            workspace,
1558            highlight_positions,
1559            has_running_threads,
1560            waiting_thread_count,
1561            is_active,
1562        } = self.contents.entries.get(header_idx)?
1563        else {
1564            return None;
1565        };
1566
1567        let is_focused = self.focus_handle.is_focused(window);
1568        let is_selected = is_focused && self.selection == Some(header_idx);
1569
1570        let header_element = self.render_project_header(
1571            header_idx,
1572            true,
1573            &path_list,
1574            &label,
1575            workspace,
1576            &highlight_positions,
1577            *has_running_threads,
1578            *waiting_thread_count,
1579            *is_active,
1580            is_selected,
1581            cx,
1582        );
1583
1584        let top_offset = self
1585            .contents
1586            .project_header_indices
1587            .iter()
1588            .find(|&&idx| idx > header_idx)
1589            .and_then(|&next_idx| {
1590                let bounds = self.list_state.bounds_for_item(next_idx)?;
1591                let viewport = self.list_state.viewport_bounds();
1592                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1593                let header_height = bounds.size.height;
1594                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1595            })
1596            .unwrap_or(px(0.));
1597
1598        let color = cx.theme().colors();
1599        let background = color
1600            .title_bar_background
1601            .blend(color.panel_background.opacity(0.2));
1602
1603        let element = v_flex()
1604            .absolute()
1605            .top(top_offset)
1606            .left_0()
1607            .w_full()
1608            .bg(background)
1609            .border_b_1()
1610            .border_color(color.border.opacity(0.5))
1611            .child(header_element)
1612            .shadow_xs()
1613            .into_any_element();
1614
1615        Some(element)
1616    }
1617
1618    fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1619        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1620            return;
1621        };
1622        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
1623
1624        // Collect all worktree paths that are currently listed by any main
1625        // repo open in any workspace.
1626        let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
1627        for workspace in &workspaces {
1628            for snapshot in root_repository_snapshots(workspace, cx) {
1629                if snapshot.is_linked_worktree() {
1630                    continue;
1631                }
1632                for git_worktree in snapshot.linked_worktrees() {
1633                    known_worktree_paths.insert(git_worktree.path.to_path_buf());
1634                }
1635            }
1636        }
1637
1638        // Find workspaces that consist of exactly one root folder which is a
1639        // stale worktree checkout. Multi-root workspaces are never pruned —
1640        // losing one worktree shouldn't destroy a workspace that also
1641        // contains other folders.
1642        let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
1643        for workspace in &workspaces {
1644            let path_list = workspace_path_list(workspace, cx);
1645            if path_list.paths().len() != 1 {
1646                continue;
1647            }
1648            let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| {
1649                snapshot.is_linked_worktree()
1650                    && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
1651            });
1652            if should_prune {
1653                to_remove.push(workspace.clone());
1654            }
1655        }
1656
1657        for workspace in &to_remove {
1658            self.remove_workspace(workspace, window, cx);
1659        }
1660    }
1661
1662    fn remove_workspace(
1663        &mut self,
1664        workspace: &Entity<Workspace>,
1665        window: &mut Window,
1666        cx: &mut Context<Self>,
1667    ) {
1668        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1669            return;
1670        };
1671
1672        multi_workspace.update(cx, |multi_workspace, cx| {
1673            let Some(index) = multi_workspace
1674                .workspaces()
1675                .iter()
1676                .position(|w| w == workspace)
1677            else {
1678                return;
1679            };
1680            multi_workspace.remove_workspace(index, window, cx);
1681        });
1682    }
1683
1684    fn set_group_collapsed(
1685        &mut self,
1686        path_list: &PathList,
1687        collapsed: bool,
1688        cx: &mut Context<Self>,
1689    ) {
1690        let changed = if collapsed {
1691            self.collapsed_groups.insert(path_list.clone())
1692        } else {
1693            self.collapsed_groups.remove(path_list)
1694        };
1695
1696        if changed {
1697            self.serialize_collapsed_groups(cx);
1698        }
1699    }
1700
1701    fn clear_collapsed_groups(&mut self, cx: &mut Context<Self>) {
1702        if self.collapsed_groups.is_empty() {
1703            return;
1704        }
1705
1706        self.collapsed_groups.clear();
1707        self.serialize_collapsed_groups(cx);
1708    }
1709
1710    fn serialize_collapsed_groups(&mut self, cx: &mut Context<Self>) {
1711        let known_path_lists: HashSet<&PathList> = self
1712            .contents
1713            .entries
1714            .iter()
1715            .filter_map(|entry| match entry {
1716                ListEntry::ProjectHeader { path_list, .. } => Some(path_list),
1717                _ => None,
1718            })
1719            .collect();
1720        let collapsed_groups = self
1721            .collapsed_groups
1722            .iter()
1723            .filter(|path_list| known_path_lists.contains(path_list))
1724            .map(PathList::serialize)
1725            .collect::<Vec<_>>();
1726        if let Some(multi_workspace) = self.multi_workspace.upgrade() {
1727            multi_workspace.update(cx, |mw, cx| {
1728                mw.set_sidebar_collapsed_groups(collapsed_groups, cx);
1729                mw.serialize(cx);
1730            });
1731        }
1732    }
1733
1734    fn toggle_collapse(
1735        &mut self,
1736        path_list: &PathList,
1737        _window: &mut Window,
1738        cx: &mut Context<Self>,
1739    ) {
1740        self.set_group_collapsed(path_list, !self.collapsed_groups.contains(path_list), cx);
1741        self.update_entries(cx);
1742    }
1743
1744    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
1745        let mut dispatch_context = KeyContext::new_with_defaults();
1746        dispatch_context.add("ThreadsSidebar");
1747        dispatch_context.add("menu");
1748
1749        let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) {
1750            "searching"
1751        } else {
1752            "not_searching"
1753        };
1754
1755        dispatch_context.add(identifier);
1756        dispatch_context
1757    }
1758
1759    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1760        if !self.focus_handle.is_focused(window) {
1761            return;
1762        }
1763
1764        if let SidebarView::Archive(archive) = &self.view {
1765            let has_selection = archive.read(cx).has_selection();
1766            if !has_selection {
1767                archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1768            }
1769        } else if self.selection.is_none() {
1770            self.filter_editor.focus_handle(cx).focus(window, cx);
1771        }
1772    }
1773
1774    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1775        if self.reset_filter_editor_text(window, cx) {
1776            self.update_entries(cx);
1777        } else {
1778            self.selection = None;
1779            self.filter_editor.focus_handle(cx).focus(window, cx);
1780            cx.notify();
1781        }
1782    }
1783
1784    fn focus_sidebar_filter(
1785        &mut self,
1786        _: &FocusSidebarFilter,
1787        window: &mut Window,
1788        cx: &mut Context<Self>,
1789    ) {
1790        self.selection = None;
1791        if let SidebarView::Archive(archive) = &self.view {
1792            archive.update(cx, |view, cx| {
1793                view.clear_selection();
1794                view.focus_filter_editor(window, cx);
1795            });
1796        } else {
1797            self.filter_editor.focus_handle(cx).focus(window, cx);
1798        }
1799
1800        // When vim mode is active, the editor defaults to normal mode which
1801        // blocks text input. Switch to insert mode so the user can type
1802        // immediately.
1803        if vim_mode_setting::VimModeSetting::get_global(cx).0 {
1804            if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
1805                window.dispatch_action(action, cx);
1806            }
1807        }
1808
1809        cx.notify();
1810    }
1811
1812    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1813        self.filter_editor.update(cx, |editor, cx| {
1814            if editor.buffer().read(cx).len(cx).0 > 0 {
1815                editor.set_text("", window, cx);
1816                true
1817            } else {
1818                false
1819            }
1820        })
1821    }
1822
1823    fn has_filter_query(&self, cx: &App) -> bool {
1824        !self.filter_editor.read(cx).text(cx).is_empty()
1825    }
1826
1827    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1828        self.select_next(&SelectNext, window, cx);
1829        if self.selection.is_some() {
1830            self.focus_handle.focus(window, cx);
1831        }
1832    }
1833
1834    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1835        self.select_previous(&SelectPrevious, window, cx);
1836        if self.selection.is_some() {
1837            self.focus_handle.focus(window, cx);
1838        }
1839    }
1840
1841    fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1842        if self.selection.is_none() {
1843            self.select_next(&SelectNext, window, cx);
1844        }
1845        if self.selection.is_some() {
1846            self.focus_handle.focus(window, cx);
1847        }
1848    }
1849
1850    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1851        let next = match self.selection {
1852            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1853            Some(_) if !self.contents.entries.is_empty() => 0,
1854            None if !self.contents.entries.is_empty() => 0,
1855            _ => return,
1856        };
1857        self.selection = Some(next);
1858        self.list_state.scroll_to_reveal_item(next);
1859        cx.notify();
1860    }
1861
1862    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1863        match self.selection {
1864            Some(0) => {
1865                self.selection = None;
1866                self.filter_editor.focus_handle(cx).focus(window, cx);
1867                cx.notify();
1868            }
1869            Some(ix) => {
1870                self.selection = Some(ix - 1);
1871                self.list_state.scroll_to_reveal_item(ix - 1);
1872                cx.notify();
1873            }
1874            None if !self.contents.entries.is_empty() => {
1875                let last = self.contents.entries.len() - 1;
1876                self.selection = Some(last);
1877                self.list_state.scroll_to_reveal_item(last);
1878                cx.notify();
1879            }
1880            None => {}
1881        }
1882    }
1883
1884    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1885        if !self.contents.entries.is_empty() {
1886            self.selection = Some(0);
1887            self.list_state.scroll_to_reveal_item(0);
1888            cx.notify();
1889        }
1890    }
1891
1892    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1893        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1894            self.selection = Some(last);
1895            self.list_state.scroll_to_reveal_item(last);
1896            cx.notify();
1897        }
1898    }
1899
1900    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1901        let Some(ix) = self.selection else { return };
1902        let Some(entry) = self.contents.entries.get(ix) else {
1903            return;
1904        };
1905
1906        match entry {
1907            ListEntry::ProjectHeader { path_list, .. } => {
1908                let path_list = path_list.clone();
1909                self.toggle_collapse(&path_list, window, cx);
1910            }
1911            ListEntry::Thread(thread) => {
1912                let session_info = thread.session_info.clone();
1913                match &thread.workspace {
1914                    ThreadEntryWorkspace::Open(workspace) => {
1915                        let workspace = workspace.clone();
1916                        self.activate_thread(
1917                            thread.agent.clone(),
1918                            session_info,
1919                            &workspace,
1920                            window,
1921                            cx,
1922                        );
1923                    }
1924                    ThreadEntryWorkspace::Closed(path_list) => {
1925                        self.open_workspace_and_activate_thread(
1926                            thread.agent.clone(),
1927                            session_info,
1928                            path_list.clone(),
1929                            window,
1930                            cx,
1931                        );
1932                    }
1933                }
1934            }
1935            ListEntry::ViewMore {
1936                path_list,
1937                is_fully_expanded,
1938                ..
1939            } => {
1940                let path_list = path_list.clone();
1941                if *is_fully_expanded {
1942                    self.expanded_groups.remove(&path_list);
1943                } else {
1944                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1945                    self.expanded_groups.insert(path_list, current + 1);
1946                }
1947                self.update_entries(cx);
1948            }
1949            ListEntry::NewThread { workspace, .. } => {
1950                let workspace = workspace.clone();
1951                self.create_new_thread(&workspace, window, cx);
1952            }
1953        }
1954    }
1955
1956    fn find_workspace_across_windows(
1957        &self,
1958        cx: &App,
1959        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1960    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
1961        cx.windows()
1962            .into_iter()
1963            .filter_map(|window| window.downcast::<MultiWorkspace>())
1964            .find_map(|window| {
1965                let workspace = window.read(cx).ok().and_then(|multi_workspace| {
1966                    multi_workspace
1967                        .workspaces()
1968                        .iter()
1969                        .find(|workspace| predicate(workspace, cx))
1970                        .cloned()
1971                })?;
1972                Some((window, workspace))
1973            })
1974    }
1975
1976    fn find_workspace_in_current_window(
1977        &self,
1978        cx: &App,
1979        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1980    ) -> Option<Entity<Workspace>> {
1981        self.multi_workspace.upgrade().and_then(|multi_workspace| {
1982            multi_workspace
1983                .read(cx)
1984                .workspaces()
1985                .iter()
1986                .find(|workspace| predicate(workspace, cx))
1987                .cloned()
1988        })
1989    }
1990
1991    fn load_agent_thread_in_workspace(
1992        workspace: &Entity<Workspace>,
1993        agent: Agent,
1994        session_info: acp_thread::AgentSessionInfo,
1995        window: &mut Window,
1996        cx: &mut App,
1997    ) {
1998        workspace.update(cx, |workspace, cx| {
1999            workspace.open_panel::<AgentPanel>(window, cx);
2000        });
2001
2002        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2003            agent_panel.update(cx, |panel, cx| {
2004                panel.load_agent_thread(
2005                    agent,
2006                    session_info.session_id,
2007                    session_info.work_dirs,
2008                    session_info.title,
2009                    true,
2010                    window,
2011                    cx,
2012                );
2013            });
2014        }
2015    }
2016
2017    fn activate_thread_locally(
2018        &mut self,
2019        agent: Agent,
2020        session_info: acp_thread::AgentSessionInfo,
2021        workspace: &Entity<Workspace>,
2022        window: &mut Window,
2023        cx: &mut Context<Self>,
2024    ) {
2025        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2026            return;
2027        };
2028
2029        // Set focused_thread eagerly so the sidebar highlight updates
2030        // immediately, rather than waiting for a deferred AgentPanel
2031        // event which can race with ActiveWorkspaceChanged clearing it.
2032        self.focused_thread = Some(session_info.session_id.clone());
2033
2034        multi_workspace.update(cx, |multi_workspace, cx| {
2035            multi_workspace.activate(workspace.clone(), cx);
2036        });
2037
2038        Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx);
2039
2040        self.update_entries(cx);
2041    }
2042
2043    fn activate_thread_in_other_window(
2044        &self,
2045        agent: Agent,
2046        session_info: acp_thread::AgentSessionInfo,
2047        workspace: Entity<Workspace>,
2048        target_window: WindowHandle<MultiWorkspace>,
2049        cx: &mut Context<Self>,
2050    ) {
2051        let target_session_id = session_info.session_id.clone();
2052
2053        let activated = target_window
2054            .update(cx, |multi_workspace, window, cx| {
2055                window.activate_window();
2056                multi_workspace.activate(workspace.clone(), cx);
2057                Self::load_agent_thread_in_workspace(&workspace, agent, session_info, window, cx);
2058            })
2059            .log_err()
2060            .is_some();
2061
2062        if activated {
2063            if let Some(target_sidebar) = target_window
2064                .read(cx)
2065                .ok()
2066                .and_then(|multi_workspace| {
2067                    multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2068                })
2069                .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2070            {
2071                target_sidebar.update(cx, |sidebar, cx| {
2072                    sidebar.focused_thread = Some(target_session_id);
2073                    sidebar.update_entries(cx);
2074                });
2075            }
2076        }
2077    }
2078
2079    fn activate_thread(
2080        &mut self,
2081        agent: Agent,
2082        session_info: acp_thread::AgentSessionInfo,
2083        workspace: &Entity<Workspace>,
2084        window: &mut Window,
2085        cx: &mut Context<Self>,
2086    ) {
2087        if self
2088            .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2089            .is_some()
2090        {
2091            self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2092            return;
2093        }
2094
2095        let Some((target_window, workspace)) =
2096            self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2097        else {
2098            return;
2099        };
2100
2101        self.activate_thread_in_other_window(agent, session_info, workspace, target_window, cx);
2102    }
2103
2104    fn open_workspace_and_activate_thread(
2105        &mut self,
2106        agent: Agent,
2107        session_info: acp_thread::AgentSessionInfo,
2108        path_list: PathList,
2109        window: &mut Window,
2110        cx: &mut Context<Self>,
2111    ) {
2112        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2113            return;
2114        };
2115
2116        let paths: Vec<std::path::PathBuf> =
2117            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
2118
2119        let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
2120
2121        cx.spawn_in(window, async move |this, cx| {
2122            let workspace = open_task.await?;
2123
2124            this.update_in(cx, |this, window, cx| {
2125                this.activate_thread(agent, session_info, &workspace, window, cx);
2126            })?;
2127            anyhow::Ok(())
2128        })
2129        .detach_and_log_err(cx);
2130    }
2131
2132    fn find_current_workspace_for_path_list(
2133        &self,
2134        path_list: &PathList,
2135        cx: &App,
2136    ) -> Option<Entity<Workspace>> {
2137        self.find_workspace_in_current_window(cx, |workspace, cx| {
2138            workspace_path_list(workspace, cx).paths() == path_list.paths()
2139        })
2140    }
2141
2142    fn find_open_workspace_for_path_list(
2143        &self,
2144        path_list: &PathList,
2145        cx: &App,
2146    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2147        self.find_workspace_across_windows(cx, |workspace, cx| {
2148            workspace_path_list(workspace, cx).paths() == path_list.paths()
2149        })
2150    }
2151
2152    fn activate_archived_thread(
2153        &mut self,
2154        agent: Agent,
2155        session_info: acp_thread::AgentSessionInfo,
2156        window: &mut Window,
2157        cx: &mut Context<Self>,
2158    ) {
2159        // Eagerly save thread metadata so that the sidebar is updated immediately
2160        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
2161            store.save(
2162                ThreadMetadata::from_session_info(agent.id(), &session_info),
2163                cx,
2164            )
2165        });
2166
2167        if let Some(path_list) = &session_info.work_dirs {
2168            if let Some(workspace) = self.find_current_workspace_for_path_list(path_list, cx) {
2169                self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2170            } else if let Some((target_window, workspace)) =
2171                self.find_open_workspace_for_path_list(path_list, cx)
2172            {
2173                self.activate_thread_in_other_window(
2174                    agent,
2175                    session_info,
2176                    workspace,
2177                    target_window,
2178                    cx,
2179                );
2180            } else {
2181                let path_list = path_list.clone();
2182                self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
2183            }
2184            return;
2185        }
2186
2187        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
2188            w.read(cx)
2189                .workspaces()
2190                .get(w.read(cx).active_workspace_index())
2191                .cloned()
2192        });
2193
2194        if let Some(workspace) = active_workspace {
2195            self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2196        }
2197    }
2198
2199    fn expand_selected_entry(
2200        &mut self,
2201        _: &SelectChild,
2202        _window: &mut Window,
2203        cx: &mut Context<Self>,
2204    ) {
2205        let Some(ix) = self.selection else { return };
2206
2207        match self.contents.entries.get(ix) {
2208            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2209                if self.collapsed_groups.contains(path_list) {
2210                    let path_list = path_list.clone();
2211                    self.set_group_collapsed(&path_list, false, cx);
2212                    self.update_entries(cx);
2213                } else if ix + 1 < self.contents.entries.len() {
2214                    self.selection = Some(ix + 1);
2215                    self.list_state.scroll_to_reveal_item(ix + 1);
2216                    cx.notify();
2217                }
2218            }
2219            _ => {}
2220        }
2221    }
2222
2223    fn collapse_selected_entry(
2224        &mut self,
2225        _: &SelectParent,
2226        _window: &mut Window,
2227        cx: &mut Context<Self>,
2228    ) {
2229        let Some(ix) = self.selection else { return };
2230
2231        match self.contents.entries.get(ix) {
2232            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2233                if !self.collapsed_groups.contains(path_list) {
2234                    let path_list = path_list.clone();
2235                    self.set_group_collapsed(&path_list, true, cx);
2236                    self.update_entries(cx);
2237                }
2238            }
2239            Some(
2240                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2241            ) => {
2242                for i in (0..ix).rev() {
2243                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2244                        self.contents.entries.get(i)
2245                    {
2246                        let path_list = path_list.clone();
2247                        self.selection = Some(i);
2248                        self.set_group_collapsed(&path_list, true, cx);
2249                        self.update_entries(cx);
2250                        break;
2251                    }
2252                }
2253            }
2254            None => {}
2255        }
2256    }
2257
2258    fn toggle_selected_fold(
2259        &mut self,
2260        _: &editor::actions::ToggleFold,
2261        _window: &mut Window,
2262        cx: &mut Context<Self>,
2263    ) {
2264        let Some(ix) = self.selection else { return };
2265
2266        // Find the group header for the current selection.
2267        let header_ix = match self.contents.entries.get(ix) {
2268            Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2269            Some(
2270                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2271            ) => (0..ix).rev().find(|&i| {
2272                matches!(
2273                    self.contents.entries.get(i),
2274                    Some(ListEntry::ProjectHeader { .. })
2275                )
2276            }),
2277            None => None,
2278        };
2279
2280        if let Some(header_ix) = header_ix {
2281            if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2282                self.contents.entries.get(header_ix)
2283            {
2284                let path_list = path_list.clone();
2285                if self.collapsed_groups.contains(&path_list) {
2286                    self.set_group_collapsed(&path_list, false, cx);
2287                } else {
2288                    self.selection = Some(header_ix);
2289                    self.set_group_collapsed(&path_list, true, cx);
2290                }
2291                self.update_entries(cx);
2292            }
2293        }
2294    }
2295
2296    fn fold_all(
2297        &mut self,
2298        _: &editor::actions::FoldAll,
2299        _window: &mut Window,
2300        cx: &mut Context<Self>,
2301    ) {
2302        let mut did_change = false;
2303        for entry in &self.contents.entries {
2304            if let ListEntry::ProjectHeader { path_list, .. } = entry {
2305                did_change |= self.collapsed_groups.insert(path_list.clone());
2306            }
2307        }
2308        if did_change {
2309            self.serialize_collapsed_groups(cx);
2310        }
2311        self.update_entries(cx);
2312    }
2313
2314    fn unfold_all(
2315        &mut self,
2316        _: &editor::actions::UnfoldAll,
2317        _window: &mut Window,
2318        cx: &mut Context<Self>,
2319    ) {
2320        self.clear_collapsed_groups(cx);
2321        self.update_entries(cx);
2322    }
2323
2324    fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2325        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2326            return;
2327        };
2328
2329        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
2330        for workspace in workspaces {
2331            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2332                let cancelled =
2333                    agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2334                if cancelled {
2335                    return;
2336                }
2337            }
2338        }
2339    }
2340
2341    fn archive_thread(
2342        &mut self,
2343        session_id: &acp::SessionId,
2344        window: &mut Window,
2345        cx: &mut Context<Self>,
2346    ) {
2347        // If we're archiving the currently focused thread, move focus to the
2348        // nearest thread within the same project group. We never cross group
2349        // boundaries — if the group has no other threads, clear focus and open
2350        // a blank new thread in the panel instead.
2351        if self.focused_thread.as_ref() == Some(session_id) {
2352            let current_pos = self.contents.entries.iter().position(|entry| {
2353                matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
2354            });
2355
2356            // Find the workspace that owns this thread's project group by
2357            // walking backwards to the nearest ProjectHeader. We must use
2358            // *this* workspace (not the active workspace) because the user
2359            // might be archiving a thread in a non-active group.
2360            let group_workspace = current_pos.and_then(|pos| {
2361                self.contents.entries[..pos]
2362                    .iter()
2363                    .rev()
2364                    .find_map(|e| match e {
2365                        ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2366                        _ => None,
2367                    })
2368            });
2369
2370            let next_thread = current_pos.and_then(|pos| {
2371                let group_start = self.contents.entries[..pos]
2372                    .iter()
2373                    .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2374                    .map_or(0, |i| i + 1);
2375                let group_end = self.contents.entries[pos + 1..]
2376                    .iter()
2377                    .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2378                    .map_or(self.contents.entries.len(), |i| pos + 1 + i);
2379
2380                let above = self.contents.entries[group_start..pos]
2381                    .iter()
2382                    .rev()
2383                    .find_map(|entry| {
2384                        if let ListEntry::Thread(t) = entry {
2385                            Some(t)
2386                        } else {
2387                            None
2388                        }
2389                    });
2390
2391                above.or_else(|| {
2392                    self.contents.entries[pos + 1..group_end]
2393                        .iter()
2394                        .find_map(|entry| {
2395                            if let ListEntry::Thread(t) = entry {
2396                                Some(t)
2397                            } else {
2398                                None
2399                            }
2400                        })
2401                })
2402            });
2403
2404            if let Some(next) = next_thread {
2405                self.focused_thread = Some(next.session_info.session_id.clone());
2406
2407                // Use the thread's own workspace when it has one open (e.g. an absorbed
2408                // linked worktree thread that appears under the main workspace's header
2409                // but belongs to its own workspace). Loading into the wrong panel binds
2410                // the thread to the wrong project, which corrupts its stored folder_paths
2411                // when metadata is saved via ThreadMetadata::from_thread.
2412                let target_workspace = match &next.workspace {
2413                    ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
2414                    ThreadEntryWorkspace::Closed(_) => group_workspace,
2415                };
2416
2417                if let Some(workspace) = target_workspace {
2418                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2419                        agent_panel.update(cx, |panel, cx| {
2420                            panel.load_agent_thread(
2421                                next.agent.clone(),
2422                                next.session_info.session_id.clone(),
2423                                next.session_info.work_dirs.clone(),
2424                                next.session_info.title.clone(),
2425                                true,
2426                                window,
2427                                cx,
2428                            );
2429                        });
2430                    }
2431                }
2432            } else {
2433                self.focused_thread = None;
2434                if let Some(workspace) = &group_workspace {
2435                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2436                        agent_panel.update(cx, |panel, cx| {
2437                            panel.new_thread(&NewThread, window, cx);
2438                        });
2439                    }
2440                }
2441            }
2442        }
2443
2444        SidebarThreadMetadataStore::global(cx)
2445            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
2446    }
2447
2448    fn remove_selected_thread(
2449        &mut self,
2450        _: &RemoveSelectedThread,
2451        window: &mut Window,
2452        cx: &mut Context<Self>,
2453    ) {
2454        let Some(ix) = self.selection else {
2455            return;
2456        };
2457        let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
2458            return;
2459        };
2460        if thread.agent != Agent::NativeAgent {
2461            return;
2462        }
2463        let session_id = thread.session_info.session_id.clone();
2464        self.archive_thread(&session_id, window, cx);
2465    }
2466
2467    fn render_thread(
2468        &self,
2469        ix: usize,
2470        thread: &ThreadEntry,
2471        is_focused: bool,
2472        cx: &mut Context<Self>,
2473    ) -> AnyElement {
2474        let has_notification = self
2475            .contents
2476            .is_thread_notified(&thread.session_info.session_id);
2477
2478        let title: SharedString = thread
2479            .session_info
2480            .title
2481            .clone()
2482            .unwrap_or_else(|| "Untitled".into());
2483        let session_info = thread.session_info.clone();
2484        let thread_workspace = thread.workspace.clone();
2485
2486        let is_hovered = self.hovered_thread_index == Some(ix);
2487        let is_selected = self.agent_panel_visible
2488            && self.focused_thread.as_ref() == Some(&session_info.session_id);
2489        let is_running = matches!(
2490            thread.status,
2491            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
2492        );
2493
2494        let session_id_for_delete = thread.session_info.session_id.clone();
2495        let focus_handle = self.focus_handle.clone();
2496
2497        let id = SharedString::from(format!("thread-entry-{}", ix));
2498
2499        let timestamp = thread
2500            .session_info
2501            .created_at
2502            .or(thread.session_info.updated_at)
2503            .map(format_history_entry_timestamp);
2504
2505        ThreadItem::new(id, title)
2506            .icon(thread.icon)
2507            .status(thread.status)
2508            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
2509                this.custom_icon_from_external_svg(svg)
2510            })
2511            .when_some(thread.worktree_name.clone(), |this, name| {
2512                let this = this.worktree(name);
2513                match thread.worktree_full_path.clone() {
2514                    Some(path) => this.worktree_full_path(path),
2515                    None => this,
2516                }
2517            })
2518            .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
2519            .when_some(timestamp, |this, ts| this.timestamp(ts))
2520            .highlight_positions(thread.highlight_positions.to_vec())
2521            .title_generating(thread.is_title_generating)
2522            .notified(has_notification)
2523            .when(thread.diff_stats.lines_added > 0, |this| {
2524                this.added(thread.diff_stats.lines_added as usize)
2525            })
2526            .when(thread.diff_stats.lines_removed > 0, |this| {
2527                this.removed(thread.diff_stats.lines_removed as usize)
2528            })
2529            .selected(is_selected)
2530            .focused(is_focused)
2531            .hovered(is_hovered)
2532            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
2533                if *is_hovered {
2534                    this.hovered_thread_index = Some(ix);
2535                } else if this.hovered_thread_index == Some(ix) {
2536                    this.hovered_thread_index = None;
2537                }
2538                cx.notify();
2539            }))
2540            .when(is_hovered && is_running, |this| {
2541                this.action_slot(
2542                    IconButton::new("stop-thread", IconName::Stop)
2543                        .icon_size(IconSize::Small)
2544                        .icon_color(Color::Error)
2545                        .style(ButtonStyle::Tinted(TintColor::Error))
2546                        .tooltip(Tooltip::text("Stop Generation"))
2547                        .on_click({
2548                            let session_id = session_id_for_delete.clone();
2549                            cx.listener(move |this, _, _window, cx| {
2550                                this.stop_thread(&session_id, cx);
2551                            })
2552                        }),
2553                )
2554            })
2555            .when(is_hovered && !is_running, |this| {
2556                this.action_slot(
2557                    IconButton::new("archive-thread", IconName::Archive)
2558                        .icon_size(IconSize::Small)
2559                        .icon_color(Color::Muted)
2560                        .tooltip({
2561                            let focus_handle = focus_handle.clone();
2562                            move |_window, cx| {
2563                                Tooltip::for_action_in(
2564                                    "Archive Thread",
2565                                    &RemoveSelectedThread,
2566                                    &focus_handle,
2567                                    cx,
2568                                )
2569                            }
2570                        })
2571                        .on_click({
2572                            let session_id = session_id_for_delete.clone();
2573                            cx.listener(move |this, _, window, cx| {
2574                                this.archive_thread(&session_id, window, cx);
2575                            })
2576                        }),
2577                )
2578            })
2579            .on_click({
2580                let agent = thread.agent.clone();
2581                cx.listener(move |this, _, window, cx| {
2582                    this.selection = None;
2583                    match &thread_workspace {
2584                        ThreadEntryWorkspace::Open(workspace) => {
2585                            this.activate_thread(
2586                                agent.clone(),
2587                                session_info.clone(),
2588                                workspace,
2589                                window,
2590                                cx,
2591                            );
2592                        }
2593                        ThreadEntryWorkspace::Closed(path_list) => {
2594                            this.open_workspace_and_activate_thread(
2595                                agent.clone(),
2596                                session_info.clone(),
2597                                path_list.clone(),
2598                                window,
2599                                cx,
2600                            );
2601                        }
2602                    }
2603                })
2604            })
2605            .into_any_element()
2606    }
2607
2608    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
2609        div()
2610            .min_w_0()
2611            .flex_1()
2612            .capture_action(
2613                cx.listener(|this, _: &editor::actions::Newline, window, cx| {
2614                    this.editor_confirm(window, cx);
2615                }),
2616            )
2617            .child(self.filter_editor.clone())
2618    }
2619
2620    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
2621        let multi_workspace = self.multi_workspace.upgrade();
2622
2623        let workspace = multi_workspace
2624            .as_ref()
2625            .map(|mw| mw.read(cx).workspace().downgrade());
2626
2627        let focus_handle = workspace
2628            .as_ref()
2629            .and_then(|ws| ws.upgrade())
2630            .map(|w| w.read(cx).focus_handle(cx))
2631            .unwrap_or_else(|| cx.focus_handle());
2632
2633        let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
2634            .as_ref()
2635            .map(|mw| {
2636                mw.read(cx)
2637                    .workspaces()
2638                    .iter()
2639                    .filter_map(|ws| ws.read(cx).database_id())
2640                    .collect()
2641            })
2642            .unwrap_or_default();
2643
2644        let popover_handle = self.recent_projects_popover_handle.clone();
2645
2646        PopoverMenu::new("sidebar-recent-projects-menu")
2647            .with_handle(popover_handle)
2648            .menu(move |window, cx| {
2649                workspace.as_ref().map(|ws| {
2650                    SidebarRecentProjects::popover(
2651                        ws.clone(),
2652                        sibling_workspace_ids.clone(),
2653                        focus_handle.clone(),
2654                        window,
2655                        cx,
2656                    )
2657                })
2658            })
2659            .trigger_with_tooltip(
2660                IconButton::new("open-project", IconName::OpenFolder)
2661                    .icon_size(IconSize::Small)
2662                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
2663                |_window, cx| {
2664                    Tooltip::for_action(
2665                        "Add Project",
2666                        &OpenRecent {
2667                            create_new_window: false,
2668                        },
2669                        cx,
2670                    )
2671                },
2672            )
2673            .offset(gpui::Point {
2674                x: px(-2.0),
2675                y: px(-2.0),
2676            })
2677            .anchor(gpui::Corner::BottomRight)
2678    }
2679
2680    fn render_view_more(
2681        &self,
2682        ix: usize,
2683        path_list: &PathList,
2684        is_fully_expanded: bool,
2685        is_selected: bool,
2686        cx: &mut Context<Self>,
2687    ) -> AnyElement {
2688        let path_list = path_list.clone();
2689        let id = SharedString::from(format!("view-more-{}", ix));
2690
2691        let label: SharedString = if is_fully_expanded {
2692            "Collapse".into()
2693        } else {
2694            "View More".into()
2695        };
2696
2697        ThreadItem::new(id, label)
2698            .focused(is_selected)
2699            .icon_visible(false)
2700            .title_label_color(Color::Muted)
2701            .on_click(cx.listener(move |this, _, _window, cx| {
2702                this.selection = None;
2703                if is_fully_expanded {
2704                    this.expanded_groups.remove(&path_list);
2705                } else {
2706                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
2707                    this.expanded_groups.insert(path_list.clone(), current + 1);
2708                }
2709                this.update_entries(cx);
2710            }))
2711            .into_any_element()
2712    }
2713
2714    fn new_thread_in_group(
2715        &mut self,
2716        _: &NewThreadInGroup,
2717        window: &mut Window,
2718        cx: &mut Context<Self>,
2719    ) {
2720        // If there is a keyboard selection, walk backwards through
2721        // `project_header_indices` to find the header that owns the selected
2722        // row. Otherwise fall back to the active workspace.
2723        let workspace = if let Some(selected_ix) = self.selection {
2724            self.contents
2725                .project_header_indices
2726                .iter()
2727                .rev()
2728                .find(|&&header_ix| header_ix <= selected_ix)
2729                .and_then(|&header_ix| match &self.contents.entries[header_ix] {
2730                    ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2731                    _ => None,
2732                })
2733        } else {
2734            // Use the currently active workspace.
2735            self.multi_workspace
2736                .upgrade()
2737                .map(|mw| mw.read(cx).workspace().clone())
2738        };
2739
2740        let Some(workspace) = workspace else {
2741            return;
2742        };
2743
2744        self.create_new_thread(&workspace, window, cx);
2745    }
2746
2747    fn create_new_thread(
2748        &mut self,
2749        workspace: &Entity<Workspace>,
2750        window: &mut Window,
2751        cx: &mut Context<Self>,
2752    ) {
2753        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2754            return;
2755        };
2756
2757        // Clear focused_thread immediately so no existing thread stays
2758        // highlighted while the new blank thread is being shown. Without this,
2759        // if the target workspace is already active (so ActiveWorkspaceChanged
2760        // never fires), the previous thread's highlight would linger.
2761        self.focused_thread = None;
2762
2763        multi_workspace.update(cx, |multi_workspace, cx| {
2764            multi_workspace.activate(workspace.clone(), cx);
2765        });
2766
2767        workspace.update(cx, |workspace, cx| {
2768            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
2769                agent_panel.update(cx, |panel, cx| {
2770                    panel.new_thread(&NewThread, window, cx);
2771                });
2772            }
2773            workspace.focus_panel::<AgentPanel>(window, cx);
2774        });
2775    }
2776
2777    fn render_new_thread(
2778        &self,
2779        ix: usize,
2780        _path_list: &PathList,
2781        workspace: &Entity<Workspace>,
2782        is_active_draft: bool,
2783        is_selected: bool,
2784        cx: &mut Context<Self>,
2785    ) -> AnyElement {
2786        let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft;
2787
2788        let label: SharedString = if is_active {
2789            self.active_draft_text(cx)
2790                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
2791        } else {
2792            DEFAULT_THREAD_TITLE.into()
2793        };
2794
2795        let workspace = workspace.clone();
2796        let id = SharedString::from(format!("new-thread-btn-{}", ix));
2797
2798        let thread_item = ThreadItem::new(id, label)
2799            .icon(IconName::Plus)
2800            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
2801            .selected(is_active)
2802            .focused(is_selected)
2803            .when(!is_active, |this| {
2804                this.on_click(cx.listener(move |this, _, window, cx| {
2805                    this.selection = None;
2806                    this.create_new_thread(&workspace, window, cx);
2807                }))
2808            });
2809
2810        if is_active {
2811            div()
2812                .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
2813                    cx.stop_propagation();
2814                })
2815                .child(thread_item)
2816                .into_any_element()
2817        } else {
2818            thread_item.into_any_element()
2819        }
2820    }
2821
2822    fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
2823        let has_query = self.has_filter_query(cx);
2824        let message = if has_query {
2825            "No threads match your search."
2826        } else {
2827            "No threads yet"
2828        };
2829
2830        v_flex()
2831            .id("sidebar-no-results")
2832            .p_4()
2833            .size_full()
2834            .items_center()
2835            .justify_center()
2836            .child(
2837                Label::new(message)
2838                    .size(LabelSize::Small)
2839                    .color(Color::Muted),
2840            )
2841    }
2842
2843    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2844        v_flex()
2845            .id("sidebar-empty-state")
2846            .p_4()
2847            .size_full()
2848            .items_center()
2849            .justify_center()
2850            .gap_1()
2851            .track_focus(&self.focus_handle(cx))
2852            .child(
2853                Button::new("open_project", "Open Project")
2854                    .full_width()
2855                    .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
2856                    .on_click(|_, window, cx| {
2857                        window.dispatch_action(
2858                            Open {
2859                                create_new_window: false,
2860                            }
2861                            .boxed_clone(),
2862                            cx,
2863                        );
2864                    }),
2865            )
2866            .child(
2867                h_flex()
2868                    .w_1_2()
2869                    .gap_2()
2870                    .child(Divider::horizontal())
2871                    .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
2872                    .child(Divider::horizontal()),
2873            )
2874            .child(
2875                Button::new("clone_repo", "Clone Repository")
2876                    .full_width()
2877                    .on_click(|_, window, cx| {
2878                        window.dispatch_action(git::Clone.boxed_clone(), cx);
2879                    }),
2880            )
2881    }
2882
2883    fn render_sidebar_header(
2884        &self,
2885        no_open_projects: bool,
2886        window: &Window,
2887        cx: &mut Context<Self>,
2888    ) -> impl IntoElement {
2889        let has_query = self.has_filter_query(cx);
2890        let sidebar_on_left = self.side(cx) == SidebarSide::Left;
2891        let traffic_lights =
2892            cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
2893        let header_height = platform_title_bar_height(window);
2894
2895        h_flex()
2896            .h(header_height)
2897            .mt_px()
2898            .pb_px()
2899            .map(|this| {
2900                if traffic_lights {
2901                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
2902                } else {
2903                    this.pl_1p5()
2904                }
2905            })
2906            .pr_1p5()
2907            .gap_1()
2908            .when(!no_open_projects, |this| {
2909                this.border_b_1()
2910                    .border_color(cx.theme().colors().border)
2911                    .when(traffic_lights, |this| {
2912                        this.child(Divider::vertical().color(ui::DividerColor::Border))
2913                    })
2914                    .child(
2915                        div().ml_1().child(
2916                            Icon::new(IconName::MagnifyingGlass)
2917                                .size(IconSize::Small)
2918                                .color(Color::Muted),
2919                        ),
2920                    )
2921                    .child(self.render_filter_input(cx))
2922                    .child(
2923                        h_flex()
2924                            .gap_1()
2925                            .when(
2926                                self.selection.is_some()
2927                                    && !self.filter_editor.focus_handle(cx).is_focused(window),
2928                                |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
2929                            )
2930                            .when(has_query, |this| {
2931                                this.child(
2932                                    IconButton::new("clear_filter", IconName::Close)
2933                                        .icon_size(IconSize::Small)
2934                                        .tooltip(Tooltip::text("Clear Search"))
2935                                        .on_click(cx.listener(|this, _, window, cx| {
2936                                            this.reset_filter_editor_text(window, cx);
2937                                            this.update_entries(cx);
2938                                        })),
2939                                )
2940                            }),
2941                    )
2942            })
2943    }
2944
2945    fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
2946        let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
2947
2948        sidebar_side_context_menu("sidebar-toggle-menu", _cx)
2949            .anchor(if on_right {
2950                gpui::Corner::BottomRight
2951            } else {
2952                gpui::Corner::BottomLeft
2953            })
2954            .attach(if on_right {
2955                gpui::Corner::TopRight
2956            } else {
2957                gpui::Corner::TopLeft
2958            })
2959            .trigger(move |_is_active, _window, _cx| {
2960                let icon = if on_right {
2961                    IconName::ThreadsSidebarRightOpen
2962                } else {
2963                    IconName::ThreadsSidebarLeftOpen
2964                };
2965                IconButton::new("sidebar-close-toggle", icon)
2966                    .icon_size(IconSize::Small)
2967                    .tooltip(Tooltip::element(move |_window, cx| {
2968                        v_flex()
2969                            .gap_1()
2970                            .child(
2971                                h_flex()
2972                                    .gap_2()
2973                                    .justify_between()
2974                                    .child(Label::new("Toggle Sidebar"))
2975                                    .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
2976                            )
2977                            .child(
2978                                h_flex()
2979                                    .pt_1()
2980                                    .gap_2()
2981                                    .border_t_1()
2982                                    .border_color(cx.theme().colors().border_variant)
2983                                    .justify_between()
2984                                    .child(Label::new("Focus Sidebar"))
2985                                    .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
2986                            )
2987                            .into_any_element()
2988                    }))
2989                    .on_click(|_, window, cx| {
2990                        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2991                            multi_workspace.update(cx, |multi_workspace, cx| {
2992                                multi_workspace.close_sidebar(window, cx);
2993                            });
2994                        }
2995                    })
2996            })
2997    }
2998
2999    fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
3000        let on_right = self.side(cx) == SidebarSide::Right;
3001        let is_archive = matches!(self.view, SidebarView::Archive(..));
3002        let action_buttons = h_flex()
3003            .gap_1()
3004            .child(
3005                IconButton::new("archive", IconName::Archive)
3006                    .icon_size(IconSize::Small)
3007                    .toggle_state(is_archive)
3008                    .tooltip(move |_, cx| {
3009                        Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
3010                    })
3011                    .on_click(cx.listener(|this, _, window, cx| {
3012                        this.toggle_archive(&ToggleArchive, window, cx);
3013                    })),
3014            )
3015            .child(self.render_recent_projects_button(cx));
3016        let border_color = cx.theme().colors().border;
3017        let toggle_button = self.render_sidebar_toggle_button(cx);
3018
3019        let bar = h_flex()
3020            .p_1()
3021            .gap_1()
3022            .justify_between()
3023            .border_t_1()
3024            .border_color(border_color);
3025
3026        if on_right {
3027            bar.child(action_buttons).child(toggle_button)
3028        } else {
3029            bar.child(toggle_button).child(action_buttons)
3030        }
3031    }
3032
3033    fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
3034        match &self.view {
3035            SidebarView::ThreadList => self.show_archive(window, cx),
3036            SidebarView::Archive(_) => self.show_thread_list(window, cx),
3037        }
3038    }
3039
3040    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3041        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
3042            w.read(cx)
3043                .workspaces()
3044                .get(w.read(cx).active_workspace_index())
3045                .cloned()
3046        }) else {
3047            return;
3048        };
3049
3050        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
3051            return;
3052        };
3053
3054        let thread_store = agent_panel.read(cx).thread_store().clone();
3055        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
3056        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
3057        let agent_server_store = active_workspace
3058            .read(cx)
3059            .project()
3060            .read(cx)
3061            .agent_server_store()
3062            .clone();
3063
3064        let archive_view = cx.new(|cx| {
3065            ThreadsArchiveView::new(
3066                agent_connection_store,
3067                agent_server_store,
3068                thread_store,
3069                fs,
3070                window,
3071                cx,
3072            )
3073        });
3074        let subscription = cx.subscribe_in(
3075            &archive_view,
3076            window,
3077            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
3078                ThreadsArchiveViewEvent::Close => {
3079                    this.show_thread_list(window, cx);
3080                }
3081                ThreadsArchiveViewEvent::Unarchive {
3082                    agent,
3083                    session_info,
3084                } => {
3085                    this.show_thread_list(window, cx);
3086                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
3087                }
3088            },
3089        );
3090
3091        self._subscriptions.push(subscription);
3092        self.view = SidebarView::Archive(archive_view.clone());
3093        archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
3094        cx.notify();
3095    }
3096
3097    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3098        self.view = SidebarView::ThreadList;
3099        self._subscriptions.clear();
3100        let handle = self.filter_editor.read(cx).focus_handle(cx);
3101        handle.focus(window, cx);
3102        cx.notify();
3103    }
3104}
3105
3106impl WorkspaceSidebar for Sidebar {
3107    fn width(&self, _cx: &App) -> Pixels {
3108        self.width
3109    }
3110
3111    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
3112        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
3113        cx.notify();
3114    }
3115
3116    fn has_notifications(&self, _cx: &App) -> bool {
3117        !self.contents.notified_threads.is_empty()
3118    }
3119
3120    fn is_threads_list_view_active(&self) -> bool {
3121        matches!(self.view, SidebarView::ThreadList)
3122    }
3123
3124    fn side(&self, cx: &App) -> SidebarSide {
3125        AgentSettings::get_global(cx).sidebar_side()
3126    }
3127
3128    fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3129        self.selection = None;
3130        cx.notify();
3131    }
3132}
3133
3134impl Focusable for Sidebar {
3135    fn focus_handle(&self, _cx: &App) -> FocusHandle {
3136        self.focus_handle.clone()
3137    }
3138}
3139
3140impl Render for Sidebar {
3141    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3142        let _titlebar_height = ui::utils::platform_title_bar_height(window);
3143        let ui_font = theme::setup_ui_font(window, cx);
3144        let sticky_header = self.render_sticky_header(window, cx);
3145
3146        let color = cx.theme().colors();
3147        let bg = color
3148            .title_bar_background
3149            .blend(color.panel_background.opacity(0.32));
3150
3151        let no_open_projects = !self.contents.has_open_projects;
3152        let no_search_results = self.contents.entries.is_empty();
3153
3154        v_flex()
3155            .id("workspace-sidebar")
3156            .key_context(self.dispatch_context(window, cx))
3157            .track_focus(&self.focus_handle)
3158            .on_action(cx.listener(Self::select_next))
3159            .on_action(cx.listener(Self::select_previous))
3160            .on_action(cx.listener(Self::editor_move_down))
3161            .on_action(cx.listener(Self::editor_move_up))
3162            .on_action(cx.listener(Self::select_first))
3163            .on_action(cx.listener(Self::select_last))
3164            .on_action(cx.listener(Self::confirm))
3165            .on_action(cx.listener(Self::expand_selected_entry))
3166            .on_action(cx.listener(Self::collapse_selected_entry))
3167            .on_action(cx.listener(Self::toggle_selected_fold))
3168            .on_action(cx.listener(Self::fold_all))
3169            .on_action(cx.listener(Self::unfold_all))
3170            .on_action(cx.listener(Self::cancel))
3171            .on_action(cx.listener(Self::remove_selected_thread))
3172            .on_action(cx.listener(Self::new_thread_in_group))
3173            .on_action(cx.listener(Self::toggle_archive))
3174            .on_action(cx.listener(Self::focus_sidebar_filter))
3175            .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
3176                this.recent_projects_popover_handle.toggle(window, cx);
3177            }))
3178            .font(ui_font)
3179            .h_full()
3180            .w(self.width)
3181            .bg(bg)
3182            .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
3183            .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
3184            .border_color(color.border)
3185            .map(|this| match &self.view {
3186                SidebarView::ThreadList => this
3187                    .child(self.render_sidebar_header(no_open_projects, window, cx))
3188                    .map(|this| {
3189                        if no_open_projects {
3190                            this.child(self.render_empty_state(cx))
3191                        } else {
3192                            this.child(
3193                                v_flex()
3194                                    .relative()
3195                                    .flex_1()
3196                                    .overflow_hidden()
3197                                    .child(
3198                                        list(
3199                                            self.list_state.clone(),
3200                                            cx.processor(Self::render_list_entry),
3201                                        )
3202                                        .flex_1()
3203                                        .size_full(),
3204                                    )
3205                                    .when(no_search_results, |this| {
3206                                        this.child(self.render_no_results(cx))
3207                                    })
3208                                    .when_some(sticky_header, |this, header| this.child(header))
3209                                    .vertical_scrollbar_for(&self.list_state, window, cx),
3210                            )
3211                        }
3212                    }),
3213                SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
3214            })
3215            .child(self.render_sidebar_bottom_bar(cx))
3216    }
3217}
3218
3219fn all_thread_infos_for_workspace(
3220    workspace: &Entity<Workspace>,
3221    cx: &App,
3222) -> impl Iterator<Item = ActiveThreadInfo> {
3223    let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
3224        return None.into_iter().flatten();
3225    };
3226    let agent_panel = agent_panel.read(cx);
3227
3228    let threads = agent_panel
3229        .parent_threads(cx)
3230        .into_iter()
3231        .map(|thread_view| {
3232            let thread_view_ref = thread_view.read(cx);
3233            let thread = thread_view_ref.thread.read(cx);
3234
3235            let icon = thread_view_ref.agent_icon;
3236            let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
3237            let title = thread
3238                .title()
3239                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
3240            let is_native = thread_view_ref.as_native_thread(cx).is_some();
3241            let is_title_generating = is_native && thread.has_provisional_title();
3242            let session_id = thread.session_id().clone();
3243            let is_background = agent_panel.is_background_thread(&session_id);
3244
3245            let status = if thread.is_waiting_for_confirmation() {
3246                AgentThreadStatus::WaitingForConfirmation
3247            } else if thread.had_error() {
3248                AgentThreadStatus::Error
3249            } else {
3250                match thread.status() {
3251                    ThreadStatus::Generating => AgentThreadStatus::Running,
3252                    ThreadStatus::Idle => AgentThreadStatus::Completed,
3253                }
3254            };
3255
3256            let diff_stats = thread.action_log().read(cx).diff_stats(cx);
3257
3258            ActiveThreadInfo {
3259                session_id,
3260                title,
3261                status,
3262                icon,
3263                icon_from_external_svg,
3264                is_background,
3265                is_title_generating,
3266                diff_stats,
3267            }
3268        });
3269
3270    Some(threads).into_iter().flatten()
3271}
3272
3273#[cfg(test)]
3274mod tests {
3275    use super::*;
3276    use acp_thread::StubAgentConnection;
3277    use db::AppDatabase;
3278
3279    use agent::ThreadStore;
3280    use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
3281    use assistant_text_thread::TextThreadStore;
3282    use chrono::DateTime;
3283    use feature_flags::FeatureFlagAppExt as _;
3284    use fs::FakeFs;
3285    use gpui::TestAppContext;
3286    use pretty_assertions::assert_eq;
3287    use settings::SettingsStore;
3288    use std::{path::PathBuf, sync::Arc};
3289    use util::path_list::PathList;
3290
3291    fn init_test(cx: &mut TestAppContext) {
3292        cx.update(|cx| {
3293            let settings_store = SettingsStore::test(cx);
3294            cx.set_global(settings_store);
3295            cx.set_global(AppDatabase::test_new());
3296            theme::init(theme::LoadThemes::JustBase, cx);
3297
3298            editor::init(cx);
3299            cx.update_flags(false, vec!["agent-v2".into()]);
3300            ThreadStore::init_global(cx);
3301            SidebarThreadMetadataStore::init_global(cx);
3302            language_model::LanguageModelRegistry::test(cx);
3303            prompt_store::init(cx);
3304        });
3305    }
3306
3307    fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
3308        sidebar.contents.entries.iter().any(|entry| {
3309            matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
3310        })
3311    }
3312
3313    async fn init_test_project(
3314        worktree_path: &str,
3315        cx: &mut TestAppContext,
3316    ) -> Entity<project::Project> {
3317        init_test(cx);
3318        let fs = FakeFs::new(cx.executor());
3319        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
3320            .await;
3321        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3322        project::Project::test(fs, [worktree_path.as_ref()], cx).await
3323    }
3324
3325    fn setup_sidebar(
3326        multi_workspace: &Entity<MultiWorkspace>,
3327        cx: &mut gpui::VisualTestContext,
3328    ) -> Entity<Sidebar> {
3329        let multi_workspace = multi_workspace.clone();
3330        let sidebar =
3331            cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
3332        multi_workspace.update(cx, |mw, cx| {
3333            mw.register_sidebar(sidebar.clone(), cx);
3334        });
3335        cx.run_until_parked();
3336        sidebar
3337    }
3338
3339    async fn save_n_test_threads(
3340        count: u32,
3341        path_list: &PathList,
3342        cx: &mut gpui::VisualTestContext,
3343    ) {
3344        for i in 0..count {
3345            save_thread_metadata(
3346                acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3347                format!("Thread {}", i + 1).into(),
3348                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3349                path_list.clone(),
3350                cx,
3351            )
3352            .await;
3353        }
3354        cx.run_until_parked();
3355    }
3356
3357    async fn save_test_thread_metadata(
3358        session_id: &acp::SessionId,
3359        path_list: PathList,
3360        cx: &mut TestAppContext,
3361    ) {
3362        save_thread_metadata(
3363            session_id.clone(),
3364            "Test".into(),
3365            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3366            path_list,
3367            cx,
3368        )
3369        .await;
3370    }
3371
3372    async fn save_named_thread_metadata(
3373        session_id: &str,
3374        title: &str,
3375        path_list: &PathList,
3376        cx: &mut gpui::VisualTestContext,
3377    ) {
3378        save_thread_metadata(
3379            acp::SessionId::new(Arc::from(session_id)),
3380            SharedString::from(title.to_string()),
3381            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3382            path_list.clone(),
3383            cx,
3384        )
3385        .await;
3386        cx.run_until_parked();
3387    }
3388
3389    async fn save_thread_metadata(
3390        session_id: acp::SessionId,
3391        title: SharedString,
3392        updated_at: DateTime<Utc>,
3393        path_list: PathList,
3394        cx: &mut TestAppContext,
3395    ) {
3396        let metadata = ThreadMetadata {
3397            session_id,
3398            agent_id: None,
3399            title,
3400            updated_at,
3401            created_at: None,
3402            folder_paths: path_list,
3403        };
3404        cx.update(|cx| {
3405            SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
3406        });
3407        cx.run_until_parked();
3408    }
3409
3410    fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
3411        let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade());
3412        if let Some(multi_workspace) = multi_workspace {
3413            multi_workspace.update_in(cx, |mw, window, cx| {
3414                if !mw.sidebar_open() {
3415                    mw.toggle_sidebar(window, cx);
3416                }
3417            });
3418        }
3419        cx.run_until_parked();
3420        sidebar.update_in(cx, |_, window, cx| {
3421            cx.focus_self(window);
3422        });
3423        cx.run_until_parked();
3424    }
3425
3426    fn visible_entries_as_strings(
3427        sidebar: &Entity<Sidebar>,
3428        cx: &mut gpui::VisualTestContext,
3429    ) -> Vec<String> {
3430        sidebar.read_with(cx, |sidebar, _cx| {
3431            sidebar
3432                .contents
3433                .entries
3434                .iter()
3435                .enumerate()
3436                .map(|(ix, entry)| {
3437                    let selected = if sidebar.selection == Some(ix) {
3438                        "  <== selected"
3439                    } else {
3440                        ""
3441                    };
3442                    match entry {
3443                        ListEntry::ProjectHeader {
3444                            label,
3445                            path_list,
3446                            highlight_positions: _,
3447                            ..
3448                        } => {
3449                            let icon = if sidebar.collapsed_groups.contains(path_list) {
3450                                ">"
3451                            } else {
3452                                "v"
3453                            };
3454                            format!("{} [{}]{}", icon, label, selected)
3455                        }
3456                        ListEntry::Thread(thread) => {
3457                            let title = thread
3458                                .session_info
3459                                .title
3460                                .as_ref()
3461                                .map(|s| s.as_ref())
3462                                .unwrap_or("Untitled");
3463                            let active = if thread.is_live { " *" } else { "" };
3464                            let status_str = match thread.status {
3465                                AgentThreadStatus::Running => " (running)",
3466                                AgentThreadStatus::Error => " (error)",
3467                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
3468                                _ => "",
3469                            };
3470                            let notified = if sidebar
3471                                .contents
3472                                .is_thread_notified(&thread.session_info.session_id)
3473                            {
3474                                " (!)"
3475                            } else {
3476                                ""
3477                            };
3478                            let worktree = thread
3479                                .worktree_name
3480                                .as_ref()
3481                                .map(|name| format!(" {{{}}}", name))
3482                                .unwrap_or_default();
3483                            format!(
3484                                "  {}{}{}{}{}{}",
3485                                title, worktree, active, status_str, notified, selected
3486                            )
3487                        }
3488                        ListEntry::ViewMore {
3489                            is_fully_expanded, ..
3490                        } => {
3491                            if *is_fully_expanded {
3492                                format!("  - Collapse{}", selected)
3493                            } else {
3494                                format!("  + View More{}", selected)
3495                            }
3496                        }
3497                        ListEntry::NewThread { .. } => {
3498                            format!("  [+ New Thread]{}", selected)
3499                        }
3500                    }
3501                })
3502                .collect()
3503        })
3504    }
3505
3506    #[test]
3507    fn test_clean_mention_links() {
3508        // Simple mention link
3509        assert_eq!(
3510            Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
3511            "check @Button.tsx"
3512        );
3513
3514        // Multiple mention links
3515        assert_eq!(
3516            Sidebar::clean_mention_links(
3517                "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
3518            ),
3519            "look at @foo.rs and @bar.rs"
3520        );
3521
3522        // No mention links — passthrough
3523        assert_eq!(
3524            Sidebar::clean_mention_links("plain text with no mentions"),
3525            "plain text with no mentions"
3526        );
3527
3528        // Incomplete link syntax — preserved as-is
3529        assert_eq!(
3530            Sidebar::clean_mention_links("broken [@mention without closing"),
3531            "broken [@mention without closing"
3532        );
3533
3534        // Regular markdown link (no @) — not touched
3535        assert_eq!(
3536            Sidebar::clean_mention_links("see [docs](https://example.com)"),
3537            "see [docs](https://example.com)"
3538        );
3539
3540        // Empty input
3541        assert_eq!(Sidebar::clean_mention_links(""), "");
3542    }
3543
3544    #[gpui::test]
3545    async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
3546        let project = init_test_project("/my-project", cx).await;
3547        let (multi_workspace, cx) =
3548            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3549        let sidebar = setup_sidebar(&multi_workspace, cx);
3550
3551        let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
3552        let weak_sidebar = sidebar.downgrade();
3553        let weak_multi_workspace = multi_workspace.downgrade();
3554
3555        drop(sidebar);
3556        drop(multi_workspace);
3557        cx.update(|window, _cx| window.remove_window());
3558        cx.run_until_parked();
3559
3560        weak_multi_workspace.assert_released();
3561        weak_sidebar.assert_released();
3562        weak_workspace.assert_released();
3563    }
3564
3565    #[gpui::test]
3566    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
3567        let project = init_test_project("/my-project", cx).await;
3568        let (multi_workspace, cx) =
3569            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3570        let sidebar = setup_sidebar(&multi_workspace, cx);
3571
3572        assert_eq!(
3573            visible_entries_as_strings(&sidebar, cx),
3574            vec!["v [my-project]", "  [+ New Thread]"]
3575        );
3576    }
3577
3578    #[gpui::test]
3579    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
3580        let project = init_test_project("/my-project", cx).await;
3581        let (multi_workspace, cx) =
3582            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3583        let sidebar = setup_sidebar(&multi_workspace, cx);
3584
3585        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3586
3587        save_thread_metadata(
3588            acp::SessionId::new(Arc::from("thread-1")),
3589            "Fix crash in project panel".into(),
3590            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
3591            path_list.clone(),
3592            cx,
3593        )
3594        .await;
3595
3596        save_thread_metadata(
3597            acp::SessionId::new(Arc::from("thread-2")),
3598            "Add inline diff view".into(),
3599            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3600            path_list.clone(),
3601            cx,
3602        )
3603        .await;
3604        cx.run_until_parked();
3605
3606        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3607        cx.run_until_parked();
3608
3609        assert_eq!(
3610            visible_entries_as_strings(&sidebar, cx),
3611            vec![
3612                "v [my-project]",
3613                "  Fix crash in project panel",
3614                "  Add inline diff view",
3615            ]
3616        );
3617    }
3618
3619    #[gpui::test]
3620    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
3621        let project = init_test_project("/project-a", cx).await;
3622        let (multi_workspace, cx) =
3623            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3624        let sidebar = setup_sidebar(&multi_workspace, cx);
3625
3626        // Single workspace with a thread
3627        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3628
3629        save_thread_metadata(
3630            acp::SessionId::new(Arc::from("thread-a1")),
3631            "Thread A1".into(),
3632            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3633            path_list.clone(),
3634            cx,
3635        )
3636        .await;
3637        cx.run_until_parked();
3638
3639        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3640        cx.run_until_parked();
3641
3642        assert_eq!(
3643            visible_entries_as_strings(&sidebar, cx),
3644            vec!["v [project-a]", "  Thread A1"]
3645        );
3646
3647        // Add a second workspace
3648        multi_workspace.update_in(cx, |mw, window, cx| {
3649            mw.create_test_workspace(window, cx).detach();
3650        });
3651        cx.run_until_parked();
3652
3653        assert_eq!(
3654            visible_entries_as_strings(&sidebar, cx),
3655            vec!["v [project-a]", "  Thread A1",]
3656        );
3657
3658        // Remove the second workspace
3659        multi_workspace.update_in(cx, |mw, window, cx| {
3660            mw.remove_workspace(1, window, cx);
3661        });
3662        cx.run_until_parked();
3663
3664        assert_eq!(
3665            visible_entries_as_strings(&sidebar, cx),
3666            vec!["v [project-a]", "  Thread A1"]
3667        );
3668    }
3669
3670    #[gpui::test]
3671    async fn test_view_more_pagination(cx: &mut TestAppContext) {
3672        let project = init_test_project("/my-project", cx).await;
3673        let (multi_workspace, cx) =
3674            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3675        let sidebar = setup_sidebar(&multi_workspace, cx);
3676
3677        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3678        save_n_test_threads(12, &path_list, cx).await;
3679
3680        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3681        cx.run_until_parked();
3682
3683        assert_eq!(
3684            visible_entries_as_strings(&sidebar, cx),
3685            vec![
3686                "v [my-project]",
3687                "  Thread 12",
3688                "  Thread 11",
3689                "  Thread 10",
3690                "  Thread 9",
3691                "  Thread 8",
3692                "  + View More",
3693            ]
3694        );
3695    }
3696
3697    #[gpui::test]
3698    async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
3699        let project = init_test_project("/my-project", cx).await;
3700        let (multi_workspace, cx) =
3701            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3702        let sidebar = setup_sidebar(&multi_workspace, cx);
3703
3704        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3705        // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
3706        save_n_test_threads(17, &path_list, cx).await;
3707
3708        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3709        cx.run_until_parked();
3710
3711        // Initially shows 5 threads + View More
3712        let entries = visible_entries_as_strings(&sidebar, cx);
3713        assert_eq!(entries.len(), 7); // header + 5 threads + View More
3714        assert!(entries.iter().any(|e| e.contains("View More")));
3715
3716        // Focus and navigate to View More, then confirm to expand by one batch
3717        open_and_focus_sidebar(&sidebar, cx);
3718        for _ in 0..7 {
3719            cx.dispatch_action(SelectNext);
3720        }
3721        cx.dispatch_action(Confirm);
3722        cx.run_until_parked();
3723
3724        // Now shows 10 threads + View More
3725        let entries = visible_entries_as_strings(&sidebar, cx);
3726        assert_eq!(entries.len(), 12); // header + 10 threads + View More
3727        assert!(entries.iter().any(|e| e.contains("View More")));
3728
3729        // Expand again by one batch
3730        sidebar.update_in(cx, |s, _window, cx| {
3731            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
3732            s.expanded_groups.insert(path_list.clone(), current + 1);
3733            s.update_entries(cx);
3734        });
3735        cx.run_until_parked();
3736
3737        // Now shows 15 threads + View More
3738        let entries = visible_entries_as_strings(&sidebar, cx);
3739        assert_eq!(entries.len(), 17); // header + 15 threads + View More
3740        assert!(entries.iter().any(|e| e.contains("View More")));
3741
3742        // Expand one more time - should show all 17 threads with Collapse button
3743        sidebar.update_in(cx, |s, _window, cx| {
3744            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
3745            s.expanded_groups.insert(path_list.clone(), current + 1);
3746            s.update_entries(cx);
3747        });
3748        cx.run_until_parked();
3749
3750        // All 17 threads shown with Collapse button
3751        let entries = visible_entries_as_strings(&sidebar, cx);
3752        assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
3753        assert!(!entries.iter().any(|e| e.contains("View More")));
3754        assert!(entries.iter().any(|e| e.contains("Collapse")));
3755
3756        // Click collapse - should go back to showing 5 threads
3757        sidebar.update_in(cx, |s, _window, cx| {
3758            s.expanded_groups.remove(&path_list);
3759            s.update_entries(cx);
3760        });
3761        cx.run_until_parked();
3762
3763        // Back to initial state: 5 threads + View More
3764        let entries = visible_entries_as_strings(&sidebar, cx);
3765        assert_eq!(entries.len(), 7); // header + 5 threads + View More
3766        assert!(entries.iter().any(|e| e.contains("View More")));
3767    }
3768
3769    #[gpui::test]
3770    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
3771        let project = init_test_project("/my-project", cx).await;
3772        let (multi_workspace, cx) =
3773            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3774        let sidebar = setup_sidebar(&multi_workspace, cx);
3775
3776        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3777        save_n_test_threads(1, &path_list, cx).await;
3778
3779        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3780        cx.run_until_parked();
3781
3782        assert_eq!(
3783            visible_entries_as_strings(&sidebar, cx),
3784            vec!["v [my-project]", "  Thread 1"]
3785        );
3786
3787        // Collapse
3788        sidebar.update_in(cx, |s, window, cx| {
3789            s.toggle_collapse(&path_list, window, cx);
3790        });
3791        cx.run_until_parked();
3792
3793        assert_eq!(
3794            visible_entries_as_strings(&sidebar, cx),
3795            vec!["> [my-project]"]
3796        );
3797
3798        // Expand
3799        sidebar.update_in(cx, |s, window, cx| {
3800            s.toggle_collapse(&path_list, window, cx);
3801        });
3802        cx.run_until_parked();
3803
3804        assert_eq!(
3805            visible_entries_as_strings(&sidebar, cx),
3806            vec!["v [my-project]", "  Thread 1"]
3807        );
3808    }
3809
3810    #[gpui::test]
3811    async fn test_collapsed_groups_restore_after_restart(cx: &mut TestAppContext) {
3812        let project = init_test_project("/my-project", cx).await;
3813        let (multi_workspace, cx) =
3814            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3815        let sidebar = setup_sidebar(&multi_workspace, cx);
3816
3817        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3818        save_n_test_threads(1, &path_list, cx).await;
3819
3820        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3821        cx.run_until_parked();
3822
3823        sidebar.update_in(cx, |s, window, cx| {
3824            s.toggle_collapse(&path_list, window, cx);
3825        });
3826        cx.run_until_parked();
3827
3828        let restored_sidebar = setup_sidebar(&multi_workspace, cx);
3829        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3830        cx.run_until_parked();
3831
3832        assert_eq!(
3833            visible_entries_as_strings(&restored_sidebar, cx),
3834            vec!["> [my-project]"]
3835        );
3836    }
3837
3838    #[gpui::test]
3839    async fn test_collapsed_groups_mixed_state_restore(cx: &mut TestAppContext) {
3840        init_test(cx);
3841        let fs = FakeFs::new(cx.executor());
3842        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
3843            .await;
3844        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
3845            .await;
3846        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3847
3848        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
3849        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
3850
3851        let (multi_workspace, cx) =
3852            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3853        multi_workspace.update_in(cx, |mw, window, cx| {
3854            mw.test_add_workspace(project_b, window, cx);
3855        });
3856        let sidebar = setup_sidebar(&multi_workspace, cx);
3857
3858        let path_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3859        let path_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
3860        save_n_test_threads(1, &path_a, cx).await;
3861        save_named_thread_metadata("thread-b", "Thread B", &path_b, cx).await;
3862
3863        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3864        cx.run_until_parked();
3865
3866        // Collapse only project-a, leave project-b expanded
3867        sidebar.update_in(cx, |s, window, cx| {
3868            s.toggle_collapse(&path_a, window, cx);
3869        });
3870        cx.run_until_parked();
3871
3872        assert_eq!(
3873            visible_entries_as_strings(&sidebar, cx),
3874            vec!["> [project-a]", "v [project-b]", "  Thread B"]
3875        );
3876
3877        // Recreate sidebar to simulate restart
3878        let restored_sidebar = setup_sidebar(&multi_workspace, cx);
3879        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3880        cx.run_until_parked();
3881
3882        // project-a should still be collapsed, project-b should still be expanded
3883        assert_eq!(
3884            visible_entries_as_strings(&restored_sidebar, cx),
3885            vec!["> [project-a]", "v [project-b]", "  Thread B"]
3886        );
3887    }
3888
3889    #[gpui::test]
3890    async fn test_collapsed_groups_empty_after_uncollapse_all(cx: &mut TestAppContext) {
3891        let project = init_test_project("/my-project", cx).await;
3892        let (multi_workspace, cx) =
3893            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3894        let sidebar = setup_sidebar(&multi_workspace, cx);
3895
3896        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3897        save_n_test_threads(1, &path_list, cx).await;
3898
3899        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3900        cx.run_until_parked();
3901
3902        // Collapse, then uncollapse
3903        sidebar.update_in(cx, |s, window, cx| {
3904            s.toggle_collapse(&path_list, window, cx);
3905        });
3906        cx.run_until_parked();
3907        sidebar.update_in(cx, |s, window, cx| {
3908            s.toggle_collapse(&path_list, window, cx);
3909        });
3910        cx.run_until_parked();
3911
3912        // Recreate sidebar — everything should be expanded
3913        let restored_sidebar = setup_sidebar(&multi_workspace, cx);
3914        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3915        cx.run_until_parked();
3916
3917        assert_eq!(
3918            visible_entries_as_strings(&restored_sidebar, cx),
3919            vec!["v [my-project]", "  Thread 1"]
3920        );
3921    }
3922
3923    #[gpui::test]
3924    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
3925        let project = init_test_project("/my-project", cx).await;
3926        let (multi_workspace, cx) =
3927            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3928        let sidebar = setup_sidebar(&multi_workspace, cx);
3929
3930        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3931        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
3932        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
3933
3934        sidebar.update_in(cx, |s, _window, _cx| {
3935            s.collapsed_groups.insert(collapsed_path.clone());
3936            s.contents
3937                .notified_threads
3938                .insert(acp::SessionId::new(Arc::from("t-5")));
3939            s.contents.entries = vec![
3940                // Expanded project header
3941                ListEntry::ProjectHeader {
3942                    path_list: expanded_path.clone(),
3943                    label: "expanded-project".into(),
3944                    workspace: workspace.clone(),
3945                    highlight_positions: Vec::new(),
3946                    has_running_threads: false,
3947                    waiting_thread_count: 0,
3948                    is_active: true,
3949                },
3950                ListEntry::Thread(ThreadEntry {
3951                    agent: Agent::NativeAgent,
3952                    session_info: acp_thread::AgentSessionInfo {
3953                        session_id: acp::SessionId::new(Arc::from("t-1")),
3954                        work_dirs: None,
3955                        title: Some("Completed thread".into()),
3956                        updated_at: Some(Utc::now()),
3957                        created_at: Some(Utc::now()),
3958                        meta: None,
3959                    },
3960                    icon: IconName::ZedAgent,
3961                    icon_from_external_svg: None,
3962                    status: AgentThreadStatus::Completed,
3963                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3964                    is_live: false,
3965                    is_background: false,
3966                    is_title_generating: false,
3967                    highlight_positions: Vec::new(),
3968                    worktree_name: None,
3969                    worktree_full_path: None,
3970                    worktree_highlight_positions: Vec::new(),
3971                    diff_stats: DiffStats::default(),
3972                }),
3973                // Active thread with Running status
3974                ListEntry::Thread(ThreadEntry {
3975                    agent: Agent::NativeAgent,
3976                    session_info: acp_thread::AgentSessionInfo {
3977                        session_id: acp::SessionId::new(Arc::from("t-2")),
3978                        work_dirs: None,
3979                        title: Some("Running thread".into()),
3980                        updated_at: Some(Utc::now()),
3981                        created_at: Some(Utc::now()),
3982                        meta: None,
3983                    },
3984                    icon: IconName::ZedAgent,
3985                    icon_from_external_svg: None,
3986                    status: AgentThreadStatus::Running,
3987                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3988                    is_live: true,
3989                    is_background: false,
3990                    is_title_generating: false,
3991                    highlight_positions: Vec::new(),
3992                    worktree_name: None,
3993                    worktree_full_path: None,
3994                    worktree_highlight_positions: Vec::new(),
3995                    diff_stats: DiffStats::default(),
3996                }),
3997                // Active thread with Error status
3998                ListEntry::Thread(ThreadEntry {
3999                    agent: Agent::NativeAgent,
4000                    session_info: acp_thread::AgentSessionInfo {
4001                        session_id: acp::SessionId::new(Arc::from("t-3")),
4002                        work_dirs: None,
4003                        title: Some("Error thread".into()),
4004                        updated_at: Some(Utc::now()),
4005                        created_at: Some(Utc::now()),
4006                        meta: None,
4007                    },
4008                    icon: IconName::ZedAgent,
4009                    icon_from_external_svg: None,
4010                    status: AgentThreadStatus::Error,
4011                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
4012                    is_live: true,
4013                    is_background: false,
4014                    is_title_generating: false,
4015                    highlight_positions: Vec::new(),
4016                    worktree_name: None,
4017                    worktree_full_path: None,
4018                    worktree_highlight_positions: Vec::new(),
4019                    diff_stats: DiffStats::default(),
4020                }),
4021                // Thread with WaitingForConfirmation status, not active
4022                ListEntry::Thread(ThreadEntry {
4023                    agent: Agent::NativeAgent,
4024                    session_info: acp_thread::AgentSessionInfo {
4025                        session_id: acp::SessionId::new(Arc::from("t-4")),
4026                        work_dirs: None,
4027                        title: Some("Waiting thread".into()),
4028                        updated_at: Some(Utc::now()),
4029                        created_at: Some(Utc::now()),
4030                        meta: None,
4031                    },
4032                    icon: IconName::ZedAgent,
4033                    icon_from_external_svg: None,
4034                    status: AgentThreadStatus::WaitingForConfirmation,
4035                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
4036                    is_live: false,
4037                    is_background: false,
4038                    is_title_generating: false,
4039                    highlight_positions: Vec::new(),
4040                    worktree_name: None,
4041                    worktree_full_path: None,
4042                    worktree_highlight_positions: Vec::new(),
4043                    diff_stats: DiffStats::default(),
4044                }),
4045                // Background thread that completed (should show notification)
4046                ListEntry::Thread(ThreadEntry {
4047                    agent: Agent::NativeAgent,
4048                    session_info: acp_thread::AgentSessionInfo {
4049                        session_id: acp::SessionId::new(Arc::from("t-5")),
4050                        work_dirs: None,
4051                        title: Some("Notified thread".into()),
4052                        updated_at: Some(Utc::now()),
4053                        created_at: Some(Utc::now()),
4054                        meta: None,
4055                    },
4056                    icon: IconName::ZedAgent,
4057                    icon_from_external_svg: None,
4058                    status: AgentThreadStatus::Completed,
4059                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
4060                    is_live: true,
4061                    is_background: true,
4062                    is_title_generating: false,
4063                    highlight_positions: Vec::new(),
4064                    worktree_name: None,
4065                    worktree_full_path: None,
4066                    worktree_highlight_positions: Vec::new(),
4067                    diff_stats: DiffStats::default(),
4068                }),
4069                // View More entry
4070                ListEntry::ViewMore {
4071                    path_list: expanded_path.clone(),
4072                    is_fully_expanded: false,
4073                },
4074                // Collapsed project header
4075                ListEntry::ProjectHeader {
4076                    path_list: collapsed_path.clone(),
4077                    label: "collapsed-project".into(),
4078                    workspace: workspace.clone(),
4079                    highlight_positions: Vec::new(),
4080                    has_running_threads: false,
4081                    waiting_thread_count: 0,
4082                    is_active: false,
4083                },
4084            ];
4085
4086            // Select the Running thread (index 2)
4087            s.selection = Some(2);
4088        });
4089
4090        assert_eq!(
4091            visible_entries_as_strings(&sidebar, cx),
4092            vec![
4093                "v [expanded-project]",
4094                "  Completed thread",
4095                "  Running thread * (running)  <== selected",
4096                "  Error thread * (error)",
4097                "  Waiting thread (waiting)",
4098                "  Notified thread * (!)",
4099                "  + View More",
4100                "> [collapsed-project]",
4101            ]
4102        );
4103
4104        // Move selection to the collapsed header
4105        sidebar.update_in(cx, |s, _window, _cx| {
4106            s.selection = Some(7);
4107        });
4108
4109        assert_eq!(
4110            visible_entries_as_strings(&sidebar, cx).last().cloned(),
4111            Some("> [collapsed-project]  <== selected".to_string()),
4112        );
4113
4114        // Clear selection
4115        sidebar.update_in(cx, |s, _window, _cx| {
4116            s.selection = None;
4117        });
4118
4119        // No entry should have the selected marker
4120        let entries = visible_entries_as_strings(&sidebar, cx);
4121        for entry in &entries {
4122            assert!(
4123                !entry.contains("<== selected"),
4124                "unexpected selection marker in: {}",
4125                entry
4126            );
4127        }
4128    }
4129
4130    #[gpui::test]
4131    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
4132        let project = init_test_project("/my-project", cx).await;
4133        let (multi_workspace, cx) =
4134            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4135        let sidebar = setup_sidebar(&multi_workspace, cx);
4136
4137        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4138        save_n_test_threads(3, &path_list, cx).await;
4139
4140        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4141        cx.run_until_parked();
4142
4143        // Entries: [header, thread3, thread2, thread1]
4144        // Focusing the sidebar does not set a selection; select_next/select_previous
4145        // handle None gracefully by starting from the first or last entry.
4146        open_and_focus_sidebar(&sidebar, cx);
4147        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4148
4149        // First SelectNext from None starts at index 0
4150        cx.dispatch_action(SelectNext);
4151        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4152
4153        // Move down through remaining entries
4154        cx.dispatch_action(SelectNext);
4155        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4156
4157        cx.dispatch_action(SelectNext);
4158        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
4159
4160        cx.dispatch_action(SelectNext);
4161        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
4162
4163        // At the end, wraps back to first entry
4164        cx.dispatch_action(SelectNext);
4165        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4166
4167        // Navigate back to the end
4168        cx.dispatch_action(SelectNext);
4169        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4170        cx.dispatch_action(SelectNext);
4171        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
4172        cx.dispatch_action(SelectNext);
4173        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
4174
4175        // Move back up
4176        cx.dispatch_action(SelectPrevious);
4177        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
4178
4179        cx.dispatch_action(SelectPrevious);
4180        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4181
4182        cx.dispatch_action(SelectPrevious);
4183        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4184
4185        // At the top, selection clears (focus returns to editor)
4186        cx.dispatch_action(SelectPrevious);
4187        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4188    }
4189
4190    #[gpui::test]
4191    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
4192        let project = init_test_project("/my-project", cx).await;
4193        let (multi_workspace, cx) =
4194            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4195        let sidebar = setup_sidebar(&multi_workspace, cx);
4196
4197        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4198        save_n_test_threads(3, &path_list, cx).await;
4199        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4200        cx.run_until_parked();
4201
4202        open_and_focus_sidebar(&sidebar, cx);
4203
4204        // SelectLast jumps to the end
4205        cx.dispatch_action(SelectLast);
4206        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
4207
4208        // SelectFirst jumps to the beginning
4209        cx.dispatch_action(SelectFirst);
4210        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4211    }
4212
4213    #[gpui::test]
4214    async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
4215        let project = init_test_project("/my-project", cx).await;
4216        let (multi_workspace, cx) =
4217            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4218        let sidebar = setup_sidebar(&multi_workspace, cx);
4219
4220        // Initially no selection
4221        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4222
4223        // Open the sidebar so it's rendered, then focus it to trigger focus_in.
4224        // focus_in no longer sets a default selection.
4225        open_and_focus_sidebar(&sidebar, cx);
4226        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4227
4228        // Manually set a selection, blur, then refocus — selection should be preserved
4229        sidebar.update_in(cx, |sidebar, _window, _cx| {
4230            sidebar.selection = Some(0);
4231        });
4232
4233        cx.update(|window, _cx| {
4234            window.blur();
4235        });
4236        cx.run_until_parked();
4237
4238        sidebar.update_in(cx, |_, window, cx| {
4239            cx.focus_self(window);
4240        });
4241        cx.run_until_parked();
4242        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4243    }
4244
4245    #[gpui::test]
4246    async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
4247        let project = init_test_project("/my-project", cx).await;
4248        let (multi_workspace, cx) =
4249            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4250        let sidebar = setup_sidebar(&multi_workspace, cx);
4251
4252        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4253        save_n_test_threads(1, &path_list, cx).await;
4254        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4255        cx.run_until_parked();
4256
4257        assert_eq!(
4258            visible_entries_as_strings(&sidebar, cx),
4259            vec!["v [my-project]", "  Thread 1"]
4260        );
4261
4262        // Focus the sidebar and select the header (index 0)
4263        open_and_focus_sidebar(&sidebar, cx);
4264        sidebar.update_in(cx, |sidebar, _window, _cx| {
4265            sidebar.selection = Some(0);
4266        });
4267
4268        // Confirm on project header collapses the group
4269        cx.dispatch_action(Confirm);
4270        cx.run_until_parked();
4271
4272        assert_eq!(
4273            visible_entries_as_strings(&sidebar, cx),
4274            vec!["> [my-project]  <== selected"]
4275        );
4276
4277        // Confirm again expands the group
4278        cx.dispatch_action(Confirm);
4279        cx.run_until_parked();
4280
4281        assert_eq!(
4282            visible_entries_as_strings(&sidebar, cx),
4283            vec!["v [my-project]  <== selected", "  Thread 1",]
4284        );
4285    }
4286
4287    #[gpui::test]
4288    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
4289        let project = init_test_project("/my-project", cx).await;
4290        let (multi_workspace, cx) =
4291            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4292        let sidebar = setup_sidebar(&multi_workspace, cx);
4293
4294        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4295        save_n_test_threads(8, &path_list, cx).await;
4296        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4297        cx.run_until_parked();
4298
4299        // Should show header + 5 threads + "View More"
4300        let entries = visible_entries_as_strings(&sidebar, cx);
4301        assert_eq!(entries.len(), 7);
4302        assert!(entries.iter().any(|e| e.contains("View More")));
4303
4304        // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
4305        open_and_focus_sidebar(&sidebar, cx);
4306        for _ in 0..7 {
4307            cx.dispatch_action(SelectNext);
4308        }
4309        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
4310
4311        // Confirm on "View More" to expand
4312        cx.dispatch_action(Confirm);
4313        cx.run_until_parked();
4314
4315        // All 8 threads should now be visible with a "Collapse" button
4316        let entries = visible_entries_as_strings(&sidebar, cx);
4317        assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
4318        assert!(!entries.iter().any(|e| e.contains("View More")));
4319        assert!(entries.iter().any(|e| e.contains("Collapse")));
4320    }
4321
4322    #[gpui::test]
4323    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
4324        let project = init_test_project("/my-project", cx).await;
4325        let (multi_workspace, cx) =
4326            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4327        let sidebar = setup_sidebar(&multi_workspace, cx);
4328
4329        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4330        save_n_test_threads(1, &path_list, cx).await;
4331        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4332        cx.run_until_parked();
4333
4334        assert_eq!(
4335            visible_entries_as_strings(&sidebar, cx),
4336            vec!["v [my-project]", "  Thread 1"]
4337        );
4338
4339        // Focus sidebar and manually select the header (index 0). Press left to collapse.
4340        open_and_focus_sidebar(&sidebar, cx);
4341        sidebar.update_in(cx, |sidebar, _window, _cx| {
4342            sidebar.selection = Some(0);
4343        });
4344
4345        cx.dispatch_action(SelectParent);
4346        cx.run_until_parked();
4347
4348        assert_eq!(
4349            visible_entries_as_strings(&sidebar, cx),
4350            vec!["> [my-project]  <== selected"]
4351        );
4352
4353        // Press right to expand
4354        cx.dispatch_action(SelectChild);
4355        cx.run_until_parked();
4356
4357        assert_eq!(
4358            visible_entries_as_strings(&sidebar, cx),
4359            vec!["v [my-project]  <== selected", "  Thread 1",]
4360        );
4361
4362        // Press right again on already-expanded header moves selection down
4363        cx.dispatch_action(SelectChild);
4364        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4365    }
4366
4367    #[gpui::test]
4368    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
4369        let project = init_test_project("/my-project", cx).await;
4370        let (multi_workspace, cx) =
4371            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4372        let sidebar = setup_sidebar(&multi_workspace, cx);
4373
4374        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4375        save_n_test_threads(1, &path_list, cx).await;
4376        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4377        cx.run_until_parked();
4378
4379        // Focus sidebar (selection starts at None), then navigate down to the thread (child)
4380        open_and_focus_sidebar(&sidebar, cx);
4381        cx.dispatch_action(SelectNext);
4382        cx.dispatch_action(SelectNext);
4383        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4384
4385        assert_eq!(
4386            visible_entries_as_strings(&sidebar, cx),
4387            vec!["v [my-project]", "  Thread 1  <== selected",]
4388        );
4389
4390        // Pressing left on a child collapses the parent group and selects it
4391        cx.dispatch_action(SelectParent);
4392        cx.run_until_parked();
4393
4394        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4395        assert_eq!(
4396            visible_entries_as_strings(&sidebar, cx),
4397            vec!["> [my-project]  <== selected"]
4398        );
4399    }
4400
4401    #[gpui::test]
4402    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
4403        let project = init_test_project("/empty-project", cx).await;
4404        let (multi_workspace, cx) =
4405            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4406        let sidebar = setup_sidebar(&multi_workspace, cx);
4407
4408        // An empty project has the header and a new thread button.
4409        assert_eq!(
4410            visible_entries_as_strings(&sidebar, cx),
4411            vec!["v [empty-project]", "  [+ New Thread]"]
4412        );
4413
4414        // Focus sidebar — focus_in does not set a selection
4415        open_and_focus_sidebar(&sidebar, cx);
4416        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4417
4418        // First SelectNext from None starts at index 0 (header)
4419        cx.dispatch_action(SelectNext);
4420        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4421
4422        // SelectNext moves to the new thread button
4423        cx.dispatch_action(SelectNext);
4424        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4425
4426        // At the end, wraps back to first entry
4427        cx.dispatch_action(SelectNext);
4428        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4429
4430        // SelectPrevious from first entry clears selection (returns to editor)
4431        cx.dispatch_action(SelectPrevious);
4432        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4433    }
4434
4435    #[gpui::test]
4436    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
4437        let project = init_test_project("/my-project", cx).await;
4438        let (multi_workspace, cx) =
4439            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4440        let sidebar = setup_sidebar(&multi_workspace, cx);
4441
4442        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4443        save_n_test_threads(1, &path_list, cx).await;
4444        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4445        cx.run_until_parked();
4446
4447        // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
4448        open_and_focus_sidebar(&sidebar, cx);
4449        cx.dispatch_action(SelectNext);
4450        cx.dispatch_action(SelectNext);
4451        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4452
4453        // Collapse the group, which removes the thread from the list
4454        cx.dispatch_action(SelectParent);
4455        cx.run_until_parked();
4456
4457        // Selection should be clamped to the last valid index (0 = header)
4458        let selection = sidebar.read_with(cx, |s, _| s.selection);
4459        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
4460        assert!(
4461            selection.unwrap_or(0) < entry_count,
4462            "selection {} should be within bounds (entries: {})",
4463            selection.unwrap_or(0),
4464            entry_count,
4465        );
4466    }
4467
4468    async fn init_test_project_with_agent_panel(
4469        worktree_path: &str,
4470        cx: &mut TestAppContext,
4471    ) -> Entity<project::Project> {
4472        agent_ui::test_support::init_test(cx);
4473        cx.update(|cx| {
4474            cx.update_flags(false, vec!["agent-v2".into()]);
4475            ThreadStore::init_global(cx);
4476            SidebarThreadMetadataStore::init_global(cx);
4477            language_model::LanguageModelRegistry::test(cx);
4478            prompt_store::init(cx);
4479        });
4480
4481        let fs = FakeFs::new(cx.executor());
4482        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
4483            .await;
4484        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4485        project::Project::test(fs, [worktree_path.as_ref()], cx).await
4486    }
4487
4488    fn add_agent_panel(
4489        workspace: &Entity<Workspace>,
4490        project: &Entity<project::Project>,
4491        cx: &mut gpui::VisualTestContext,
4492    ) -> Entity<AgentPanel> {
4493        workspace.update_in(cx, |workspace, window, cx| {
4494            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4495            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
4496            workspace.add_panel(panel.clone(), window, cx);
4497            panel
4498        })
4499    }
4500
4501    fn setup_sidebar_with_agent_panel(
4502        multi_workspace: &Entity<MultiWorkspace>,
4503        project: &Entity<project::Project>,
4504        cx: &mut gpui::VisualTestContext,
4505    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
4506        let sidebar = setup_sidebar(multi_workspace, cx);
4507        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
4508        let panel = add_agent_panel(&workspace, project, cx);
4509        (sidebar, panel)
4510    }
4511
4512    #[gpui::test]
4513    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
4514        let project = init_test_project_with_agent_panel("/my-project", cx).await;
4515        let (multi_workspace, cx) =
4516            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4517        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
4518
4519        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4520
4521        // Open thread A and keep it generating.
4522        let connection = StubAgentConnection::new();
4523        open_thread_with_connection(&panel, connection.clone(), cx);
4524        send_message(&panel, cx);
4525
4526        let session_id_a = active_session_id(&panel, cx);
4527        save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await;
4528
4529        cx.update(|_, cx| {
4530            connection.send_update(
4531                session_id_a.clone(),
4532                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4533                cx,
4534            );
4535        });
4536        cx.run_until_parked();
4537
4538        // Open thread B (idle, default response) — thread A goes to background.
4539        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4540            acp::ContentChunk::new("Done".into()),
4541        )]);
4542        open_thread_with_connection(&panel, connection, cx);
4543        send_message(&panel, cx);
4544
4545        let session_id_b = active_session_id(&panel, cx);
4546        save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await;
4547
4548        cx.run_until_parked();
4549
4550        let mut entries = visible_entries_as_strings(&sidebar, cx);
4551        entries[1..].sort();
4552        assert_eq!(
4553            entries,
4554            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
4555        );
4556    }
4557
4558    #[gpui::test]
4559    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
4560        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
4561        let (multi_workspace, cx) = cx
4562            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4563        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
4564
4565        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4566
4567        // Open thread on workspace A and keep it generating.
4568        let connection_a = StubAgentConnection::new();
4569        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
4570        send_message(&panel_a, cx);
4571
4572        let session_id_a = active_session_id(&panel_a, cx);
4573        save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
4574
4575        cx.update(|_, cx| {
4576            connection_a.send_update(
4577                session_id_a.clone(),
4578                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4579                cx,
4580            );
4581        });
4582        cx.run_until_parked();
4583
4584        // Add a second workspace and activate it (making workspace A the background).
4585        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
4586        let project_b = project::Project::test(fs, [], cx).await;
4587        multi_workspace.update_in(cx, |mw, window, cx| {
4588            mw.test_add_workspace(project_b, window, cx);
4589        });
4590        cx.run_until_parked();
4591
4592        // Thread A is still running; no notification yet.
4593        assert_eq!(
4594            visible_entries_as_strings(&sidebar, cx),
4595            vec!["v [project-a]", "  Hello * (running)",]
4596        );
4597
4598        // Complete thread A's turn (transition Running → Completed).
4599        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
4600        cx.run_until_parked();
4601
4602        // The completed background thread shows a notification indicator.
4603        assert_eq!(
4604            visible_entries_as_strings(&sidebar, cx),
4605            vec!["v [project-a]", "  Hello * (!)",]
4606        );
4607    }
4608
4609    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
4610        sidebar.update_in(cx, |sidebar, window, cx| {
4611            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
4612            sidebar.filter_editor.update(cx, |editor, cx| {
4613                editor.set_text(query, window, cx);
4614            });
4615        });
4616        cx.run_until_parked();
4617    }
4618
4619    #[gpui::test]
4620    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
4621        let project = init_test_project("/my-project", cx).await;
4622        let (multi_workspace, cx) =
4623            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4624        let sidebar = setup_sidebar(&multi_workspace, cx);
4625
4626        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4627
4628        for (id, title, hour) in [
4629            ("t-1", "Fix crash in project panel", 3),
4630            ("t-2", "Add inline diff view", 2),
4631            ("t-3", "Refactor settings module", 1),
4632        ] {
4633            save_thread_metadata(
4634                acp::SessionId::new(Arc::from(id)),
4635                title.into(),
4636                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4637                path_list.clone(),
4638                cx,
4639            )
4640            .await;
4641        }
4642        cx.run_until_parked();
4643
4644        assert_eq!(
4645            visible_entries_as_strings(&sidebar, cx),
4646            vec![
4647                "v [my-project]",
4648                "  Fix crash in project panel",
4649                "  Add inline diff view",
4650                "  Refactor settings module",
4651            ]
4652        );
4653
4654        // User types "diff" in the search box — only the matching thread remains,
4655        // with its workspace header preserved for context.
4656        type_in_search(&sidebar, "diff", cx);
4657        assert_eq!(
4658            visible_entries_as_strings(&sidebar, cx),
4659            vec!["v [my-project]", "  Add inline diff view  <== selected",]
4660        );
4661
4662        // User changes query to something with no matches — list is empty.
4663        type_in_search(&sidebar, "nonexistent", cx);
4664        assert_eq!(
4665            visible_entries_as_strings(&sidebar, cx),
4666            Vec::<String>::new()
4667        );
4668    }
4669
4670    #[gpui::test]
4671    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
4672        // Scenario: A user remembers a thread title but not the exact casing.
4673        // Search should match case-insensitively so they can still find it.
4674        let project = init_test_project("/my-project", cx).await;
4675        let (multi_workspace, cx) =
4676            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4677        let sidebar = setup_sidebar(&multi_workspace, cx);
4678
4679        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4680
4681        save_thread_metadata(
4682            acp::SessionId::new(Arc::from("thread-1")),
4683            "Fix Crash In Project Panel".into(),
4684            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4685            path_list.clone(),
4686            cx,
4687        )
4688        .await;
4689        cx.run_until_parked();
4690
4691        // Lowercase query matches mixed-case title.
4692        type_in_search(&sidebar, "fix crash", cx);
4693        assert_eq!(
4694            visible_entries_as_strings(&sidebar, cx),
4695            vec![
4696                "v [my-project]",
4697                "  Fix Crash In Project Panel  <== selected",
4698            ]
4699        );
4700
4701        // Uppercase query also matches the same title.
4702        type_in_search(&sidebar, "FIX CRASH", cx);
4703        assert_eq!(
4704            visible_entries_as_strings(&sidebar, cx),
4705            vec![
4706                "v [my-project]",
4707                "  Fix Crash In Project Panel  <== selected",
4708            ]
4709        );
4710    }
4711
4712    #[gpui::test]
4713    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
4714        // Scenario: A user searches, finds what they need, then presses Escape
4715        // to dismiss the filter and see the full list again.
4716        let project = init_test_project("/my-project", cx).await;
4717        let (multi_workspace, cx) =
4718            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4719        let sidebar = setup_sidebar(&multi_workspace, cx);
4720
4721        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4722
4723        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
4724            save_thread_metadata(
4725                acp::SessionId::new(Arc::from(id)),
4726                title.into(),
4727                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4728                path_list.clone(),
4729                cx,
4730            )
4731            .await;
4732        }
4733        cx.run_until_parked();
4734
4735        // Confirm the full list is showing.
4736        assert_eq!(
4737            visible_entries_as_strings(&sidebar, cx),
4738            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
4739        );
4740
4741        // User types a search query to filter down.
4742        open_and_focus_sidebar(&sidebar, cx);
4743        type_in_search(&sidebar, "alpha", cx);
4744        assert_eq!(
4745            visible_entries_as_strings(&sidebar, cx),
4746            vec!["v [my-project]", "  Alpha thread  <== selected",]
4747        );
4748
4749        // User presses Escape — filter clears, full list is restored.
4750        // The selection index (1) now points at the first thread entry.
4751        cx.dispatch_action(Cancel);
4752        cx.run_until_parked();
4753        assert_eq!(
4754            visible_entries_as_strings(&sidebar, cx),
4755            vec![
4756                "v [my-project]",
4757                "  Alpha thread  <== selected",
4758                "  Beta thread",
4759            ]
4760        );
4761    }
4762
4763    #[gpui::test]
4764    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
4765        let project_a = init_test_project("/project-a", cx).await;
4766        let (multi_workspace, cx) =
4767            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4768        let sidebar = setup_sidebar(&multi_workspace, cx);
4769
4770        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4771
4772        for (id, title, hour) in [
4773            ("a1", "Fix bug in sidebar", 2),
4774            ("a2", "Add tests for editor", 1),
4775        ] {
4776            save_thread_metadata(
4777                acp::SessionId::new(Arc::from(id)),
4778                title.into(),
4779                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4780                path_list_a.clone(),
4781                cx,
4782            )
4783            .await;
4784        }
4785
4786        // Add a second workspace.
4787        multi_workspace.update_in(cx, |mw, window, cx| {
4788            mw.create_test_workspace(window, cx).detach();
4789        });
4790        cx.run_until_parked();
4791
4792        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
4793
4794        for (id, title, hour) in [
4795            ("b1", "Refactor sidebar layout", 3),
4796            ("b2", "Fix typo in README", 1),
4797        ] {
4798            save_thread_metadata(
4799                acp::SessionId::new(Arc::from(id)),
4800                title.into(),
4801                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4802                path_list_b.clone(),
4803                cx,
4804            )
4805            .await;
4806        }
4807        cx.run_until_parked();
4808
4809        assert_eq!(
4810            visible_entries_as_strings(&sidebar, cx),
4811            vec![
4812                "v [project-a]",
4813                "  Fix bug in sidebar",
4814                "  Add tests for editor",
4815            ]
4816        );
4817
4818        // "sidebar" matches a thread in each workspace — both headers stay visible.
4819        type_in_search(&sidebar, "sidebar", cx);
4820        assert_eq!(
4821            visible_entries_as_strings(&sidebar, cx),
4822            vec!["v [project-a]", "  Fix bug in sidebar  <== selected",]
4823        );
4824
4825        // "typo" only matches in the second workspace — the first header disappears.
4826        type_in_search(&sidebar, "typo", cx);
4827        assert_eq!(
4828            visible_entries_as_strings(&sidebar, cx),
4829            Vec::<String>::new()
4830        );
4831
4832        // "project-a" matches the first workspace name — the header appears
4833        // with all child threads included.
4834        type_in_search(&sidebar, "project-a", cx);
4835        assert_eq!(
4836            visible_entries_as_strings(&sidebar, cx),
4837            vec![
4838                "v [project-a]",
4839                "  Fix bug in sidebar  <== selected",
4840                "  Add tests for editor",
4841            ]
4842        );
4843    }
4844
4845    #[gpui::test]
4846    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
4847        let project_a = init_test_project("/alpha-project", cx).await;
4848        let (multi_workspace, cx) =
4849            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4850        let sidebar = setup_sidebar(&multi_workspace, cx);
4851
4852        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
4853
4854        for (id, title, hour) in [
4855            ("a1", "Fix bug in sidebar", 2),
4856            ("a2", "Add tests for editor", 1),
4857        ] {
4858            save_thread_metadata(
4859                acp::SessionId::new(Arc::from(id)),
4860                title.into(),
4861                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4862                path_list_a.clone(),
4863                cx,
4864            )
4865            .await;
4866        }
4867
4868        // Add a second workspace.
4869        multi_workspace.update_in(cx, |mw, window, cx| {
4870            mw.create_test_workspace(window, cx).detach();
4871        });
4872        cx.run_until_parked();
4873
4874        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
4875
4876        for (id, title, hour) in [
4877            ("b1", "Refactor sidebar layout", 3),
4878            ("b2", "Fix typo in README", 1),
4879        ] {
4880            save_thread_metadata(
4881                acp::SessionId::new(Arc::from(id)),
4882                title.into(),
4883                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4884                path_list_b.clone(),
4885                cx,
4886            )
4887            .await;
4888        }
4889        cx.run_until_parked();
4890
4891        // "alpha" matches the workspace name "alpha-project" but no thread titles.
4892        // The workspace header should appear with all child threads included.
4893        type_in_search(&sidebar, "alpha", cx);
4894        assert_eq!(
4895            visible_entries_as_strings(&sidebar, cx),
4896            vec![
4897                "v [alpha-project]",
4898                "  Fix bug in sidebar  <== selected",
4899                "  Add tests for editor",
4900            ]
4901        );
4902
4903        // "sidebar" matches thread titles in both workspaces but not workspace names.
4904        // Both headers appear with their matching threads.
4905        type_in_search(&sidebar, "sidebar", cx);
4906        assert_eq!(
4907            visible_entries_as_strings(&sidebar, cx),
4908            vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
4909        );
4910
4911        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
4912        // doesn't match) — but does not match either workspace name or any thread.
4913        // Actually let's test something simpler: a query that matches both a workspace
4914        // name AND some threads in that workspace. Matching threads should still appear.
4915        type_in_search(&sidebar, "fix", cx);
4916        assert_eq!(
4917            visible_entries_as_strings(&sidebar, cx),
4918            vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
4919        );
4920
4921        // A query that matches a workspace name AND a thread in that same workspace.
4922        // Both the header (highlighted) and all child threads should appear.
4923        type_in_search(&sidebar, "alpha", cx);
4924        assert_eq!(
4925            visible_entries_as_strings(&sidebar, cx),
4926            vec![
4927                "v [alpha-project]",
4928                "  Fix bug in sidebar  <== selected",
4929                "  Add tests for editor",
4930            ]
4931        );
4932
4933        // Now search for something that matches only a workspace name when there
4934        // are also threads with matching titles — the non-matching workspace's
4935        // threads should still appear if their titles match.
4936        type_in_search(&sidebar, "alp", cx);
4937        assert_eq!(
4938            visible_entries_as_strings(&sidebar, cx),
4939            vec![
4940                "v [alpha-project]",
4941                "  Fix bug in sidebar  <== selected",
4942                "  Add tests for editor",
4943            ]
4944        );
4945    }
4946
4947    #[gpui::test]
4948    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
4949        let project = init_test_project("/my-project", cx).await;
4950        let (multi_workspace, cx) =
4951            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4952        let sidebar = setup_sidebar(&multi_workspace, cx);
4953
4954        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4955
4956        // Create 8 threads. The oldest one has a unique name and will be
4957        // behind View More (only 5 shown by default).
4958        for i in 0..8u32 {
4959            let title = if i == 0 {
4960                "Hidden gem thread".to_string()
4961            } else {
4962                format!("Thread {}", i + 1)
4963            };
4964            save_thread_metadata(
4965                acp::SessionId::new(Arc::from(format!("thread-{}", i))),
4966                title.into(),
4967                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
4968                path_list.clone(),
4969                cx,
4970            )
4971            .await;
4972        }
4973        cx.run_until_parked();
4974
4975        // Confirm the thread is not visible and View More is shown.
4976        let entries = visible_entries_as_strings(&sidebar, cx);
4977        assert!(
4978            entries.iter().any(|e| e.contains("View More")),
4979            "should have View More button"
4980        );
4981        assert!(
4982            !entries.iter().any(|e| e.contains("Hidden gem")),
4983            "Hidden gem should be behind View More"
4984        );
4985
4986        // User searches for the hidden thread — it appears, and View More is gone.
4987        type_in_search(&sidebar, "hidden gem", cx);
4988        let filtered = visible_entries_as_strings(&sidebar, cx);
4989        assert_eq!(
4990            filtered,
4991            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
4992        );
4993        assert!(
4994            !filtered.iter().any(|e| e.contains("View More")),
4995            "View More should not appear when filtering"
4996        );
4997    }
4998
4999    #[gpui::test]
5000    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
5001        let project = init_test_project("/my-project", cx).await;
5002        let (multi_workspace, cx) =
5003            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
5004        let sidebar = setup_sidebar(&multi_workspace, cx);
5005
5006        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5007
5008        save_thread_metadata(
5009            acp::SessionId::new(Arc::from("thread-1")),
5010            "Important thread".into(),
5011            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5012            path_list.clone(),
5013            cx,
5014        )
5015        .await;
5016        cx.run_until_parked();
5017
5018        // User focuses the sidebar and collapses the group using keyboard:
5019        // manually select the header, then press SelectParent to collapse.
5020        open_and_focus_sidebar(&sidebar, cx);
5021        sidebar.update_in(cx, |sidebar, _window, _cx| {
5022            sidebar.selection = Some(0);
5023        });
5024        cx.dispatch_action(SelectParent);
5025        cx.run_until_parked();
5026
5027        assert_eq!(
5028            visible_entries_as_strings(&sidebar, cx),
5029            vec!["> [my-project]  <== selected"]
5030        );
5031
5032        // User types a search — the thread appears even though its group is collapsed.
5033        type_in_search(&sidebar, "important", cx);
5034        assert_eq!(
5035            visible_entries_as_strings(&sidebar, cx),
5036            vec!["> [my-project]", "  Important thread  <== selected",]
5037        );
5038    }
5039
5040    #[gpui::test]
5041    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
5042        let project = init_test_project("/my-project", cx).await;
5043        let (multi_workspace, cx) =
5044            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
5045        let sidebar = setup_sidebar(&multi_workspace, cx);
5046
5047        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5048
5049        for (id, title, hour) in [
5050            ("t-1", "Fix crash in panel", 3),
5051            ("t-2", "Fix lint warnings", 2),
5052            ("t-3", "Add new feature", 1),
5053        ] {
5054            save_thread_metadata(
5055                acp::SessionId::new(Arc::from(id)),
5056                title.into(),
5057                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
5058                path_list.clone(),
5059                cx,
5060            )
5061            .await;
5062        }
5063        cx.run_until_parked();
5064
5065        open_and_focus_sidebar(&sidebar, cx);
5066
5067        // User types "fix" — two threads match.
5068        type_in_search(&sidebar, "fix", cx);
5069        assert_eq!(
5070            visible_entries_as_strings(&sidebar, cx),
5071            vec![
5072                "v [my-project]",
5073                "  Fix crash in panel  <== selected",
5074                "  Fix lint warnings",
5075            ]
5076        );
5077
5078        // Selection starts on the first matching thread. User presses
5079        // SelectNext to move to the second match.
5080        cx.dispatch_action(SelectNext);
5081        assert_eq!(
5082            visible_entries_as_strings(&sidebar, cx),
5083            vec![
5084                "v [my-project]",
5085                "  Fix crash in panel",
5086                "  Fix lint warnings  <== selected",
5087            ]
5088        );
5089
5090        // User can also jump back with SelectPrevious.
5091        cx.dispatch_action(SelectPrevious);
5092        assert_eq!(
5093            visible_entries_as_strings(&sidebar, cx),
5094            vec![
5095                "v [my-project]",
5096                "  Fix crash in panel  <== selected",
5097                "  Fix lint warnings",
5098            ]
5099        );
5100    }
5101
5102    #[gpui::test]
5103    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
5104        let project = init_test_project("/my-project", cx).await;
5105        let (multi_workspace, cx) =
5106            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
5107        let sidebar = setup_sidebar(&multi_workspace, cx);
5108
5109        multi_workspace.update_in(cx, |mw, window, cx| {
5110            mw.create_test_workspace(window, cx).detach();
5111        });
5112        cx.run_until_parked();
5113
5114        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5115
5116        save_thread_metadata(
5117            acp::SessionId::new(Arc::from("hist-1")),
5118            "Historical Thread".into(),
5119            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
5120            path_list.clone(),
5121            cx,
5122        )
5123        .await;
5124        cx.run_until_parked();
5125        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5126        cx.run_until_parked();
5127
5128        assert_eq!(
5129            visible_entries_as_strings(&sidebar, cx),
5130            vec!["v [my-project]", "  Historical Thread",]
5131        );
5132
5133        // Switch to workspace 1 so we can verify the confirm switches back.
5134        multi_workspace.update_in(cx, |mw, window, cx| {
5135            mw.activate_index(1, window, cx);
5136        });
5137        cx.run_until_parked();
5138        assert_eq!(
5139            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
5140            1
5141        );
5142
5143        // Confirm on the historical (non-live) thread at index 1.
5144        // Before a previous fix, the workspace field was Option<usize> and
5145        // historical threads had None, so activate_thread early-returned
5146        // without switching the workspace.
5147        sidebar.update_in(cx, |sidebar, window, cx| {
5148            sidebar.selection = Some(1);
5149            sidebar.confirm(&Confirm, window, cx);
5150        });
5151        cx.run_until_parked();
5152
5153        assert_eq!(
5154            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
5155            0
5156        );
5157    }
5158
5159    #[gpui::test]
5160    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
5161        let project = init_test_project("/my-project", cx).await;
5162        let (multi_workspace, cx) =
5163            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
5164        let sidebar = setup_sidebar(&multi_workspace, cx);
5165
5166        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5167
5168        save_thread_metadata(
5169            acp::SessionId::new(Arc::from("t-1")),
5170            "Thread A".into(),
5171            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5172            path_list.clone(),
5173            cx,
5174        )
5175        .await;
5176
5177        save_thread_metadata(
5178            acp::SessionId::new(Arc::from("t-2")),
5179            "Thread B".into(),
5180            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5181            path_list.clone(),
5182            cx,
5183        )
5184        .await;
5185
5186        cx.run_until_parked();
5187        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5188        cx.run_until_parked();
5189
5190        assert_eq!(
5191            visible_entries_as_strings(&sidebar, cx),
5192            vec!["v [my-project]", "  Thread A", "  Thread B",]
5193        );
5194
5195        // Keyboard confirm preserves selection.
5196        sidebar.update_in(cx, |sidebar, window, cx| {
5197            sidebar.selection = Some(1);
5198            sidebar.confirm(&Confirm, window, cx);
5199        });
5200        assert_eq!(
5201            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
5202            Some(1)
5203        );
5204
5205        // Click handlers clear selection to None so no highlight lingers
5206        // after a click regardless of focus state. The hover style provides
5207        // visual feedback during mouse interaction instead.
5208        sidebar.update_in(cx, |sidebar, window, cx| {
5209            sidebar.selection = None;
5210            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5211            sidebar.toggle_collapse(&path_list, window, cx);
5212        });
5213        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
5214
5215        // When the user tabs back into the sidebar, focus_in no longer
5216        // restores selection — it stays None.
5217        sidebar.update_in(cx, |sidebar, window, cx| {
5218            sidebar.focus_in(window, cx);
5219        });
5220        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
5221    }
5222
5223    #[gpui::test]
5224    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
5225        let project = init_test_project_with_agent_panel("/my-project", cx).await;
5226        let (multi_workspace, cx) =
5227            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5228        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5229
5230        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5231
5232        let connection = StubAgentConnection::new();
5233        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5234            acp::ContentChunk::new("Hi there!".into()),
5235        )]);
5236        open_thread_with_connection(&panel, connection, cx);
5237        send_message(&panel, cx);
5238
5239        let session_id = active_session_id(&panel, cx);
5240        save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
5241        cx.run_until_parked();
5242
5243        assert_eq!(
5244            visible_entries_as_strings(&sidebar, cx),
5245            vec!["v [my-project]", "  Hello *"]
5246        );
5247
5248        // Simulate the agent generating a title. The notification chain is:
5249        // AcpThread::set_title emits TitleUpdated →
5250        // ConnectionView::handle_thread_event calls cx.notify() →
5251        // AgentPanel observer fires and emits AgentPanelEvent →
5252        // Sidebar subscription calls update_entries / rebuild_contents.
5253        //
5254        // Before the fix, handle_thread_event did NOT call cx.notify() for
5255        // TitleUpdated, so the AgentPanel observer never fired and the
5256        // sidebar kept showing the old title.
5257        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
5258        thread.update(cx, |thread, cx| {
5259            thread
5260                .set_title("Friendly Greeting with AI".into(), cx)
5261                .detach();
5262        });
5263        cx.run_until_parked();
5264
5265        assert_eq!(
5266            visible_entries_as_strings(&sidebar, cx),
5267            vec!["v [my-project]", "  Friendly Greeting with AI *"]
5268        );
5269    }
5270
5271    #[gpui::test]
5272    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
5273        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
5274        let (multi_workspace, cx) = cx
5275            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5276        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
5277
5278        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
5279
5280        // Save a thread so it appears in the list.
5281        let connection_a = StubAgentConnection::new();
5282        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5283            acp::ContentChunk::new("Done".into()),
5284        )]);
5285        open_thread_with_connection(&panel_a, connection_a, cx);
5286        send_message(&panel_a, cx);
5287        let session_id_a = active_session_id(&panel_a, cx);
5288        save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
5289
5290        // Add a second workspace with its own agent panel.
5291        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
5292        fs.as_fake()
5293            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
5294            .await;
5295        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
5296        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5297            mw.test_add_workspace(project_b.clone(), window, cx)
5298        });
5299        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
5300        cx.run_until_parked();
5301
5302        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
5303
5304        // ── 1. Initial state: focused thread derived from active panel ─────
5305        sidebar.read_with(cx, |sidebar, _cx| {
5306            assert_eq!(
5307                sidebar.focused_thread.as_ref(),
5308                Some(&session_id_a),
5309                "The active panel's thread should be focused on startup"
5310            );
5311        });
5312
5313        sidebar.update_in(cx, |sidebar, window, cx| {
5314            sidebar.activate_thread(
5315                Agent::NativeAgent,
5316                acp_thread::AgentSessionInfo {
5317                    session_id: session_id_a.clone(),
5318                    work_dirs: None,
5319                    title: Some("Test".into()),
5320                    updated_at: None,
5321                    created_at: None,
5322                    meta: None,
5323                },
5324                &workspace_a,
5325                window,
5326                cx,
5327            );
5328        });
5329        cx.run_until_parked();
5330
5331        sidebar.read_with(cx, |sidebar, _cx| {
5332            assert_eq!(
5333                sidebar.focused_thread.as_ref(),
5334                Some(&session_id_a),
5335                "After clicking a thread, it should be the focused thread"
5336            );
5337            assert!(
5338                has_thread_entry(sidebar, &session_id_a),
5339                "The clicked thread should be present in the entries"
5340            );
5341        });
5342
5343        workspace_a.read_with(cx, |workspace, cx| {
5344            assert!(
5345                workspace.panel::<AgentPanel>(cx).is_some(),
5346                "Agent panel should exist"
5347            );
5348            let dock = workspace.right_dock().read(cx);
5349            assert!(
5350                dock.is_open(),
5351                "Clicking a thread should open the agent panel dock"
5352            );
5353        });
5354
5355        let connection_b = StubAgentConnection::new();
5356        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5357            acp::ContentChunk::new("Thread B".into()),
5358        )]);
5359        open_thread_with_connection(&panel_b, connection_b, cx);
5360        send_message(&panel_b, cx);
5361        let session_id_b = active_session_id(&panel_b, cx);
5362        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
5363        save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await;
5364        cx.run_until_parked();
5365
5366        // Workspace A is currently active. Click a thread in workspace B,
5367        // which also triggers a workspace switch.
5368        sidebar.update_in(cx, |sidebar, window, cx| {
5369            sidebar.activate_thread(
5370                Agent::NativeAgent,
5371                acp_thread::AgentSessionInfo {
5372                    session_id: session_id_b.clone(),
5373                    work_dirs: None,
5374                    title: Some("Thread B".into()),
5375                    updated_at: None,
5376                    created_at: None,
5377                    meta: None,
5378                },
5379                &workspace_b,
5380                window,
5381                cx,
5382            );
5383        });
5384        cx.run_until_parked();
5385
5386        sidebar.read_with(cx, |sidebar, _cx| {
5387            assert_eq!(
5388                sidebar.focused_thread.as_ref(),
5389                Some(&session_id_b),
5390                "Clicking a thread in another workspace should focus that thread"
5391            );
5392            assert!(
5393                has_thread_entry(sidebar, &session_id_b),
5394                "The cross-workspace thread should be present in the entries"
5395            );
5396        });
5397
5398        multi_workspace.update_in(cx, |mw, window, cx| {
5399            mw.activate_index(0, window, cx);
5400        });
5401        cx.run_until_parked();
5402
5403        sidebar.read_with(cx, |sidebar, _cx| {
5404            assert_eq!(
5405                sidebar.focused_thread.as_ref(),
5406                Some(&session_id_a),
5407                "Switching workspace should seed focused_thread from the new active panel"
5408            );
5409            assert!(
5410                has_thread_entry(sidebar, &session_id_a),
5411                "The seeded thread should be present in the entries"
5412            );
5413        });
5414
5415        let connection_b2 = StubAgentConnection::new();
5416        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5417            acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
5418        )]);
5419        open_thread_with_connection(&panel_b, connection_b2, cx);
5420        send_message(&panel_b, cx);
5421        let session_id_b2 = active_session_id(&panel_b, cx);
5422        save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await;
5423        cx.run_until_parked();
5424
5425        // Panel B is not the active workspace's panel (workspace A is
5426        // active), so opening a thread there should not change focused_thread.
5427        // This prevents running threads in background workspaces from causing
5428        // the selection highlight to jump around.
5429        sidebar.read_with(cx, |sidebar, _cx| {
5430            assert_eq!(
5431                sidebar.focused_thread.as_ref(),
5432                Some(&session_id_a),
5433                "Opening a thread in a non-active panel should not change focused_thread"
5434            );
5435        });
5436
5437        workspace_b.update_in(cx, |workspace, window, cx| {
5438            workspace.focus_handle(cx).focus(window, cx);
5439        });
5440        cx.run_until_parked();
5441
5442        sidebar.read_with(cx, |sidebar, _cx| {
5443            assert_eq!(
5444                sidebar.focused_thread.as_ref(),
5445                Some(&session_id_a),
5446                "Defocusing the sidebar should not change focused_thread"
5447            );
5448        });
5449
5450        // Switching workspaces via the multi_workspace (simulates clicking
5451        // a workspace header) should clear focused_thread.
5452        multi_workspace.update_in(cx, |mw, window, cx| {
5453            if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) {
5454                mw.activate_index(index, window, cx);
5455            }
5456        });
5457        cx.run_until_parked();
5458
5459        sidebar.read_with(cx, |sidebar, _cx| {
5460            assert_eq!(
5461                sidebar.focused_thread.as_ref(),
5462                Some(&session_id_b2),
5463                "Switching workspace should seed focused_thread from the new active panel"
5464            );
5465            assert!(
5466                has_thread_entry(sidebar, &session_id_b2),
5467                "The seeded thread should be present in the entries"
5468            );
5469        });
5470
5471        // ── 8. Focusing the agent panel thread keeps focused_thread ────
5472        // Workspace B still has session_id_b2 loaded in the agent panel.
5473        // Clicking into the thread (simulated by focusing its view) should
5474        // keep focused_thread since it was already seeded on workspace switch.
5475        panel_b.update_in(cx, |panel, window, cx| {
5476            if let Some(thread_view) = panel.active_conversation_view() {
5477                thread_view.read(cx).focus_handle(cx).focus(window, cx);
5478            }
5479        });
5480        cx.run_until_parked();
5481
5482        sidebar.read_with(cx, |sidebar, _cx| {
5483            assert_eq!(
5484                sidebar.focused_thread.as_ref(),
5485                Some(&session_id_b2),
5486                "Focusing the agent panel thread should set focused_thread"
5487            );
5488            assert!(
5489                has_thread_entry(sidebar, &session_id_b2),
5490                "The focused thread should be present in the entries"
5491            );
5492        });
5493    }
5494
5495    #[gpui::test]
5496    async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
5497        let project = init_test_project_with_agent_panel("/project-a", cx).await;
5498        let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
5499        let (multi_workspace, cx) =
5500            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5501        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5502
5503        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
5504
5505        // Start a thread and send a message so it has history.
5506        let connection = StubAgentConnection::new();
5507        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5508            acp::ContentChunk::new("Done".into()),
5509        )]);
5510        open_thread_with_connection(&panel, connection, cx);
5511        send_message(&panel, cx);
5512        let session_id = active_session_id(&panel, cx);
5513        save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await;
5514        cx.run_until_parked();
5515
5516        // Verify the thread appears in the sidebar.
5517        assert_eq!(
5518            visible_entries_as_strings(&sidebar, cx),
5519            vec!["v [project-a]", "  Hello *",]
5520        );
5521
5522        // The "New Thread" button should NOT be in "active/draft" state
5523        // because the panel has a thread with messages.
5524        sidebar.read_with(cx, |sidebar, _cx| {
5525            assert!(
5526                !sidebar.active_thread_is_draft,
5527                "Panel has a thread with messages, so it should not be a draft"
5528            );
5529        });
5530
5531        // Now add a second folder to the workspace, changing the path_list.
5532        fs.as_fake()
5533            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
5534            .await;
5535        project
5536            .update(cx, |project, cx| {
5537                project.find_or_create_worktree("/project-b", true, cx)
5538            })
5539            .await
5540            .expect("should add worktree");
5541        cx.run_until_parked();
5542
5543        // The workspace path_list is now [project-a, project-b]. The old
5544        // thread was stored under [project-a], so it no longer appears in
5545        // the sidebar list for this workspace.
5546        let entries = visible_entries_as_strings(&sidebar, cx);
5547        assert!(
5548            !entries.iter().any(|e| e.contains("Hello")),
5549            "Thread stored under the old path_list should not appear: {:?}",
5550            entries
5551        );
5552
5553        // The "New Thread" button must still be clickable (not stuck in
5554        // "active/draft" state). Verify that `active_thread_is_draft` is
5555        // false — the panel still has the old thread with messages.
5556        sidebar.read_with(cx, |sidebar, _cx| {
5557            assert!(
5558                !sidebar.active_thread_is_draft,
5559                "After adding a folder the panel still has a thread with messages, \
5560                 so active_thread_is_draft should be false"
5561            );
5562        });
5563
5564        // Actually click "New Thread" by calling create_new_thread and
5565        // verify a new draft is created.
5566        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
5567        sidebar.update_in(cx, |sidebar, window, cx| {
5568            sidebar.create_new_thread(&workspace, window, cx);
5569        });
5570        cx.run_until_parked();
5571
5572        // After creating a new thread, the panel should now be in draft
5573        // state (no messages on the new thread).
5574        sidebar.read_with(cx, |sidebar, _cx| {
5575            assert!(
5576                sidebar.active_thread_is_draft,
5577                "After creating a new thread the panel should be in draft state"
5578            );
5579        });
5580    }
5581
5582    #[gpui::test]
5583    async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
5584        // When the user presses Cmd-N (NewThread action) while viewing a
5585        // non-empty thread, the sidebar should show the "New Thread" entry.
5586        // This exercises the same code path as the workspace action handler
5587        // (which bypasses the sidebar's create_new_thread method).
5588        let project = init_test_project_with_agent_panel("/my-project", cx).await;
5589        let (multi_workspace, cx) =
5590            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5591        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5592
5593        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5594
5595        // Create a non-empty thread (has messages).
5596        let connection = StubAgentConnection::new();
5597        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5598            acp::ContentChunk::new("Done".into()),
5599        )]);
5600        open_thread_with_connection(&panel, connection, cx);
5601        send_message(&panel, cx);
5602
5603        let session_id = active_session_id(&panel, cx);
5604        save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
5605        cx.run_until_parked();
5606
5607        assert_eq!(
5608            visible_entries_as_strings(&sidebar, cx),
5609            vec!["v [my-project]", "  Hello *"]
5610        );
5611
5612        // Simulate cmd-n
5613        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
5614        panel.update_in(cx, |panel, window, cx| {
5615            panel.new_thread(&NewThread, window, cx);
5616        });
5617        workspace.update_in(cx, |workspace, window, cx| {
5618            workspace.focus_panel::<AgentPanel>(window, cx);
5619        });
5620        cx.run_until_parked();
5621
5622        assert_eq!(
5623            visible_entries_as_strings(&sidebar, cx),
5624            vec!["v [my-project]", "  [+ New Thread]", "  Hello *"],
5625            "After Cmd-N the sidebar should show a highlighted New Thread entry"
5626        );
5627
5628        sidebar.read_with(cx, |sidebar, _cx| {
5629            assert!(
5630                sidebar.focused_thread.is_none(),
5631                "focused_thread should be cleared after Cmd-N"
5632            );
5633            assert!(
5634                sidebar.active_thread_is_draft,
5635                "the new blank thread should be a draft"
5636            );
5637        });
5638    }
5639
5640    #[gpui::test]
5641    async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
5642        // When the active workspace is an absorbed git worktree, cmd-n
5643        // should still show the "New Thread" entry under the main repo's
5644        // header and highlight it as active.
5645        agent_ui::test_support::init_test(cx);
5646        cx.update(|cx| {
5647            cx.update_flags(false, vec!["agent-v2".into()]);
5648            ThreadStore::init_global(cx);
5649            SidebarThreadMetadataStore::init_global(cx);
5650            language_model::LanguageModelRegistry::test(cx);
5651            prompt_store::init(cx);
5652        });
5653
5654        let fs = FakeFs::new(cx.executor());
5655
5656        // Main repo with a linked worktree.
5657        fs.insert_tree(
5658            "/project",
5659            serde_json::json!({
5660                ".git": {
5661                    "worktrees": {
5662                        "feature-a": {
5663                            "commondir": "../../",
5664                            "HEAD": "ref: refs/heads/feature-a",
5665                        },
5666                    },
5667                },
5668                "src": {},
5669            }),
5670        )
5671        .await;
5672
5673        // Worktree checkout pointing back to the main repo.
5674        fs.insert_tree(
5675            "/wt-feature-a",
5676            serde_json::json!({
5677                ".git": "gitdir: /project/.git/worktrees/feature-a",
5678                "src": {},
5679            }),
5680        )
5681        .await;
5682
5683        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5684            state.worktrees.push(git::repository::Worktree {
5685                path: std::path::PathBuf::from("/wt-feature-a"),
5686                ref_name: Some("refs/heads/feature-a".into()),
5687                sha: "aaa".into(),
5688            });
5689        })
5690        .unwrap();
5691
5692        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5693
5694        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5695        let worktree_project =
5696            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5697
5698        main_project
5699            .update(cx, |p, cx| p.git_scans_complete(cx))
5700            .await;
5701        worktree_project
5702            .update(cx, |p, cx| p.git_scans_complete(cx))
5703            .await;
5704
5705        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5706            MultiWorkspace::test_new(main_project.clone(), window, cx)
5707        });
5708
5709        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5710            mw.test_add_workspace(worktree_project.clone(), window, cx)
5711        });
5712
5713        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
5714
5715        // Switch to the worktree workspace.
5716        multi_workspace.update_in(cx, |mw, window, cx| {
5717            mw.activate_index(1, window, cx);
5718        });
5719
5720        let sidebar = setup_sidebar(&multi_workspace, cx);
5721
5722        // Create a non-empty thread in the worktree workspace.
5723        let connection = StubAgentConnection::new();
5724        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5725            acp::ContentChunk::new("Done".into()),
5726        )]);
5727        open_thread_with_connection(&worktree_panel, connection, cx);
5728        send_message(&worktree_panel, cx);
5729
5730        let session_id = active_session_id(&worktree_panel, cx);
5731        let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5732        save_test_thread_metadata(&session_id, wt_path_list, cx).await;
5733        cx.run_until_parked();
5734
5735        assert_eq!(
5736            visible_entries_as_strings(&sidebar, cx),
5737            vec!["v [project]", "  Hello {wt-feature-a} *"]
5738        );
5739
5740        // Simulate Cmd-N in the worktree workspace.
5741        worktree_panel.update_in(cx, |panel, window, cx| {
5742            panel.new_thread(&NewThread, window, cx);
5743        });
5744        worktree_workspace.update_in(cx, |workspace, window, cx| {
5745            workspace.focus_panel::<AgentPanel>(window, cx);
5746        });
5747        cx.run_until_parked();
5748
5749        assert_eq!(
5750            visible_entries_as_strings(&sidebar, cx),
5751            vec![
5752                "v [project]",
5753                "  [+ New Thread]",
5754                "  Hello {wt-feature-a} *"
5755            ],
5756            "After Cmd-N in an absorbed worktree, the sidebar should show \
5757             a highlighted New Thread entry under the main repo header"
5758        );
5759
5760        sidebar.read_with(cx, |sidebar, _cx| {
5761            assert!(
5762                sidebar.focused_thread.is_none(),
5763                "focused_thread should be cleared after Cmd-N"
5764            );
5765            assert!(
5766                sidebar.active_thread_is_draft,
5767                "the new blank thread should be a draft"
5768            );
5769        });
5770    }
5771
5772    async fn init_test_project_with_git(
5773        worktree_path: &str,
5774        cx: &mut TestAppContext,
5775    ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
5776        init_test(cx);
5777        let fs = FakeFs::new(cx.executor());
5778        fs.insert_tree(
5779            worktree_path,
5780            serde_json::json!({
5781                ".git": {},
5782                "src": {},
5783            }),
5784        )
5785        .await;
5786        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5787        let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
5788        (project, fs)
5789    }
5790
5791    #[gpui::test]
5792    async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
5793        let (project, fs) = init_test_project_with_git("/project", cx).await;
5794
5795        fs.as_fake()
5796            .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5797                state.worktrees.push(git::repository::Worktree {
5798                    path: std::path::PathBuf::from("/wt/rosewood"),
5799                    ref_name: Some("refs/heads/rosewood".into()),
5800                    sha: "abc".into(),
5801                });
5802            })
5803            .unwrap();
5804
5805        project
5806            .update(cx, |project, cx| project.git_scans_complete(cx))
5807            .await;
5808
5809        let (multi_workspace, cx) =
5810            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5811        let sidebar = setup_sidebar(&multi_workspace, cx);
5812
5813        let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
5814        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
5815        save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await;
5816        save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await;
5817
5818        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5819        cx.run_until_parked();
5820
5821        // Search for "rosewood" — should match the worktree name, not the title.
5822        type_in_search(&sidebar, "rosewood", cx);
5823
5824        assert_eq!(
5825            visible_entries_as_strings(&sidebar, cx),
5826            vec!["v [project]", "  Fix Bug {rosewood}  <== selected"],
5827        );
5828    }
5829
5830    #[gpui::test]
5831    async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
5832        let (project, fs) = init_test_project_with_git("/project", cx).await;
5833
5834        project
5835            .update(cx, |project, cx| project.git_scans_complete(cx))
5836            .await;
5837
5838        let (multi_workspace, cx) =
5839            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5840        let sidebar = setup_sidebar(&multi_workspace, cx);
5841
5842        // Save a thread against a worktree path that doesn't exist yet.
5843        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
5844        save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
5845
5846        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5847        cx.run_until_parked();
5848
5849        // Thread is not visible yet — no worktree knows about this path.
5850        assert_eq!(
5851            visible_entries_as_strings(&sidebar, cx),
5852            vec!["v [project]", "  [+ New Thread]"]
5853        );
5854
5855        // Now add the worktree to the git state and trigger a rescan.
5856        fs.as_fake()
5857            .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
5858                state.worktrees.push(git::repository::Worktree {
5859                    path: std::path::PathBuf::from("/wt/rosewood"),
5860                    ref_name: Some("refs/heads/rosewood".into()),
5861                    sha: "abc".into(),
5862                });
5863            })
5864            .unwrap();
5865
5866        cx.run_until_parked();
5867
5868        assert_eq!(
5869            visible_entries_as_strings(&sidebar, cx),
5870            vec!["v [project]", "  Worktree Thread {rosewood}",]
5871        );
5872    }
5873
5874    #[gpui::test]
5875    async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
5876        init_test(cx);
5877        let fs = FakeFs::new(cx.executor());
5878
5879        // Create the main repo directory (not opened as a workspace yet).
5880        fs.insert_tree(
5881            "/project",
5882            serde_json::json!({
5883                ".git": {
5884                    "worktrees": {
5885                        "feature-a": {
5886                            "commondir": "../../",
5887                            "HEAD": "ref: refs/heads/feature-a",
5888                        },
5889                        "feature-b": {
5890                            "commondir": "../../",
5891                            "HEAD": "ref: refs/heads/feature-b",
5892                        },
5893                    },
5894                },
5895                "src": {},
5896            }),
5897        )
5898        .await;
5899
5900        // Two worktree checkouts whose .git files point back to the main repo.
5901        fs.insert_tree(
5902            "/wt-feature-a",
5903            serde_json::json!({
5904                ".git": "gitdir: /project/.git/worktrees/feature-a",
5905                "src": {},
5906            }),
5907        )
5908        .await;
5909        fs.insert_tree(
5910            "/wt-feature-b",
5911            serde_json::json!({
5912                ".git": "gitdir: /project/.git/worktrees/feature-b",
5913                "src": {},
5914            }),
5915        )
5916        .await;
5917
5918        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5919
5920        let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5921        let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
5922
5923        project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5924        project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5925
5926        // Open both worktrees as workspaces — no main repo yet.
5927        let (multi_workspace, cx) = cx
5928            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5929        multi_workspace.update_in(cx, |mw, window, cx| {
5930            mw.test_add_workspace(project_b.clone(), window, cx);
5931        });
5932        let sidebar = setup_sidebar(&multi_workspace, cx);
5933
5934        let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5935        let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
5936        save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
5937        save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await;
5938
5939        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5940        cx.run_until_parked();
5941
5942        // Without the main repo, each worktree has its own header.
5943        assert_eq!(
5944            visible_entries_as_strings(&sidebar, cx),
5945            vec![
5946                "v [project]",
5947                "  Thread A {wt-feature-a}",
5948                "  Thread B {wt-feature-b}",
5949            ]
5950        );
5951
5952        // Configure the main repo to list both worktrees before opening
5953        // it so the initial git scan picks them up.
5954        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5955            state.worktrees.push(git::repository::Worktree {
5956                path: std::path::PathBuf::from("/wt-feature-a"),
5957                ref_name: Some("refs/heads/feature-a".into()),
5958                sha: "aaa".into(),
5959            });
5960            state.worktrees.push(git::repository::Worktree {
5961                path: std::path::PathBuf::from("/wt-feature-b"),
5962                ref_name: Some("refs/heads/feature-b".into()),
5963                sha: "bbb".into(),
5964            });
5965        })
5966        .unwrap();
5967
5968        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5969        main_project
5970            .update(cx, |p, cx| p.git_scans_complete(cx))
5971            .await;
5972
5973        multi_workspace.update_in(cx, |mw, window, cx| {
5974            mw.test_add_workspace(main_project.clone(), window, cx);
5975        });
5976        cx.run_until_parked();
5977
5978        // Both worktree workspaces should now be absorbed under the main
5979        // repo header, with worktree chips.
5980        assert_eq!(
5981            visible_entries_as_strings(&sidebar, cx),
5982            vec![
5983                "v [project]",
5984                "  Thread A {wt-feature-a}",
5985                "  Thread B {wt-feature-b}",
5986            ]
5987        );
5988
5989        // Remove feature-b from the main repo's linked worktrees.
5990        // The feature-b workspace should be pruned automatically.
5991        fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
5992            state
5993                .worktrees
5994                .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
5995        })
5996        .unwrap();
5997
5998        cx.run_until_parked();
5999
6000        // feature-b's workspace is pruned; feature-a remains absorbed
6001        // under the main repo.
6002        assert_eq!(
6003            visible_entries_as_strings(&sidebar, cx),
6004            vec!["v [project]", "  Thread A {wt-feature-a}",]
6005        );
6006    }
6007
6008    #[gpui::test]
6009    async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
6010        // When a worktree workspace is absorbed under the main repo, a
6011        // running thread in the worktree's agent panel should still show
6012        // live status (spinner + "(running)") in the sidebar.
6013        agent_ui::test_support::init_test(cx);
6014        cx.update(|cx| {
6015            cx.update_flags(false, vec!["agent-v2".into()]);
6016            ThreadStore::init_global(cx);
6017            SidebarThreadMetadataStore::init_global(cx);
6018            language_model::LanguageModelRegistry::test(cx);
6019            prompt_store::init(cx);
6020        });
6021
6022        let fs = FakeFs::new(cx.executor());
6023
6024        // Main repo with a linked worktree.
6025        fs.insert_tree(
6026            "/project",
6027            serde_json::json!({
6028                ".git": {
6029                    "worktrees": {
6030                        "feature-a": {
6031                            "commondir": "../../",
6032                            "HEAD": "ref: refs/heads/feature-a",
6033                        },
6034                    },
6035                },
6036                "src": {},
6037            }),
6038        )
6039        .await;
6040
6041        // Worktree checkout pointing back to the main repo.
6042        fs.insert_tree(
6043            "/wt-feature-a",
6044            serde_json::json!({
6045                ".git": "gitdir: /project/.git/worktrees/feature-a",
6046                "src": {},
6047            }),
6048        )
6049        .await;
6050
6051        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6052            state.worktrees.push(git::repository::Worktree {
6053                path: std::path::PathBuf::from("/wt-feature-a"),
6054                ref_name: Some("refs/heads/feature-a".into()),
6055                sha: "aaa".into(),
6056            });
6057        })
6058        .unwrap();
6059
6060        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6061
6062        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6063        let worktree_project =
6064            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6065
6066        main_project
6067            .update(cx, |p, cx| p.git_scans_complete(cx))
6068            .await;
6069        worktree_project
6070            .update(cx, |p, cx| p.git_scans_complete(cx))
6071            .await;
6072
6073        // Create the MultiWorkspace with both projects.
6074        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6075            MultiWorkspace::test_new(main_project.clone(), window, cx)
6076        });
6077
6078        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6079            mw.test_add_workspace(worktree_project.clone(), window, cx)
6080        });
6081
6082        // Add an agent panel to the worktree workspace so we can run a
6083        // thread inside it.
6084        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
6085
6086        // Switch back to the main workspace before setting up the sidebar.
6087        multi_workspace.update_in(cx, |mw, window, cx| {
6088            mw.activate_index(0, window, cx);
6089        });
6090
6091        let sidebar = setup_sidebar(&multi_workspace, cx);
6092
6093        // Start a thread in the worktree workspace's panel and keep it
6094        // generating (don't resolve it).
6095        let connection = StubAgentConnection::new();
6096        open_thread_with_connection(&worktree_panel, connection.clone(), cx);
6097        send_message(&worktree_panel, cx);
6098
6099        let session_id = active_session_id(&worktree_panel, cx);
6100
6101        // Save metadata so the sidebar knows about this thread.
6102        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6103        save_test_thread_metadata(&session_id, wt_paths, cx).await;
6104
6105        // Keep the thread generating by sending a chunk without ending
6106        // the turn.
6107        cx.update(|_, cx| {
6108            connection.send_update(
6109                session_id.clone(),
6110                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
6111                cx,
6112            );
6113        });
6114        cx.run_until_parked();
6115
6116        // The worktree thread should be absorbed under the main project
6117        // and show live running status.
6118        let entries = visible_entries_as_strings(&sidebar, cx);
6119        assert_eq!(
6120            entries,
6121            vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
6122        );
6123    }
6124
6125    #[gpui::test]
6126    async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
6127        agent_ui::test_support::init_test(cx);
6128        cx.update(|cx| {
6129            cx.update_flags(false, vec!["agent-v2".into()]);
6130            ThreadStore::init_global(cx);
6131            SidebarThreadMetadataStore::init_global(cx);
6132            language_model::LanguageModelRegistry::test(cx);
6133            prompt_store::init(cx);
6134        });
6135
6136        let fs = FakeFs::new(cx.executor());
6137
6138        fs.insert_tree(
6139            "/project",
6140            serde_json::json!({
6141                ".git": {
6142                    "worktrees": {
6143                        "feature-a": {
6144                            "commondir": "../../",
6145                            "HEAD": "ref: refs/heads/feature-a",
6146                        },
6147                    },
6148                },
6149                "src": {},
6150            }),
6151        )
6152        .await;
6153
6154        fs.insert_tree(
6155            "/wt-feature-a",
6156            serde_json::json!({
6157                ".git": "gitdir: /project/.git/worktrees/feature-a",
6158                "src": {},
6159            }),
6160        )
6161        .await;
6162
6163        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6164            state.worktrees.push(git::repository::Worktree {
6165                path: std::path::PathBuf::from("/wt-feature-a"),
6166                ref_name: Some("refs/heads/feature-a".into()),
6167                sha: "aaa".into(),
6168            });
6169        })
6170        .unwrap();
6171
6172        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6173
6174        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6175        let worktree_project =
6176            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6177
6178        main_project
6179            .update(cx, |p, cx| p.git_scans_complete(cx))
6180            .await;
6181        worktree_project
6182            .update(cx, |p, cx| p.git_scans_complete(cx))
6183            .await;
6184
6185        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6186            MultiWorkspace::test_new(main_project.clone(), window, cx)
6187        });
6188
6189        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6190            mw.test_add_workspace(worktree_project.clone(), window, cx)
6191        });
6192
6193        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
6194
6195        multi_workspace.update_in(cx, |mw, window, cx| {
6196            mw.activate_index(0, window, cx);
6197        });
6198
6199        let sidebar = setup_sidebar(&multi_workspace, cx);
6200
6201        let connection = StubAgentConnection::new();
6202        open_thread_with_connection(&worktree_panel, connection.clone(), cx);
6203        send_message(&worktree_panel, cx);
6204
6205        let session_id = active_session_id(&worktree_panel, cx);
6206        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6207        save_test_thread_metadata(&session_id, wt_paths, cx).await;
6208
6209        cx.update(|_, cx| {
6210            connection.send_update(
6211                session_id.clone(),
6212                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
6213                cx,
6214            );
6215        });
6216        cx.run_until_parked();
6217
6218        assert_eq!(
6219            visible_entries_as_strings(&sidebar, cx),
6220            vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
6221        );
6222
6223        connection.end_turn(session_id, acp::StopReason::EndTurn);
6224        cx.run_until_parked();
6225
6226        assert_eq!(
6227            visible_entries_as_strings(&sidebar, cx),
6228            vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
6229        );
6230    }
6231
6232    #[gpui::test]
6233    async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
6234        cx: &mut TestAppContext,
6235    ) {
6236        init_test(cx);
6237        let fs = FakeFs::new(cx.executor());
6238
6239        fs.insert_tree(
6240            "/project",
6241            serde_json::json!({
6242                ".git": {
6243                    "worktrees": {
6244                        "feature-a": {
6245                            "commondir": "../../",
6246                            "HEAD": "ref: refs/heads/feature-a",
6247                        },
6248                    },
6249                },
6250                "src": {},
6251            }),
6252        )
6253        .await;
6254
6255        fs.insert_tree(
6256            "/wt-feature-a",
6257            serde_json::json!({
6258                ".git": "gitdir: /project/.git/worktrees/feature-a",
6259                "src": {},
6260            }),
6261        )
6262        .await;
6263
6264        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6265            state.worktrees.push(git::repository::Worktree {
6266                path: std::path::PathBuf::from("/wt-feature-a"),
6267                ref_name: Some("refs/heads/feature-a".into()),
6268                sha: "aaa".into(),
6269            });
6270        })
6271        .unwrap();
6272
6273        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6274
6275        // Only open the main repo — no workspace for the worktree.
6276        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6277        main_project
6278            .update(cx, |p, cx| p.git_scans_complete(cx))
6279            .await;
6280
6281        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6282            MultiWorkspace::test_new(main_project.clone(), window, cx)
6283        });
6284        let sidebar = setup_sidebar(&multi_workspace, cx);
6285
6286        // Save a thread for the worktree path (no workspace for it).
6287        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6288        save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6289
6290        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6291        cx.run_until_parked();
6292
6293        // Thread should appear under the main repo with a worktree chip.
6294        assert_eq!(
6295            visible_entries_as_strings(&sidebar, cx),
6296            vec!["v [project]", "  WT Thread {wt-feature-a}"],
6297        );
6298
6299        // Only 1 workspace should exist.
6300        assert_eq!(
6301            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6302            1,
6303        );
6304
6305        // Focus the sidebar and select the worktree thread.
6306        open_and_focus_sidebar(&sidebar, cx);
6307        sidebar.update_in(cx, |sidebar, _window, _cx| {
6308            sidebar.selection = Some(1); // index 0 is header, 1 is the thread
6309        });
6310
6311        // Confirm to open the worktree thread.
6312        cx.dispatch_action(Confirm);
6313        cx.run_until_parked();
6314
6315        // A new workspace should have been created for the worktree path.
6316        let new_workspace = multi_workspace.read_with(cx, |mw, _| {
6317            assert_eq!(
6318                mw.workspaces().len(),
6319                2,
6320                "confirming a worktree thread without a workspace should open one",
6321            );
6322            mw.workspaces()[1].clone()
6323        });
6324
6325        let new_path_list =
6326            new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
6327        assert_eq!(
6328            new_path_list,
6329            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
6330            "the new workspace should have been opened for the worktree path",
6331        );
6332    }
6333
6334    #[gpui::test]
6335    async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
6336        cx: &mut TestAppContext,
6337    ) {
6338        init_test(cx);
6339        let fs = FakeFs::new(cx.executor());
6340
6341        fs.insert_tree(
6342            "/project",
6343            serde_json::json!({
6344                ".git": {
6345                    "worktrees": {
6346                        "feature-a": {
6347                            "commondir": "../../",
6348                            "HEAD": "ref: refs/heads/feature-a",
6349                        },
6350                    },
6351                },
6352                "src": {},
6353            }),
6354        )
6355        .await;
6356
6357        fs.insert_tree(
6358            "/wt-feature-a",
6359            serde_json::json!({
6360                ".git": "gitdir: /project/.git/worktrees/feature-a",
6361                "src": {},
6362            }),
6363        )
6364        .await;
6365
6366        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6367            state.worktrees.push(git::repository::Worktree {
6368                path: std::path::PathBuf::from("/wt-feature-a"),
6369                ref_name: Some("refs/heads/feature-a".into()),
6370                sha: "aaa".into(),
6371            });
6372        })
6373        .unwrap();
6374
6375        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6376
6377        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6378        main_project
6379            .update(cx, |p, cx| p.git_scans_complete(cx))
6380            .await;
6381
6382        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6383            MultiWorkspace::test_new(main_project.clone(), window, cx)
6384        });
6385        let sidebar = setup_sidebar(&multi_workspace, cx);
6386
6387        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6388        save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6389
6390        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6391        cx.run_until_parked();
6392
6393        assert_eq!(
6394            visible_entries_as_strings(&sidebar, cx),
6395            vec!["v [project]", "  WT Thread {wt-feature-a}"],
6396        );
6397
6398        open_and_focus_sidebar(&sidebar, cx);
6399        sidebar.update_in(cx, |sidebar, _window, _cx| {
6400            sidebar.selection = Some(1);
6401        });
6402
6403        let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
6404            let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
6405                if let ListEntry::ProjectHeader { label, .. } = entry {
6406                    Some(label.as_ref())
6407                } else {
6408                    None
6409                }
6410            });
6411
6412            let Some(project_header) = project_headers.next() else {
6413                panic!("expected exactly one sidebar project header named `project`, found none");
6414            };
6415            assert_eq!(
6416                project_header, "project",
6417                "expected the only sidebar project header to be `project`"
6418            );
6419            if let Some(unexpected_header) = project_headers.next() {
6420                panic!(
6421                    "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
6422                );
6423            }
6424
6425            let mut saw_expected_thread = false;
6426            for entry in &sidebar.contents.entries {
6427                match entry {
6428                    ListEntry::ProjectHeader { label, .. } => {
6429                        assert_eq!(
6430                            label.as_ref(),
6431                            "project",
6432                            "expected the only sidebar project header to be `project`"
6433                        );
6434                    }
6435                    ListEntry::Thread(thread)
6436                        if thread
6437                            .session_info
6438                            .title
6439                            .as_ref()
6440                            .map(|title| title.as_ref())
6441                            == Some("WT Thread")
6442                            && thread.worktree_name.as_ref().map(|name| name.as_ref())
6443                                == Some("wt-feature-a") =>
6444                    {
6445                        saw_expected_thread = true;
6446                    }
6447                    ListEntry::Thread(thread) => {
6448                        let title = thread
6449                            .session_info
6450                            .title
6451                            .as_ref()
6452                            .map(|title| title.as_ref())
6453                            .unwrap_or("Untitled");
6454                        let worktree_name = thread
6455                            .worktree_name
6456                            .as_ref()
6457                            .map(|name| name.as_ref())
6458                            .unwrap_or("<none>");
6459                        panic!(
6460                            "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`"
6461                        );
6462                    }
6463                    ListEntry::ViewMore { .. } => {
6464                        panic!("unexpected `View More` entry while opening linked worktree thread");
6465                    }
6466                    ListEntry::NewThread { .. } => {
6467                        panic!(
6468                            "unexpected `New Thread` entry while opening linked worktree thread"
6469                        );
6470                    }
6471                }
6472            }
6473
6474            assert!(
6475                saw_expected_thread,
6476                "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
6477            );
6478        };
6479
6480        sidebar
6481            .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
6482            .detach();
6483
6484        let window = cx.windows()[0];
6485        cx.update_window(window, |_, window, cx| {
6486            window.dispatch_action(Confirm.boxed_clone(), cx);
6487        })
6488        .unwrap();
6489
6490        cx.run_until_parked();
6491
6492        sidebar.update(cx, assert_sidebar_state);
6493    }
6494
6495    #[gpui::test]
6496    async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
6497        cx: &mut TestAppContext,
6498    ) {
6499        init_test(cx);
6500        let fs = FakeFs::new(cx.executor());
6501
6502        fs.insert_tree(
6503            "/project",
6504            serde_json::json!({
6505                ".git": {
6506                    "worktrees": {
6507                        "feature-a": {
6508                            "commondir": "../../",
6509                            "HEAD": "ref: refs/heads/feature-a",
6510                        },
6511                    },
6512                },
6513                "src": {},
6514            }),
6515        )
6516        .await;
6517
6518        fs.insert_tree(
6519            "/wt-feature-a",
6520            serde_json::json!({
6521                ".git": "gitdir: /project/.git/worktrees/feature-a",
6522                "src": {},
6523            }),
6524        )
6525        .await;
6526
6527        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6528            state.worktrees.push(git::repository::Worktree {
6529                path: std::path::PathBuf::from("/wt-feature-a"),
6530                ref_name: Some("refs/heads/feature-a".into()),
6531                sha: "aaa".into(),
6532            });
6533        })
6534        .unwrap();
6535
6536        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6537
6538        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6539        let worktree_project =
6540            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6541
6542        main_project
6543            .update(cx, |p, cx| p.git_scans_complete(cx))
6544            .await;
6545        worktree_project
6546            .update(cx, |p, cx| p.git_scans_complete(cx))
6547            .await;
6548
6549        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6550            MultiWorkspace::test_new(main_project.clone(), window, cx)
6551        });
6552
6553        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6554            mw.test_add_workspace(worktree_project.clone(), window, cx)
6555        });
6556
6557        // Activate the main workspace before setting up the sidebar.
6558        multi_workspace.update_in(cx, |mw, window, cx| {
6559            mw.activate_index(0, window, cx);
6560        });
6561
6562        let sidebar = setup_sidebar(&multi_workspace, cx);
6563
6564        let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
6565        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6566        save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await;
6567        save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6568
6569        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6570        cx.run_until_parked();
6571
6572        // The worktree workspace should be absorbed under the main repo.
6573        let entries = visible_entries_as_strings(&sidebar, cx);
6574        assert_eq!(entries.len(), 3);
6575        assert_eq!(entries[0], "v [project]");
6576        assert!(entries.contains(&"  Main Thread".to_string()));
6577        assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
6578
6579        let wt_thread_index = entries
6580            .iter()
6581            .position(|e| e.contains("WT Thread"))
6582            .expect("should find the worktree thread entry");
6583
6584        assert_eq!(
6585            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6586            0,
6587            "main workspace should be active initially"
6588        );
6589
6590        // Focus the sidebar and select the absorbed worktree thread.
6591        open_and_focus_sidebar(&sidebar, cx);
6592        sidebar.update_in(cx, |sidebar, _window, _cx| {
6593            sidebar.selection = Some(wt_thread_index);
6594        });
6595
6596        // Confirm to activate the worktree thread.
6597        cx.dispatch_action(Confirm);
6598        cx.run_until_parked();
6599
6600        // The worktree workspace should now be active, not the main one.
6601        let active_workspace = multi_workspace.read_with(cx, |mw, _| {
6602            mw.workspaces()[mw.active_workspace_index()].clone()
6603        });
6604        assert_eq!(
6605            active_workspace, worktree_workspace,
6606            "clicking an absorbed worktree thread should activate the worktree workspace"
6607        );
6608    }
6609
6610    #[gpui::test]
6611    async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
6612        cx: &mut TestAppContext,
6613    ) {
6614        // Thread has saved metadata in ThreadStore. A matching workspace is
6615        // already open. Expected: activates the matching workspace.
6616        init_test(cx);
6617        let fs = FakeFs::new(cx.executor());
6618        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6619            .await;
6620        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6621            .await;
6622        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6623
6624        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6625        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6626
6627        let (multi_workspace, cx) =
6628            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6629
6630        multi_workspace.update_in(cx, |mw, window, cx| {
6631            mw.test_add_workspace(project_b, window, cx);
6632        });
6633
6634        let sidebar = setup_sidebar(&multi_workspace, cx);
6635
6636        // Save a thread with path_list pointing to project-b.
6637        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6638        let session_id = acp::SessionId::new(Arc::from("archived-1"));
6639        save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await;
6640
6641        // Ensure workspace A is active.
6642        multi_workspace.update_in(cx, |mw, window, cx| {
6643            mw.activate_index(0, window, cx);
6644        });
6645        cx.run_until_parked();
6646        assert_eq!(
6647            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6648            0
6649        );
6650
6651        // Call activate_archived_thread – should resolve saved paths and
6652        // switch to the workspace for project-b.
6653        sidebar.update_in(cx, |sidebar, window, cx| {
6654            sidebar.activate_archived_thread(
6655                Agent::NativeAgent,
6656                acp_thread::AgentSessionInfo {
6657                    session_id: session_id.clone(),
6658                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6659                    title: Some("Archived Thread".into()),
6660                    updated_at: None,
6661                    created_at: None,
6662                    meta: None,
6663                },
6664                window,
6665                cx,
6666            );
6667        });
6668        cx.run_until_parked();
6669
6670        assert_eq!(
6671            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6672            1,
6673            "should have activated the workspace matching the saved path_list"
6674        );
6675    }
6676
6677    #[gpui::test]
6678    async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
6679        cx: &mut TestAppContext,
6680    ) {
6681        // Thread has no saved metadata but session_info has cwd. A matching
6682        // workspace is open. Expected: uses cwd to find and activate it.
6683        init_test(cx);
6684        let fs = FakeFs::new(cx.executor());
6685        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6686            .await;
6687        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6688            .await;
6689        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6690
6691        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6692        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6693
6694        let (multi_workspace, cx) =
6695            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6696
6697        multi_workspace.update_in(cx, |mw, window, cx| {
6698            mw.test_add_workspace(project_b, window, cx);
6699        });
6700
6701        let sidebar = setup_sidebar(&multi_workspace, cx);
6702
6703        // Start with workspace A active.
6704        multi_workspace.update_in(cx, |mw, window, cx| {
6705            mw.activate_index(0, window, cx);
6706        });
6707        cx.run_until_parked();
6708        assert_eq!(
6709            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6710            0
6711        );
6712
6713        // No thread saved to the store – cwd is the only path hint.
6714        sidebar.update_in(cx, |sidebar, window, cx| {
6715            sidebar.activate_archived_thread(
6716                Agent::NativeAgent,
6717                acp_thread::AgentSessionInfo {
6718                    session_id: acp::SessionId::new(Arc::from("unknown-session")),
6719                    work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])),
6720                    title: Some("CWD Thread".into()),
6721                    updated_at: None,
6722                    created_at: None,
6723                    meta: None,
6724                },
6725                window,
6726                cx,
6727            );
6728        });
6729        cx.run_until_parked();
6730
6731        assert_eq!(
6732            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6733            1,
6734            "should have activated the workspace matching the cwd"
6735        );
6736    }
6737
6738    #[gpui::test]
6739    async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
6740        cx: &mut TestAppContext,
6741    ) {
6742        // Thread has no saved metadata and no cwd. Expected: falls back to
6743        // the currently active workspace.
6744        init_test(cx);
6745        let fs = FakeFs::new(cx.executor());
6746        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6747            .await;
6748        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6749            .await;
6750        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6751
6752        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6753        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6754
6755        let (multi_workspace, cx) =
6756            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6757
6758        multi_workspace.update_in(cx, |mw, window, cx| {
6759            mw.test_add_workspace(project_b, window, cx);
6760        });
6761
6762        let sidebar = setup_sidebar(&multi_workspace, cx);
6763
6764        // Activate workspace B (index 1) to make it the active one.
6765        multi_workspace.update_in(cx, |mw, window, cx| {
6766            mw.activate_index(1, window, cx);
6767        });
6768        cx.run_until_parked();
6769        assert_eq!(
6770            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6771            1
6772        );
6773
6774        // No saved thread, no cwd – should fall back to the active workspace.
6775        sidebar.update_in(cx, |sidebar, window, cx| {
6776            sidebar.activate_archived_thread(
6777                Agent::NativeAgent,
6778                acp_thread::AgentSessionInfo {
6779                    session_id: acp::SessionId::new(Arc::from("no-context-session")),
6780                    work_dirs: None,
6781                    title: Some("Contextless Thread".into()),
6782                    updated_at: None,
6783                    created_at: None,
6784                    meta: None,
6785                },
6786                window,
6787                cx,
6788            );
6789        });
6790        cx.run_until_parked();
6791
6792        assert_eq!(
6793            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6794            1,
6795            "should have stayed on the active workspace when no path info is available"
6796        );
6797    }
6798
6799    #[gpui::test]
6800    async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
6801        cx: &mut TestAppContext,
6802    ) {
6803        // Thread has saved metadata pointing to a path with no open workspace.
6804        // Expected: opens a new workspace for that path.
6805        init_test(cx);
6806        let fs = FakeFs::new(cx.executor());
6807        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6808            .await;
6809        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6810            .await;
6811        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6812
6813        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6814
6815        let (multi_workspace, cx) =
6816            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6817
6818        let sidebar = setup_sidebar(&multi_workspace, cx);
6819
6820        // Save a thread with path_list pointing to project-b – which has no
6821        // open workspace.
6822        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6823        let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
6824
6825        assert_eq!(
6826            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6827            1,
6828            "should start with one workspace"
6829        );
6830
6831        sidebar.update_in(cx, |sidebar, window, cx| {
6832            sidebar.activate_archived_thread(
6833                Agent::NativeAgent,
6834                acp_thread::AgentSessionInfo {
6835                    session_id: session_id.clone(),
6836                    work_dirs: Some(path_list_b),
6837                    title: Some("New WS Thread".into()),
6838                    updated_at: None,
6839                    created_at: None,
6840                    meta: None,
6841                },
6842                window,
6843                cx,
6844            );
6845        });
6846        cx.run_until_parked();
6847
6848        assert_eq!(
6849            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6850            2,
6851            "should have opened a second workspace for the archived thread's saved paths"
6852        );
6853    }
6854
6855    #[gpui::test]
6856    async fn test_activate_archived_thread_reuses_workspace_in_another_window(
6857        cx: &mut TestAppContext,
6858    ) {
6859        init_test(cx);
6860        let fs = FakeFs::new(cx.executor());
6861        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6862            .await;
6863        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6864            .await;
6865        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6866
6867        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6868        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6869
6870        let multi_workspace_a =
6871            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6872        let multi_workspace_b =
6873            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
6874
6875        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6876
6877        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6878        let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
6879
6880        let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
6881
6882        sidebar.update_in(cx_a, |sidebar, window, cx| {
6883            sidebar.activate_archived_thread(
6884                Agent::NativeAgent,
6885                acp_thread::AgentSessionInfo {
6886                    session_id: session_id.clone(),
6887                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6888                    title: Some("Cross Window Thread".into()),
6889                    updated_at: None,
6890                    created_at: None,
6891                    meta: None,
6892                },
6893                window,
6894                cx,
6895            );
6896        });
6897        cx_a.run_until_parked();
6898
6899        assert_eq!(
6900            multi_workspace_a
6901                .read_with(cx_a, |mw, _| mw.workspaces().len())
6902                .unwrap(),
6903            1,
6904            "should not add the other window's workspace into the current window"
6905        );
6906        assert_eq!(
6907            multi_workspace_b
6908                .read_with(cx_a, |mw, _| mw.workspaces().len())
6909                .unwrap(),
6910            1,
6911            "should reuse the existing workspace in the other window"
6912        );
6913        assert!(
6914            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
6915            "should activate the window that already owns the matching workspace"
6916        );
6917        sidebar.read_with(cx_a, |sidebar, _| {
6918            assert_eq!(
6919                sidebar.focused_thread, None,
6920                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
6921            );
6922        });
6923    }
6924
6925    #[gpui::test]
6926    async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
6927        cx: &mut TestAppContext,
6928    ) {
6929        init_test(cx);
6930        let fs = FakeFs::new(cx.executor());
6931        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6932            .await;
6933        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6934            .await;
6935        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6936
6937        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6938        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6939
6940        let multi_workspace_a =
6941            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6942        let multi_workspace_b =
6943            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
6944
6945        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6946        let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
6947
6948        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6949        let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
6950
6951        let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
6952        let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
6953        let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
6954        let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b);
6955
6956        let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
6957
6958        sidebar_a.update_in(cx_a, |sidebar, window, cx| {
6959            sidebar.activate_archived_thread(
6960                Agent::NativeAgent,
6961                acp_thread::AgentSessionInfo {
6962                    session_id: session_id.clone(),
6963                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6964                    title: Some("Cross Window Thread".into()),
6965                    updated_at: None,
6966                    created_at: None,
6967                    meta: None,
6968                },
6969                window,
6970                cx,
6971            );
6972        });
6973        cx_a.run_until_parked();
6974
6975        assert_eq!(
6976            multi_workspace_a
6977                .read_with(cx_a, |mw, _| mw.workspaces().len())
6978                .unwrap(),
6979            1,
6980            "should not add the other window's workspace into the current window"
6981        );
6982        assert_eq!(
6983            multi_workspace_b
6984                .read_with(cx_a, |mw, _| mw.workspaces().len())
6985                .unwrap(),
6986            1,
6987            "should reuse the existing workspace in the other window"
6988        );
6989        assert!(
6990            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
6991            "should activate the window that already owns the matching workspace"
6992        );
6993        sidebar_a.read_with(cx_a, |sidebar, _| {
6994            assert_eq!(
6995                sidebar.focused_thread, None,
6996                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
6997            );
6998        });
6999        sidebar_b.read_with(cx_b, |sidebar, _| {
7000            assert_eq!(
7001                sidebar.focused_thread.as_ref(),
7002                Some(&session_id),
7003                "target window's sidebar should eagerly focus the activated archived thread"
7004            );
7005        });
7006    }
7007
7008    #[gpui::test]
7009    async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
7010        cx: &mut TestAppContext,
7011    ) {
7012        init_test(cx);
7013        let fs = FakeFs::new(cx.executor());
7014        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
7015            .await;
7016        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7017
7018        let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
7019        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
7020
7021        let multi_workspace_b =
7022            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
7023        let multi_workspace_a =
7024            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
7025
7026        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
7027
7028        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
7029        let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
7030
7031        let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
7032
7033        sidebar_a.update_in(cx_a, |sidebar, window, cx| {
7034            sidebar.activate_archived_thread(
7035                Agent::NativeAgent,
7036                acp_thread::AgentSessionInfo {
7037                    session_id: session_id.clone(),
7038                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])),
7039                    title: Some("Current Window Thread".into()),
7040                    updated_at: None,
7041                    created_at: None,
7042                    meta: None,
7043                },
7044                window,
7045                cx,
7046            );
7047        });
7048        cx_a.run_until_parked();
7049
7050        assert!(
7051            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
7052            "should keep activation in the current window when it already has a matching workspace"
7053        );
7054        sidebar_a.read_with(cx_a, |sidebar, _| {
7055            assert_eq!(
7056                sidebar.focused_thread.as_ref(),
7057                Some(&session_id),
7058                "current window's sidebar should eagerly focus the activated archived thread"
7059            );
7060        });
7061        assert_eq!(
7062            multi_workspace_a
7063                .read_with(cx_a, |mw, _| mw.workspaces().len())
7064                .unwrap(),
7065            1,
7066            "current window should continue reusing its existing workspace"
7067        );
7068        assert_eq!(
7069            multi_workspace_b
7070                .read_with(cx_a, |mw, _| mw.workspaces().len())
7071                .unwrap(),
7072            1,
7073            "other windows should not be activated just because they also match the saved paths"
7074        );
7075    }
7076
7077    #[gpui::test]
7078    async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
7079        // Regression test: archive_thread previously always loaded the next thread
7080        // through group_workspace (the main workspace's ProjectHeader), even when
7081        // the next thread belonged to an absorbed linked-worktree workspace. That
7082        // caused the worktree thread to be loaded in the main panel, which bound it
7083        // to the main project and corrupted its stored folder_paths.
7084        //
7085        // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
7086        // falling back to group_workspace only for Closed workspaces.
7087        agent_ui::test_support::init_test(cx);
7088        cx.update(|cx| {
7089            cx.update_flags(false, vec!["agent-v2".into()]);
7090            ThreadStore::init_global(cx);
7091            SidebarThreadMetadataStore::init_global(cx);
7092            language_model::LanguageModelRegistry::test(cx);
7093            prompt_store::init(cx);
7094        });
7095
7096        let fs = FakeFs::new(cx.executor());
7097
7098        fs.insert_tree(
7099            "/project",
7100            serde_json::json!({
7101                ".git": {
7102                    "worktrees": {
7103                        "feature-a": {
7104                            "commondir": "../../",
7105                            "HEAD": "ref: refs/heads/feature-a",
7106                        },
7107                    },
7108                },
7109                "src": {},
7110            }),
7111        )
7112        .await;
7113
7114        fs.insert_tree(
7115            "/wt-feature-a",
7116            serde_json::json!({
7117                ".git": "gitdir: /project/.git/worktrees/feature-a",
7118                "src": {},
7119            }),
7120        )
7121        .await;
7122
7123        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
7124            state.worktrees.push(git::repository::Worktree {
7125                path: std::path::PathBuf::from("/wt-feature-a"),
7126                ref_name: Some("refs/heads/feature-a".into()),
7127                sha: "aaa".into(),
7128            });
7129        })
7130        .unwrap();
7131
7132        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7133
7134        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7135        let worktree_project =
7136            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
7137
7138        main_project
7139            .update(cx, |p, cx| p.git_scans_complete(cx))
7140            .await;
7141        worktree_project
7142            .update(cx, |p, cx| p.git_scans_complete(cx))
7143            .await;
7144
7145        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7146            MultiWorkspace::test_new(main_project.clone(), window, cx)
7147        });
7148
7149        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
7150            mw.test_add_workspace(worktree_project.clone(), window, cx)
7151        });
7152
7153        // Activate main workspace so the sidebar tracks the main panel.
7154        multi_workspace.update_in(cx, |mw, window, cx| {
7155            mw.activate_index(0, window, cx);
7156        });
7157
7158        let sidebar = setup_sidebar(&multi_workspace, cx);
7159
7160        let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone());
7161        let main_panel = add_agent_panel(&main_workspace, &main_project, cx);
7162        let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
7163
7164        // Open Thread 2 in the main panel and keep it running.
7165        let connection = StubAgentConnection::new();
7166        open_thread_with_connection(&main_panel, connection.clone(), cx);
7167        send_message(&main_panel, cx);
7168
7169        let thread2_session_id = active_session_id(&main_panel, cx);
7170
7171        cx.update(|_, cx| {
7172            connection.send_update(
7173                thread2_session_id.clone(),
7174                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
7175                cx,
7176            );
7177        });
7178
7179        // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
7180        save_thread_metadata(
7181            thread2_session_id.clone(),
7182            "Thread 2".into(),
7183            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7184            PathList::new(&[std::path::PathBuf::from("/project")]),
7185            cx,
7186        )
7187        .await;
7188
7189        // Save thread 1's metadata with the worktree path and an older timestamp so
7190        // it sorts below thread 2. archive_thread will find it as the "next" candidate.
7191        let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
7192        save_thread_metadata(
7193            thread1_session_id.clone(),
7194            "Thread 1".into(),
7195            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7196            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
7197            cx,
7198        )
7199        .await;
7200
7201        cx.run_until_parked();
7202
7203        // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
7204        let entries_before = visible_entries_as_strings(&sidebar, cx);
7205        assert!(
7206            entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
7207            "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
7208            entries_before
7209        );
7210
7211        // The sidebar should track T2 as the focused thread (derived from the
7212        // main panel's active view).
7213        let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone());
7214        assert_eq!(
7215            focused,
7216            Some(thread2_session_id.clone()),
7217            "focused thread should be Thread 2 before archiving: {:?}",
7218            focused
7219        );
7220
7221        // Archive thread 2.
7222        sidebar.update_in(cx, |sidebar, window, cx| {
7223            sidebar.archive_thread(&thread2_session_id, window, cx);
7224        });
7225
7226        cx.run_until_parked();
7227
7228        // The main panel's active thread must still be thread 2.
7229        let main_active = main_panel.read_with(cx, |panel, cx| {
7230            panel
7231                .active_agent_thread(cx)
7232                .map(|t| t.read(cx).session_id().clone())
7233        });
7234        assert_eq!(
7235            main_active,
7236            Some(thread2_session_id.clone()),
7237            "main panel should not have been taken over by loading the linked-worktree thread T1; \
7238             before the fix, archive_thread used group_workspace instead of next.workspace, \
7239             causing T1 to be loaded in the wrong panel"
7240        );
7241
7242        // Thread 1 should still appear in the sidebar with its worktree chip
7243        // (Thread 2 was archived so it is gone from the list).
7244        let entries_after = visible_entries_as_strings(&sidebar, cx);
7245        assert!(
7246            entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
7247            "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
7248            entries_after
7249        );
7250    }
7251
7252    #[gpui::test]
7253    async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
7254        // When a multi-root workspace (e.g. [/other, /project]) shares a
7255        // repo with a single-root workspace (e.g. [/project]), linked
7256        // worktree threads from the shared repo should only appear under
7257        // the dedicated group [project], not under [other, project].
7258        init_test(cx);
7259        let fs = FakeFs::new(cx.executor());
7260
7261        fs.insert_tree(
7262            "/project",
7263            serde_json::json!({
7264                ".git": {
7265                    "worktrees": {
7266                        "feature-a": {
7267                            "commondir": "../../",
7268                            "HEAD": "ref: refs/heads/feature-a",
7269                        },
7270                    },
7271                },
7272                "src": {},
7273            }),
7274        )
7275        .await;
7276        fs.insert_tree(
7277            "/wt-feature-a",
7278            serde_json::json!({
7279                ".git": "gitdir: /project/.git/worktrees/feature-a",
7280                "src": {},
7281            }),
7282        )
7283        .await;
7284        fs.insert_tree(
7285            "/other",
7286            serde_json::json!({
7287                ".git": {},
7288                "src": {},
7289            }),
7290        )
7291        .await;
7292
7293        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
7294            state.worktrees.push(git::repository::Worktree {
7295                path: std::path::PathBuf::from("/wt-feature-a"),
7296                ref_name: Some("refs/heads/feature-a".into()),
7297                sha: "aaa".into(),
7298            });
7299        })
7300        .unwrap();
7301
7302        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7303
7304        let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7305        project_only
7306            .update(cx, |p, cx| p.git_scans_complete(cx))
7307            .await;
7308
7309        let multi_root =
7310            project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
7311        multi_root
7312            .update(cx, |p, cx| p.git_scans_complete(cx))
7313            .await;
7314
7315        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7316            MultiWorkspace::test_new(project_only.clone(), window, cx)
7317        });
7318        multi_workspace.update_in(cx, |mw, window, cx| {
7319            mw.test_add_workspace(multi_root.clone(), window, cx);
7320        });
7321        let sidebar = setup_sidebar(&multi_workspace, cx);
7322
7323        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
7324        save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
7325
7326        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7327        cx.run_until_parked();
7328
7329        assert_eq!(
7330            visible_entries_as_strings(&sidebar, cx),
7331            vec![
7332                "v [project]",
7333                "  Worktree Thread {wt-feature-a}",
7334                "v [other, project]",
7335                "  [+ New Thread]",
7336            ]
7337        );
7338    }
7339}