sidebar.rs

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