sidebar.rs

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