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