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