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