sidebar.rs

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