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                    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
1503                let this = this.clone();
1504                window
1505                    .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1506                        this.update(cx, |sidebar, cx| {
1507                            sidebar.project_header_menu_ix = None;
1508                            cx.notify();
1509                        })
1510                        .ok();
1511                    })
1512                    .detach();
1513
1514                Some(menu)
1515            })
1516            .trigger(
1517                IconButton::new(
1518                    SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1519                    IconName::Ellipsis,
1520                )
1521                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1522                .icon_size(IconSize::Small)
1523                .icon_color(Color::Muted),
1524            )
1525            .anchor(gpui::Corner::TopRight)
1526            .offset(gpui::Point {
1527                x: px(0.),
1528                y: px(1.),
1529            })
1530    }
1531
1532    fn render_sticky_header(
1533        &self,
1534        window: &mut Window,
1535        cx: &mut Context<Self>,
1536    ) -> Option<AnyElement> {
1537        let scroll_top = self.list_state.logical_scroll_top();
1538
1539        let &header_idx = self
1540            .contents
1541            .project_header_indices
1542            .iter()
1543            .rev()
1544            .find(|&&idx| idx <= scroll_top.item_ix)?;
1545
1546        let needs_sticky = header_idx < scroll_top.item_ix
1547            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1548
1549        if !needs_sticky {
1550            return None;
1551        }
1552
1553        let ListEntry::ProjectHeader {
1554            path_list,
1555            label,
1556            workspace,
1557            highlight_positions,
1558            has_running_threads,
1559            waiting_thread_count,
1560        } = self.contents.entries.get(header_idx)?
1561        else {
1562            return None;
1563        };
1564
1565        let is_focused = self.focus_handle.is_focused(window);
1566        let is_selected = is_focused && self.selection == Some(header_idx);
1567
1568        let header_element = self.render_project_header(
1569            header_idx,
1570            true,
1571            &path_list,
1572            &label,
1573            &workspace,
1574            &highlight_positions,
1575            *has_running_threads,
1576            *waiting_thread_count,
1577            is_selected,
1578            cx,
1579        );
1580
1581        let top_offset = self
1582            .contents
1583            .project_header_indices
1584            .iter()
1585            .find(|&&idx| idx > header_idx)
1586            .and_then(|&next_idx| {
1587                let bounds = self.list_state.bounds_for_item(next_idx)?;
1588                let viewport = self.list_state.viewport_bounds();
1589                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1590                let header_height = bounds.size.height;
1591                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1592            })
1593            .unwrap_or(px(0.));
1594
1595        let color = cx.theme().colors();
1596        let background = color
1597            .title_bar_background
1598            .blend(color.panel_background.opacity(0.2));
1599
1600        let element = v_flex()
1601            .absolute()
1602            .top(top_offset)
1603            .left_0()
1604            .w_full()
1605            .bg(background)
1606            .border_b_1()
1607            .border_color(color.border.opacity(0.5))
1608            .child(header_element)
1609            .shadow_xs()
1610            .into_any_element();
1611
1612        Some(element)
1613    }
1614
1615    fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1616        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1617            return;
1618        };
1619        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
1620
1621        // Collect all worktree paths that are currently listed by any main
1622        // repo open in any workspace.
1623        let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
1624        for workspace in &workspaces {
1625            for snapshot in root_repository_snapshots(workspace, cx) {
1626                if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
1627                    continue;
1628                }
1629                for git_worktree in snapshot.linked_worktrees() {
1630                    known_worktree_paths.insert(git_worktree.path.to_path_buf());
1631                }
1632            }
1633        }
1634
1635        // Find workspaces that consist of exactly one root folder which is a
1636        // stale worktree checkout. Multi-root workspaces are never pruned —
1637        // losing one worktree shouldn't destroy a workspace that also
1638        // contains other folders.
1639        let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
1640        for workspace in &workspaces {
1641            let path_list = workspace_path_list(workspace, cx);
1642            if path_list.paths().len() != 1 {
1643                continue;
1644            }
1645            let should_prune = root_repository_snapshots(workspace, cx)
1646                .iter()
1647                .any(|snapshot| {
1648                    snapshot.work_directory_abs_path != snapshot.original_repo_abs_path
1649                        && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
1650                });
1651            if should_prune {
1652                to_remove.push(workspace.clone());
1653            }
1654        }
1655
1656        for workspace in &to_remove {
1657            self.remove_workspace(workspace, window, cx);
1658        }
1659    }
1660
1661    fn remove_workspace(
1662        &mut self,
1663        workspace: &Entity<Workspace>,
1664        window: &mut Window,
1665        cx: &mut Context<Self>,
1666    ) {
1667        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1668            return;
1669        };
1670
1671        multi_workspace.update(cx, |multi_workspace, cx| {
1672            let Some(index) = multi_workspace
1673                .workspaces()
1674                .iter()
1675                .position(|w| w == workspace)
1676            else {
1677                return;
1678            };
1679            multi_workspace.remove_workspace(index, window, cx);
1680        });
1681    }
1682
1683    fn toggle_collapse(
1684        &mut self,
1685        path_list: &PathList,
1686        _window: &mut Window,
1687        cx: &mut Context<Self>,
1688    ) {
1689        if self.collapsed_groups.contains(path_list) {
1690            self.collapsed_groups.remove(path_list);
1691        } else {
1692            self.collapsed_groups.insert(path_list.clone());
1693        }
1694        self.update_entries(cx);
1695    }
1696
1697    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1698        if !self.focus_handle.is_focused(window) {
1699            return;
1700        }
1701
1702        if let SidebarView::Archive(archive) = &self.view {
1703            let has_selection = archive.read(cx).has_selection();
1704            if !has_selection {
1705                archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1706            }
1707        } else if self.selection.is_none() {
1708            self.filter_editor.focus_handle(cx).focus(window, cx);
1709        }
1710    }
1711
1712    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1713        if self.reset_filter_editor_text(window, cx) {
1714            self.update_entries(cx);
1715        } else {
1716            self.selection = None;
1717            self.filter_editor.focus_handle(cx).focus(window, cx);
1718            cx.notify();
1719        }
1720    }
1721
1722    fn focus_sidebar_filter(
1723        &mut self,
1724        _: &FocusSidebarFilter,
1725        window: &mut Window,
1726        cx: &mut Context<Self>,
1727    ) {
1728        self.selection = None;
1729        if let SidebarView::Archive(archive) = &self.view {
1730            archive.update(cx, |view, cx| {
1731                view.clear_selection();
1732                view.focus_filter_editor(window, cx);
1733            });
1734        } else {
1735            self.filter_editor.focus_handle(cx).focus(window, cx);
1736        }
1737
1738        // When vim mode is active, the editor defaults to normal mode which
1739        // blocks text input. Switch to insert mode so the user can type
1740        // immediately.
1741        if vim_mode_setting::VimModeSetting::get_global(cx).0 {
1742            if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
1743                window.dispatch_action(action, cx);
1744            }
1745        }
1746
1747        cx.notify();
1748    }
1749
1750    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1751        self.filter_editor.update(cx, |editor, cx| {
1752            if editor.buffer().read(cx).len(cx).0 > 0 {
1753                editor.set_text("", window, cx);
1754                true
1755            } else {
1756                false
1757            }
1758        })
1759    }
1760
1761    fn has_filter_query(&self, cx: &App) -> bool {
1762        !self.filter_editor.read(cx).text(cx).is_empty()
1763    }
1764
1765    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1766        self.select_next(&SelectNext, window, cx);
1767        if self.selection.is_some() {
1768            self.focus_handle.focus(window, cx);
1769        }
1770    }
1771
1772    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1773        self.select_previous(&SelectPrevious, window, cx);
1774        if self.selection.is_some() {
1775            self.focus_handle.focus(window, cx);
1776        }
1777    }
1778
1779    fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1780        if self.selection.is_none() {
1781            self.select_next(&SelectNext, window, cx);
1782        }
1783        if self.selection.is_some() {
1784            self.focus_handle.focus(window, cx);
1785        }
1786    }
1787
1788    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1789        let next = match self.selection {
1790            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1791            Some(_) if !self.contents.entries.is_empty() => 0,
1792            None if !self.contents.entries.is_empty() => 0,
1793            _ => return,
1794        };
1795        self.selection = Some(next);
1796        self.list_state.scroll_to_reveal_item(next);
1797        cx.notify();
1798    }
1799
1800    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1801        match self.selection {
1802            Some(0) => {
1803                self.selection = None;
1804                self.filter_editor.focus_handle(cx).focus(window, cx);
1805                cx.notify();
1806            }
1807            Some(ix) => {
1808                self.selection = Some(ix - 1);
1809                self.list_state.scroll_to_reveal_item(ix - 1);
1810                cx.notify();
1811            }
1812            None if !self.contents.entries.is_empty() => {
1813                let last = self.contents.entries.len() - 1;
1814                self.selection = Some(last);
1815                self.list_state.scroll_to_reveal_item(last);
1816                cx.notify();
1817            }
1818            None => {}
1819        }
1820    }
1821
1822    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1823        if !self.contents.entries.is_empty() {
1824            self.selection = Some(0);
1825            self.list_state.scroll_to_reveal_item(0);
1826            cx.notify();
1827        }
1828    }
1829
1830    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1831        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1832            self.selection = Some(last);
1833            self.list_state.scroll_to_reveal_item(last);
1834            cx.notify();
1835        }
1836    }
1837
1838    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1839        let Some(ix) = self.selection else { return };
1840        let Some(entry) = self.contents.entries.get(ix) else {
1841            return;
1842        };
1843
1844        match entry {
1845            ListEntry::ProjectHeader { path_list, .. } => {
1846                let path_list = path_list.clone();
1847                self.toggle_collapse(&path_list, window, cx);
1848            }
1849            ListEntry::Thread(thread) => {
1850                let session_info = thread.session_info.clone();
1851                match &thread.workspace {
1852                    ThreadEntryWorkspace::Open(workspace) => {
1853                        let workspace = workspace.clone();
1854                        self.activate_thread(
1855                            thread.agent.clone(),
1856                            session_info,
1857                            &workspace,
1858                            window,
1859                            cx,
1860                        );
1861                    }
1862                    ThreadEntryWorkspace::Closed(path_list) => {
1863                        self.open_workspace_and_activate_thread(
1864                            thread.agent.clone(),
1865                            session_info,
1866                            path_list.clone(),
1867                            window,
1868                            cx,
1869                        );
1870                    }
1871                }
1872            }
1873            ListEntry::ViewMore {
1874                path_list,
1875                is_fully_expanded,
1876                ..
1877            } => {
1878                let path_list = path_list.clone();
1879                if *is_fully_expanded {
1880                    self.expanded_groups.remove(&path_list);
1881                } else {
1882                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1883                    self.expanded_groups.insert(path_list, current + 1);
1884                }
1885                self.update_entries(cx);
1886            }
1887            ListEntry::NewThread { workspace, .. } => {
1888                let workspace = workspace.clone();
1889                self.create_new_thread(&workspace, window, cx);
1890            }
1891        }
1892    }
1893
1894    fn find_workspace_across_windows(
1895        &self,
1896        cx: &App,
1897        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1898    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
1899        cx.windows()
1900            .into_iter()
1901            .filter_map(|window| window.downcast::<MultiWorkspace>())
1902            .find_map(|window| {
1903                let workspace = window.read(cx).ok().and_then(|multi_workspace| {
1904                    multi_workspace
1905                        .workspaces()
1906                        .iter()
1907                        .find(|workspace| predicate(workspace, cx))
1908                        .cloned()
1909                })?;
1910                Some((window, workspace))
1911            })
1912    }
1913
1914    fn find_workspace_in_current_window(
1915        &self,
1916        cx: &App,
1917        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1918    ) -> Option<Entity<Workspace>> {
1919        self.multi_workspace.upgrade().and_then(|multi_workspace| {
1920            multi_workspace
1921                .read(cx)
1922                .workspaces()
1923                .iter()
1924                .find(|workspace| predicate(workspace, cx))
1925                .cloned()
1926        })
1927    }
1928
1929    fn load_agent_thread_in_workspace(
1930        workspace: &Entity<Workspace>,
1931        agent: Agent,
1932        session_info: acp_thread::AgentSessionInfo,
1933        window: &mut Window,
1934        cx: &mut App,
1935    ) {
1936        workspace.update(cx, |workspace, cx| {
1937            workspace.open_panel::<AgentPanel>(window, cx);
1938        });
1939
1940        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1941            agent_panel.update(cx, |panel, cx| {
1942                panel.load_agent_thread(
1943                    agent,
1944                    session_info.session_id,
1945                    session_info.work_dirs,
1946                    session_info.title,
1947                    true,
1948                    window,
1949                    cx,
1950                );
1951            });
1952        }
1953    }
1954
1955    fn activate_thread_locally(
1956        &mut self,
1957        agent: Agent,
1958        session_info: acp_thread::AgentSessionInfo,
1959        workspace: &Entity<Workspace>,
1960        window: &mut Window,
1961        cx: &mut Context<Self>,
1962    ) {
1963        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1964            return;
1965        };
1966
1967        // Set focused_thread eagerly so the sidebar highlight updates
1968        // immediately, rather than waiting for a deferred AgentPanel
1969        // event which can race with ActiveWorkspaceChanged clearing it.
1970        self.focused_thread = Some(session_info.session_id.clone());
1971
1972        multi_workspace.update(cx, |multi_workspace, cx| {
1973            multi_workspace.activate(workspace.clone(), cx);
1974        });
1975
1976        Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx);
1977
1978        self.update_entries(cx);
1979    }
1980
1981    fn activate_thread_in_other_window(
1982        &self,
1983        agent: Agent,
1984        session_info: acp_thread::AgentSessionInfo,
1985        workspace: Entity<Workspace>,
1986        target_window: WindowHandle<MultiWorkspace>,
1987        cx: &mut Context<Self>,
1988    ) {
1989        let target_session_id = session_info.session_id.clone();
1990
1991        let activated = target_window
1992            .update(cx, |multi_workspace, window, cx| {
1993                window.activate_window();
1994                multi_workspace.activate(workspace.clone(), cx);
1995                Self::load_agent_thread_in_workspace(&workspace, agent, session_info, window, cx);
1996            })
1997            .log_err()
1998            .is_some();
1999
2000        if activated {
2001            if let Some(target_sidebar) = target_window
2002                .read(cx)
2003                .ok()
2004                .and_then(|multi_workspace| {
2005                    multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2006                })
2007                .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2008            {
2009                target_sidebar.update(cx, |sidebar, cx| {
2010                    sidebar.focused_thread = Some(target_session_id);
2011                    sidebar.update_entries(cx);
2012                });
2013            }
2014        }
2015    }
2016
2017    fn activate_thread(
2018        &mut self,
2019        agent: Agent,
2020        session_info: acp_thread::AgentSessionInfo,
2021        workspace: &Entity<Workspace>,
2022        window: &mut Window,
2023        cx: &mut Context<Self>,
2024    ) {
2025        if self
2026            .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2027            .is_some()
2028        {
2029            self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2030            return;
2031        }
2032
2033        let Some((target_window, workspace)) =
2034            self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2035        else {
2036            return;
2037        };
2038
2039        self.activate_thread_in_other_window(agent, session_info, workspace, target_window, cx);
2040    }
2041
2042    fn open_workspace_and_activate_thread(
2043        &mut self,
2044        agent: Agent,
2045        session_info: acp_thread::AgentSessionInfo,
2046        path_list: PathList,
2047        window: &mut Window,
2048        cx: &mut Context<Self>,
2049    ) {
2050        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2051            return;
2052        };
2053
2054        let paths: Vec<std::path::PathBuf> =
2055            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
2056
2057        let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
2058
2059        cx.spawn_in(window, async move |this, cx| {
2060            let workspace = open_task.await?;
2061            this.update_in(cx, |this, window, cx| {
2062                this.activate_thread(agent, session_info, &workspace, window, cx);
2063            })?;
2064            anyhow::Ok(())
2065        })
2066        .detach_and_log_err(cx);
2067    }
2068
2069    fn find_current_workspace_for_path_list(
2070        &self,
2071        path_list: &PathList,
2072        cx: &App,
2073    ) -> Option<Entity<Workspace>> {
2074        self.find_workspace_in_current_window(cx, |workspace, cx| {
2075            workspace_path_list(workspace, cx).paths() == path_list.paths()
2076        })
2077    }
2078
2079    fn find_open_workspace_for_path_list(
2080        &self,
2081        path_list: &PathList,
2082        cx: &App,
2083    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2084        self.find_workspace_across_windows(cx, |workspace, cx| {
2085            workspace_path_list(workspace, cx).paths() == path_list.paths()
2086        })
2087    }
2088
2089    fn activate_archived_thread(
2090        &mut self,
2091        agent: Agent,
2092        session_info: acp_thread::AgentSessionInfo,
2093        window: &mut Window,
2094        cx: &mut Context<Self>,
2095    ) {
2096        // Eagerly save thread metadata so that the sidebar is updated immediately
2097        SidebarThreadMetadataStore::global(cx)
2098            .update(cx, |store, cx| {
2099                store.save(
2100                    ThreadMetadata::from_session_info(agent.id(), &session_info),
2101                    cx,
2102                )
2103            })
2104            .detach_and_log_err(cx);
2105
2106        if let Some(path_list) = &session_info.work_dirs {
2107            if let Some(workspace) = self.find_current_workspace_for_path_list(path_list, cx) {
2108                self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2109            } else if let Some((target_window, workspace)) =
2110                self.find_open_workspace_for_path_list(path_list, cx)
2111            {
2112                self.activate_thread_in_other_window(
2113                    agent,
2114                    session_info,
2115                    workspace,
2116                    target_window,
2117                    cx,
2118                );
2119            } else {
2120                let path_list = path_list.clone();
2121                self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
2122            }
2123            return;
2124        }
2125
2126        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
2127            w.read(cx)
2128                .workspaces()
2129                .get(w.read(cx).active_workspace_index())
2130                .cloned()
2131        });
2132
2133        if let Some(workspace) = active_workspace {
2134            self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2135        }
2136    }
2137
2138    fn expand_selected_entry(
2139        &mut self,
2140        _: &SelectChild,
2141        _window: &mut Window,
2142        cx: &mut Context<Self>,
2143    ) {
2144        let Some(ix) = self.selection else { return };
2145
2146        match self.contents.entries.get(ix) {
2147            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2148                if self.collapsed_groups.contains(path_list) {
2149                    let path_list = path_list.clone();
2150                    self.collapsed_groups.remove(&path_list);
2151                    self.update_entries(cx);
2152                } else if ix + 1 < self.contents.entries.len() {
2153                    self.selection = Some(ix + 1);
2154                    self.list_state.scroll_to_reveal_item(ix + 1);
2155                    cx.notify();
2156                }
2157            }
2158            _ => {}
2159        }
2160    }
2161
2162    fn collapse_selected_entry(
2163        &mut self,
2164        _: &SelectParent,
2165        _window: &mut Window,
2166        cx: &mut Context<Self>,
2167    ) {
2168        let Some(ix) = self.selection else { return };
2169
2170        match self.contents.entries.get(ix) {
2171            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2172                if !self.collapsed_groups.contains(path_list) {
2173                    let path_list = path_list.clone();
2174                    self.collapsed_groups.insert(path_list);
2175                    self.update_entries(cx);
2176                }
2177            }
2178            Some(
2179                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2180            ) => {
2181                for i in (0..ix).rev() {
2182                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2183                        self.contents.entries.get(i)
2184                    {
2185                        let path_list = path_list.clone();
2186                        self.selection = Some(i);
2187                        self.collapsed_groups.insert(path_list);
2188                        self.update_entries(cx);
2189                        break;
2190                    }
2191                }
2192            }
2193            None => {}
2194        }
2195    }
2196
2197    fn toggle_selected_fold(
2198        &mut self,
2199        _: &editor::actions::ToggleFold,
2200        _window: &mut Window,
2201        cx: &mut Context<Self>,
2202    ) {
2203        let Some(ix) = self.selection else { return };
2204
2205        // Find the group header for the current selection.
2206        let header_ix = match self.contents.entries.get(ix) {
2207            Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2208            Some(
2209                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2210            ) => (0..ix).rev().find(|&i| {
2211                matches!(
2212                    self.contents.entries.get(i),
2213                    Some(ListEntry::ProjectHeader { .. })
2214                )
2215            }),
2216            None => None,
2217        };
2218
2219        if let Some(header_ix) = header_ix {
2220            if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2221                self.contents.entries.get(header_ix)
2222            {
2223                let path_list = path_list.clone();
2224                if self.collapsed_groups.contains(&path_list) {
2225                    self.collapsed_groups.remove(&path_list);
2226                } else {
2227                    self.selection = Some(header_ix);
2228                    self.collapsed_groups.insert(path_list);
2229                }
2230                self.update_entries(cx);
2231            }
2232        }
2233    }
2234
2235    fn fold_all(
2236        &mut self,
2237        _: &editor::actions::FoldAll,
2238        _window: &mut Window,
2239        cx: &mut Context<Self>,
2240    ) {
2241        for entry in &self.contents.entries {
2242            if let ListEntry::ProjectHeader { path_list, .. } = entry {
2243                self.collapsed_groups.insert(path_list.clone());
2244            }
2245        }
2246        self.update_entries(cx);
2247    }
2248
2249    fn unfold_all(
2250        &mut self,
2251        _: &editor::actions::UnfoldAll,
2252        _window: &mut Window,
2253        cx: &mut Context<Self>,
2254    ) {
2255        self.collapsed_groups.clear();
2256        self.update_entries(cx);
2257    }
2258
2259    fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2260        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2261            return;
2262        };
2263
2264        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
2265        for workspace in workspaces {
2266            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2267                let cancelled =
2268                    agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2269                if cancelled {
2270                    return;
2271                }
2272            }
2273        }
2274    }
2275
2276    fn archive_thread(
2277        &mut self,
2278        session_id: &acp::SessionId,
2279        window: &mut Window,
2280        cx: &mut Context<Self>,
2281    ) {
2282        // If we're archiving the currently focused thread, move focus to the
2283        // nearest thread within the same project group. We never cross group
2284        // boundaries — if the group has no other threads, clear focus and open
2285        // a blank new thread in the panel instead.
2286        if self.focused_thread.as_ref() == Some(session_id) {
2287            let current_pos = self.contents.entries.iter().position(|entry| {
2288                matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
2289            });
2290
2291            // Find the workspace that owns this thread's project group by
2292            // walking backwards to the nearest ProjectHeader. We must use
2293            // *this* workspace (not the active workspace) because the user
2294            // might be archiving a thread in a non-active group.
2295            let group_workspace = current_pos.and_then(|pos| {
2296                self.contents.entries[..pos]
2297                    .iter()
2298                    .rev()
2299                    .find_map(|e| match e {
2300                        ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2301                        _ => None,
2302                    })
2303            });
2304
2305            let next_thread = current_pos.and_then(|pos| {
2306                let group_start = self.contents.entries[..pos]
2307                    .iter()
2308                    .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2309                    .map_or(0, |i| i + 1);
2310                let group_end = self.contents.entries[pos + 1..]
2311                    .iter()
2312                    .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2313                    .map_or(self.contents.entries.len(), |i| pos + 1 + i);
2314
2315                let above = self.contents.entries[group_start..pos]
2316                    .iter()
2317                    .rev()
2318                    .find_map(|entry| {
2319                        if let ListEntry::Thread(t) = entry {
2320                            Some(t)
2321                        } else {
2322                            None
2323                        }
2324                    });
2325
2326                above.or_else(|| {
2327                    self.contents.entries[pos + 1..group_end]
2328                        .iter()
2329                        .find_map(|entry| {
2330                            if let ListEntry::Thread(t) = entry {
2331                                Some(t)
2332                            } else {
2333                                None
2334                            }
2335                        })
2336                })
2337            });
2338
2339            if let Some(next) = next_thread {
2340                self.focused_thread = Some(next.session_info.session_id.clone());
2341
2342                if let Some(workspace) = &group_workspace {
2343                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2344                        agent_panel.update(cx, |panel, cx| {
2345                            panel.load_agent_thread(
2346                                next.agent.clone(),
2347                                next.session_info.session_id.clone(),
2348                                next.session_info.work_dirs.clone(),
2349                                next.session_info.title.clone(),
2350                                true,
2351                                window,
2352                                cx,
2353                            );
2354                        });
2355                    }
2356                }
2357            } else {
2358                self.focused_thread = None;
2359                if let Some(workspace) = &group_workspace {
2360                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2361                        agent_panel.update(cx, |panel, cx| {
2362                            panel.new_thread(&NewThread, window, cx);
2363                        });
2364                    }
2365                }
2366            }
2367        }
2368
2369        SidebarThreadMetadataStore::global(cx)
2370            .update(cx, |store, cx| store.delete(session_id.clone(), cx))
2371            .detach_and_log_err(cx);
2372    }
2373
2374    fn remove_selected_thread(
2375        &mut self,
2376        _: &RemoveSelectedThread,
2377        window: &mut Window,
2378        cx: &mut Context<Self>,
2379    ) {
2380        let Some(ix) = self.selection else {
2381            return;
2382        };
2383        let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
2384            return;
2385        };
2386        if thread.agent != Agent::NativeAgent {
2387            return;
2388        }
2389        let session_id = thread.session_info.session_id.clone();
2390        self.archive_thread(&session_id, window, cx);
2391    }
2392
2393    fn render_thread(
2394        &self,
2395        ix: usize,
2396        thread: &ThreadEntry,
2397        is_focused: bool,
2398        cx: &mut Context<Self>,
2399    ) -> AnyElement {
2400        let has_notification = self
2401            .contents
2402            .is_thread_notified(&thread.session_info.session_id);
2403
2404        let title: SharedString = thread
2405            .session_info
2406            .title
2407            .clone()
2408            .unwrap_or_else(|| "Untitled".into());
2409        let session_info = thread.session_info.clone();
2410        let thread_workspace = thread.workspace.clone();
2411
2412        let is_hovered = self.hovered_thread_index == Some(ix);
2413        let is_selected = self.agent_panel_visible
2414            && self.focused_thread.as_ref() == Some(&session_info.session_id);
2415        let is_running = matches!(
2416            thread.status,
2417            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
2418        );
2419
2420        let session_id_for_delete = thread.session_info.session_id.clone();
2421        let focus_handle = self.focus_handle.clone();
2422
2423        let id = SharedString::from(format!("thread-entry-{}", ix));
2424
2425        let timestamp = thread
2426            .session_info
2427            .created_at
2428            .or(thread.session_info.updated_at)
2429            .map(format_history_entry_timestamp);
2430
2431        ThreadItem::new(id, title)
2432            .icon(thread.icon)
2433            .status(thread.status)
2434            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
2435                this.custom_icon_from_external_svg(svg)
2436            })
2437            .when_some(thread.worktree_name.clone(), |this, name| {
2438                let this = this.worktree(name);
2439                match thread.worktree_full_path.clone() {
2440                    Some(path) => this.worktree_full_path(path),
2441                    None => this,
2442                }
2443            })
2444            .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
2445            .when_some(timestamp, |this, ts| this.timestamp(ts))
2446            .highlight_positions(thread.highlight_positions.to_vec())
2447            .title_generating(thread.is_title_generating)
2448            .notified(has_notification)
2449            .when(thread.diff_stats.lines_added > 0, |this| {
2450                this.added(thread.diff_stats.lines_added as usize)
2451            })
2452            .when(thread.diff_stats.lines_removed > 0, |this| {
2453                this.removed(thread.diff_stats.lines_removed as usize)
2454            })
2455            .selected(is_selected)
2456            .focused(is_focused)
2457            .hovered(is_hovered)
2458            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
2459                if *is_hovered {
2460                    this.hovered_thread_index = Some(ix);
2461                } else if this.hovered_thread_index == Some(ix) {
2462                    this.hovered_thread_index = None;
2463                }
2464                cx.notify();
2465            }))
2466            .when(is_hovered && is_running, |this| {
2467                this.action_slot(
2468                    IconButton::new("stop-thread", IconName::Stop)
2469                        .icon_size(IconSize::Small)
2470                        .icon_color(Color::Error)
2471                        .style(ButtonStyle::Tinted(TintColor::Error))
2472                        .tooltip(Tooltip::text("Stop Generation"))
2473                        .on_click({
2474                            let session_id = session_id_for_delete.clone();
2475                            cx.listener(move |this, _, _window, cx| {
2476                                this.stop_thread(&session_id, cx);
2477                            })
2478                        }),
2479                )
2480            })
2481            .when(is_hovered && !is_running, |this| {
2482                this.action_slot(
2483                    IconButton::new("archive-thread", IconName::Archive)
2484                        .icon_size(IconSize::Small)
2485                        .icon_color(Color::Muted)
2486                        .tooltip({
2487                            let focus_handle = focus_handle.clone();
2488                            move |_window, cx| {
2489                                Tooltip::for_action_in(
2490                                    "Archive Thread",
2491                                    &RemoveSelectedThread,
2492                                    &focus_handle,
2493                                    cx,
2494                                )
2495                            }
2496                        })
2497                        .on_click({
2498                            let session_id = session_id_for_delete.clone();
2499                            cx.listener(move |this, _, window, cx| {
2500                                this.archive_thread(&session_id, window, cx);
2501                            })
2502                        }),
2503                )
2504            })
2505            .on_click({
2506                let agent = thread.agent.clone();
2507                cx.listener(move |this, _, window, cx| {
2508                    this.selection = None;
2509                    match &thread_workspace {
2510                        ThreadEntryWorkspace::Open(workspace) => {
2511                            this.activate_thread(
2512                                agent.clone(),
2513                                session_info.clone(),
2514                                workspace,
2515                                window,
2516                                cx,
2517                            );
2518                        }
2519                        ThreadEntryWorkspace::Closed(path_list) => {
2520                            this.open_workspace_and_activate_thread(
2521                                agent.clone(),
2522                                session_info.clone(),
2523                                path_list.clone(),
2524                                window,
2525                                cx,
2526                            );
2527                        }
2528                    }
2529                })
2530            })
2531            .into_any_element()
2532    }
2533
2534    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
2535        div()
2536            .min_w_0()
2537            .flex_1()
2538            .capture_action(
2539                cx.listener(|this, _: &editor::actions::Newline, window, cx| {
2540                    this.editor_confirm(window, cx);
2541                }),
2542            )
2543            .child(self.filter_editor.clone())
2544    }
2545
2546    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
2547        let multi_workspace = self.multi_workspace.upgrade();
2548
2549        let workspace = multi_workspace
2550            .as_ref()
2551            .map(|mw| mw.read(cx).workspace().downgrade());
2552
2553        let focus_handle = workspace
2554            .as_ref()
2555            .and_then(|ws| ws.upgrade())
2556            .map(|w| w.read(cx).focus_handle(cx))
2557            .unwrap_or_else(|| cx.focus_handle());
2558
2559        let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
2560            .as_ref()
2561            .map(|mw| {
2562                mw.read(cx)
2563                    .workspaces()
2564                    .iter()
2565                    .filter_map(|ws| ws.read(cx).database_id())
2566                    .collect()
2567            })
2568            .unwrap_or_default();
2569
2570        let popover_handle = self.recent_projects_popover_handle.clone();
2571
2572        PopoverMenu::new("sidebar-recent-projects-menu")
2573            .with_handle(popover_handle)
2574            .menu(move |window, cx| {
2575                workspace.as_ref().map(|ws| {
2576                    SidebarRecentProjects::popover(
2577                        ws.clone(),
2578                        sibling_workspace_ids.clone(),
2579                        focus_handle.clone(),
2580                        window,
2581                        cx,
2582                    )
2583                })
2584            })
2585            .trigger_with_tooltip(
2586                IconButton::new("open-project", IconName::OpenFolder)
2587                    .icon_size(IconSize::Small)
2588                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
2589                |_window, cx| {
2590                    Tooltip::for_action(
2591                        "Add Project",
2592                        &OpenRecent {
2593                            create_new_window: false,
2594                        },
2595                        cx,
2596                    )
2597                },
2598            )
2599            .offset(gpui::Point {
2600                x: px(-2.0),
2601                y: px(-2.0),
2602            })
2603            .anchor(gpui::Corner::BottomRight)
2604    }
2605
2606    fn render_view_more(
2607        &self,
2608        ix: usize,
2609        path_list: &PathList,
2610        is_fully_expanded: bool,
2611        is_selected: bool,
2612        cx: &mut Context<Self>,
2613    ) -> AnyElement {
2614        let path_list = path_list.clone();
2615        let id = SharedString::from(format!("view-more-{}", ix));
2616
2617        let label: SharedString = if is_fully_expanded {
2618            "Collapse".into()
2619        } else {
2620            "View More".into()
2621        };
2622
2623        ThreadItem::new(id, label)
2624            .focused(is_selected)
2625            .icon_visible(false)
2626            .title_label_color(Color::Muted)
2627            .on_click(cx.listener(move |this, _, _window, cx| {
2628                this.selection = None;
2629                if is_fully_expanded {
2630                    this.expanded_groups.remove(&path_list);
2631                } else {
2632                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
2633                    this.expanded_groups.insert(path_list.clone(), current + 1);
2634                }
2635                this.update_entries(cx);
2636            }))
2637            .into_any_element()
2638    }
2639
2640    fn new_thread_in_group(
2641        &mut self,
2642        _: &NewThreadInGroup,
2643        window: &mut Window,
2644        cx: &mut Context<Self>,
2645    ) {
2646        // If there is a keyboard selection, walk backwards through
2647        // `project_header_indices` to find the header that owns the selected
2648        // row. Otherwise fall back to the active workspace.
2649        let workspace = if let Some(selected_ix) = self.selection {
2650            self.contents
2651                .project_header_indices
2652                .iter()
2653                .rev()
2654                .find(|&&header_ix| header_ix <= selected_ix)
2655                .and_then(|&header_ix| match &self.contents.entries[header_ix] {
2656                    ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2657                    _ => None,
2658                })
2659        } else {
2660            // Use the currently active workspace.
2661            self.multi_workspace
2662                .upgrade()
2663                .map(|mw| mw.read(cx).workspace().clone())
2664        };
2665
2666        let Some(workspace) = workspace else {
2667            return;
2668        };
2669
2670        self.create_new_thread(&workspace, window, cx);
2671    }
2672
2673    fn create_new_thread(
2674        &mut self,
2675        workspace: &Entity<Workspace>,
2676        window: &mut Window,
2677        cx: &mut Context<Self>,
2678    ) {
2679        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2680            return;
2681        };
2682
2683        // Clear focused_thread immediately so no existing thread stays
2684        // highlighted while the new blank thread is being shown. Without this,
2685        // if the target workspace is already active (so ActiveWorkspaceChanged
2686        // never fires), the previous thread's highlight would linger.
2687        self.focused_thread = None;
2688
2689        multi_workspace.update(cx, |multi_workspace, cx| {
2690            multi_workspace.activate(workspace.clone(), cx);
2691        });
2692
2693        workspace.update(cx, |workspace, cx| {
2694            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
2695                agent_panel.update(cx, |panel, cx| {
2696                    panel.new_thread(&NewThread, window, cx);
2697                });
2698            }
2699            workspace.focus_panel::<AgentPanel>(window, cx);
2700        });
2701    }
2702
2703    fn render_new_thread(
2704        &self,
2705        ix: usize,
2706        _path_list: &PathList,
2707        workspace: &Entity<Workspace>,
2708        is_active_draft: bool,
2709        is_selected: bool,
2710        cx: &mut Context<Self>,
2711    ) -> AnyElement {
2712        let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft;
2713
2714        let label: SharedString = if is_active {
2715            self.active_draft_text(cx)
2716                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
2717        } else {
2718            DEFAULT_THREAD_TITLE.into()
2719        };
2720
2721        let workspace = workspace.clone();
2722        let id = SharedString::from(format!("new-thread-btn-{}", ix));
2723
2724        let thread_item = ThreadItem::new(id, label)
2725            .icon(IconName::Plus)
2726            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
2727            .selected(is_active)
2728            .focused(is_selected)
2729            .when(!is_active, |this| {
2730                this.on_click(cx.listener(move |this, _, window, cx| {
2731                    this.selection = None;
2732                    this.create_new_thread(&workspace, window, cx);
2733                }))
2734            });
2735
2736        if is_active {
2737            div()
2738                .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
2739                    cx.stop_propagation();
2740                })
2741                .child(thread_item)
2742                .into_any_element()
2743        } else {
2744            thread_item.into_any_element()
2745        }
2746    }
2747
2748    fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
2749        let has_query = self.has_filter_query(cx);
2750        let message = if has_query {
2751            "No threads match your search."
2752        } else {
2753            "No threads yet"
2754        };
2755
2756        v_flex()
2757            .id("sidebar-no-results")
2758            .p_4()
2759            .size_full()
2760            .items_center()
2761            .justify_center()
2762            .child(
2763                Label::new(message)
2764                    .size(LabelSize::Small)
2765                    .color(Color::Muted),
2766            )
2767    }
2768
2769    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2770        v_flex()
2771            .id("sidebar-empty-state")
2772            .p_4()
2773            .size_full()
2774            .items_center()
2775            .justify_center()
2776            .gap_1()
2777            .track_focus(&self.focus_handle(cx))
2778            .child(
2779                Button::new("open_project", "Open Project")
2780                    .full_width()
2781                    .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
2782                    .on_click(|_, window, cx| {
2783                        window.dispatch_action(
2784                            Open {
2785                                create_new_window: false,
2786                            }
2787                            .boxed_clone(),
2788                            cx,
2789                        );
2790                    }),
2791            )
2792            .child(
2793                h_flex()
2794                    .w_1_2()
2795                    .gap_2()
2796                    .child(Divider::horizontal())
2797                    .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
2798                    .child(Divider::horizontal()),
2799            )
2800            .child(
2801                Button::new("clone_repo", "Clone Repository")
2802                    .full_width()
2803                    .on_click(|_, window, cx| {
2804                        window.dispatch_action(git::Clone.boxed_clone(), cx);
2805                    }),
2806            )
2807    }
2808
2809    fn render_sidebar_header(
2810        &self,
2811        no_open_projects: bool,
2812        window: &Window,
2813        cx: &mut Context<Self>,
2814    ) -> impl IntoElement {
2815        let has_query = self.has_filter_query(cx);
2816        let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
2817        let header_height = platform_title_bar_height(window);
2818
2819        h_flex()
2820            .h(header_height)
2821            .mt_px()
2822            .pb_px()
2823            .when(traffic_lights, |this| {
2824                this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
2825            })
2826            .pr_1p5()
2827            .gap_1()
2828            .when(!no_open_projects, |this| {
2829                this.border_b_1()
2830                    .border_color(cx.theme().colors().border)
2831                    .child(Divider::vertical().color(ui::DividerColor::Border))
2832                    .child(
2833                        div().ml_1().child(
2834                            Icon::new(IconName::MagnifyingGlass)
2835                                .size(IconSize::Small)
2836                                .color(Color::Muted),
2837                        ),
2838                    )
2839                    .child(self.render_filter_input(cx))
2840                    .child(
2841                        h_flex()
2842                            .gap_1()
2843                            .when(
2844                                self.selection.is_some()
2845                                    && !self.filter_editor.focus_handle(cx).is_focused(window),
2846                                |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
2847                            )
2848                            .when(has_query, |this| {
2849                                this.child(
2850                                    IconButton::new("clear_filter", IconName::Close)
2851                                        .icon_size(IconSize::Small)
2852                                        .tooltip(Tooltip::text("Clear Search"))
2853                                        .on_click(cx.listener(|this, _, window, cx| {
2854                                            this.reset_filter_editor_text(window, cx);
2855                                            this.update_entries(cx);
2856                                        })),
2857                                )
2858                            }),
2859                    )
2860            })
2861    }
2862
2863    fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
2864        IconButton::new("sidebar-close-toggle", IconName::ThreadsSidebarLeftOpen)
2865            .icon_size(IconSize::Small)
2866            .tooltip(Tooltip::element(move |_window, cx| {
2867                v_flex()
2868                    .gap_1()
2869                    .child(
2870                        h_flex()
2871                            .gap_2()
2872                            .justify_between()
2873                            .child(Label::new("Toggle Sidebar"))
2874                            .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
2875                    )
2876                    .child(
2877                        h_flex()
2878                            .pt_1()
2879                            .gap_2()
2880                            .border_t_1()
2881                            .border_color(cx.theme().colors().border_variant)
2882                            .justify_between()
2883                            .child(Label::new("Focus Sidebar"))
2884                            .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
2885                    )
2886                    .into_any_element()
2887            }))
2888            .on_click(|_, window, cx| {
2889                window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
2890            })
2891    }
2892}
2893
2894impl Sidebar {
2895    fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
2896        match &self.view {
2897            SidebarView::ThreadList => self.show_archive(window, cx),
2898            SidebarView::Archive(_) => self.show_thread_list(window, cx),
2899        }
2900    }
2901
2902    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2903        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
2904            w.read(cx)
2905                .workspaces()
2906                .get(w.read(cx).active_workspace_index())
2907                .cloned()
2908        }) else {
2909            return;
2910        };
2911
2912        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
2913            return;
2914        };
2915
2916        let thread_store = agent_panel.read(cx).thread_store().clone();
2917        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
2918        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
2919        let agent_server_store = active_workspace
2920            .read(cx)
2921            .project()
2922            .read(cx)
2923            .agent_server_store()
2924            .clone();
2925
2926        let archive_view = cx.new(|cx| {
2927            ThreadsArchiveView::new(
2928                agent_connection_store,
2929                agent_server_store,
2930                thread_store,
2931                fs,
2932                window,
2933                cx,
2934            )
2935        });
2936        let subscription = cx.subscribe_in(
2937            &archive_view,
2938            window,
2939            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
2940                ThreadsArchiveViewEvent::Close => {
2941                    this.show_thread_list(window, cx);
2942                }
2943                ThreadsArchiveViewEvent::Unarchive {
2944                    agent,
2945                    session_info,
2946                } => {
2947                    this.show_thread_list(window, cx);
2948                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
2949                }
2950            },
2951        );
2952
2953        self._subscriptions.push(subscription);
2954        self.view = SidebarView::Archive(archive_view.clone());
2955        archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
2956        cx.notify();
2957    }
2958
2959    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2960        self.view = SidebarView::ThreadList;
2961        self._subscriptions.clear();
2962        let handle = self.filter_editor.read(cx).focus_handle(cx);
2963        handle.focus(window, cx);
2964        cx.notify();
2965    }
2966}
2967
2968impl WorkspaceSidebar for Sidebar {
2969    fn width(&self, _cx: &App) -> Pixels {
2970        self.width
2971    }
2972
2973    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
2974        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
2975        cx.notify();
2976    }
2977
2978    fn has_notifications(&self, _cx: &App) -> bool {
2979        !self.contents.notified_threads.is_empty()
2980    }
2981
2982    fn is_threads_list_view_active(&self) -> bool {
2983        matches!(self.view, SidebarView::ThreadList)
2984    }
2985
2986    fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
2987        self.selection = None;
2988        cx.notify();
2989    }
2990}
2991
2992impl Focusable for Sidebar {
2993    fn focus_handle(&self, _cx: &App) -> FocusHandle {
2994        self.focus_handle.clone()
2995    }
2996}
2997
2998impl Render for Sidebar {
2999    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3000        let _titlebar_height = ui::utils::platform_title_bar_height(window);
3001        let ui_font = theme::setup_ui_font(window, cx);
3002        let sticky_header = self.render_sticky_header(window, cx);
3003
3004        let color = cx.theme().colors();
3005        let bg = color
3006            .title_bar_background
3007            .blend(color.panel_background.opacity(0.32));
3008
3009        let no_open_projects = !self.contents.has_open_projects;
3010        let no_search_results = self.contents.entries.is_empty();
3011
3012        v_flex()
3013            .id("workspace-sidebar")
3014            .key_context("ThreadsSidebar")
3015            .track_focus(&self.focus_handle)
3016            .on_action(cx.listener(Self::select_next))
3017            .on_action(cx.listener(Self::select_previous))
3018            .on_action(cx.listener(Self::editor_move_down))
3019            .on_action(cx.listener(Self::editor_move_up))
3020            .on_action(cx.listener(Self::select_first))
3021            .on_action(cx.listener(Self::select_last))
3022            .on_action(cx.listener(Self::confirm))
3023            .on_action(cx.listener(Self::expand_selected_entry))
3024            .on_action(cx.listener(Self::collapse_selected_entry))
3025            .on_action(cx.listener(Self::toggle_selected_fold))
3026            .on_action(cx.listener(Self::fold_all))
3027            .on_action(cx.listener(Self::unfold_all))
3028            .on_action(cx.listener(Self::cancel))
3029            .on_action(cx.listener(Self::remove_selected_thread))
3030            .on_action(cx.listener(Self::new_thread_in_group))
3031            .on_action(cx.listener(Self::toggle_archive))
3032            .on_action(cx.listener(Self::focus_sidebar_filter))
3033            .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
3034                this.recent_projects_popover_handle.toggle(window, cx);
3035            }))
3036            .font(ui_font)
3037            .h_full()
3038            .w(self.width)
3039            .bg(bg)
3040            .border_r_1()
3041            .border_color(color.border)
3042            .map(|this| match &self.view {
3043                SidebarView::ThreadList => this
3044                    .child(self.render_sidebar_header(no_open_projects, window, cx))
3045                    .map(|this| {
3046                        if no_open_projects {
3047                            this.child(self.render_empty_state(cx))
3048                        } else {
3049                            this.child(
3050                                v_flex()
3051                                    .relative()
3052                                    .flex_1()
3053                                    .overflow_hidden()
3054                                    .child(
3055                                        list(
3056                                            self.list_state.clone(),
3057                                            cx.processor(Self::render_list_entry),
3058                                        )
3059                                        .flex_1()
3060                                        .size_full(),
3061                                    )
3062                                    .when(no_search_results, |this| {
3063                                        this.child(self.render_no_results(cx))
3064                                    })
3065                                    .when_some(sticky_header, |this, header| this.child(header))
3066                                    .vertical_scrollbar_for(&self.list_state, window, cx),
3067                            )
3068                        }
3069                    }),
3070                SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
3071            })
3072            .child(
3073                h_flex()
3074                    .p_1()
3075                    .gap_1()
3076                    .justify_between()
3077                    .border_t_1()
3078                    .border_color(cx.theme().colors().border)
3079                    .child(self.render_sidebar_toggle_button(cx))
3080                    .child(
3081                        h_flex()
3082                            .gap_1()
3083                            .child(self.render_recent_projects_button(cx))
3084                            .child(
3085                                IconButton::new("archive", IconName::Archive)
3086                                    .icon_size(IconSize::Small)
3087                                    .toggle_state(matches!(self.view, SidebarView::Archive(..)))
3088                                    .tooltip(move |_, cx| {
3089                                        Tooltip::for_action(
3090                                            "Toggle Archived Threads",
3091                                            &ToggleArchive,
3092                                            cx,
3093                                        )
3094                                    })
3095                                    .on_click(cx.listener(|this, _, window, cx| {
3096                                        this.toggle_archive(&ToggleArchive, window, cx);
3097                                    })),
3098                            ),
3099                    ),
3100            )
3101    }
3102}
3103
3104#[cfg(test)]
3105mod tests {
3106    use super::*;
3107    use acp_thread::StubAgentConnection;
3108    use agent::ThreadStore;
3109    use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
3110    use assistant_text_thread::TextThreadStore;
3111    use chrono::DateTime;
3112    use feature_flags::FeatureFlagAppExt as _;
3113    use fs::FakeFs;
3114    use gpui::TestAppContext;
3115    use pretty_assertions::assert_eq;
3116    use settings::SettingsStore;
3117    use std::{path::PathBuf, sync::Arc};
3118    use util::path_list::PathList;
3119
3120    fn init_test(cx: &mut TestAppContext) {
3121        cx.update(|cx| {
3122            let settings_store = SettingsStore::test(cx);
3123            cx.set_global(settings_store);
3124            theme::init(theme::LoadThemes::JustBase, cx);
3125            editor::init(cx);
3126            cx.update_flags(false, vec!["agent-v2".into()]);
3127            ThreadStore::init_global(cx);
3128            SidebarThreadMetadataStore::init_global(cx);
3129            language_model::LanguageModelRegistry::test(cx);
3130            prompt_store::init(cx);
3131        });
3132    }
3133
3134    fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
3135        sidebar.contents.entries.iter().any(|entry| {
3136            matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
3137        })
3138    }
3139
3140    async fn init_test_project(
3141        worktree_path: &str,
3142        cx: &mut TestAppContext,
3143    ) -> Entity<project::Project> {
3144        init_test(cx);
3145        let fs = FakeFs::new(cx.executor());
3146        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
3147            .await;
3148        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3149        project::Project::test(fs, [worktree_path.as_ref()], cx).await
3150    }
3151
3152    fn setup_sidebar(
3153        multi_workspace: &Entity<MultiWorkspace>,
3154        cx: &mut gpui::VisualTestContext,
3155    ) -> Entity<Sidebar> {
3156        let multi_workspace = multi_workspace.clone();
3157        let sidebar =
3158            cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
3159        multi_workspace.update(cx, |mw, cx| {
3160            mw.register_sidebar(sidebar.clone(), cx);
3161        });
3162        cx.run_until_parked();
3163        sidebar
3164    }
3165
3166    async fn save_n_test_threads(
3167        count: u32,
3168        path_list: &PathList,
3169        cx: &mut gpui::VisualTestContext,
3170    ) {
3171        for i in 0..count {
3172            save_thread_metadata(
3173                acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3174                format!("Thread {}", i + 1).into(),
3175                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3176                path_list.clone(),
3177                cx,
3178            )
3179            .await;
3180        }
3181        cx.run_until_parked();
3182    }
3183
3184    async fn save_test_thread_metadata(
3185        session_id: &acp::SessionId,
3186        path_list: PathList,
3187        cx: &mut TestAppContext,
3188    ) {
3189        save_thread_metadata(
3190            session_id.clone(),
3191            "Test".into(),
3192            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3193            path_list,
3194            cx,
3195        )
3196        .await;
3197    }
3198
3199    async fn save_named_thread_metadata(
3200        session_id: &str,
3201        title: &str,
3202        path_list: &PathList,
3203        cx: &mut gpui::VisualTestContext,
3204    ) {
3205        save_thread_metadata(
3206            acp::SessionId::new(Arc::from(session_id)),
3207            SharedString::from(title.to_string()),
3208            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3209            path_list.clone(),
3210            cx,
3211        )
3212        .await;
3213        cx.run_until_parked();
3214    }
3215
3216    async fn save_thread_metadata(
3217        session_id: acp::SessionId,
3218        title: SharedString,
3219        updated_at: DateTime<Utc>,
3220        path_list: PathList,
3221        cx: &mut TestAppContext,
3222    ) {
3223        let metadata = ThreadMetadata {
3224            session_id,
3225            agent_id: None,
3226            title,
3227            updated_at,
3228            created_at: None,
3229            folder_paths: path_list,
3230        };
3231        let task = cx.update(|cx| {
3232            SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
3233        });
3234        task.await.unwrap();
3235    }
3236
3237    fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
3238        let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade());
3239        if let Some(multi_workspace) = multi_workspace {
3240            multi_workspace.update_in(cx, |mw, window, cx| {
3241                if !mw.sidebar_open() {
3242                    mw.toggle_sidebar(window, cx);
3243                }
3244            });
3245        }
3246        cx.run_until_parked();
3247        sidebar.update_in(cx, |_, window, cx| {
3248            cx.focus_self(window);
3249        });
3250        cx.run_until_parked();
3251    }
3252
3253    fn visible_entries_as_strings(
3254        sidebar: &Entity<Sidebar>,
3255        cx: &mut gpui::VisualTestContext,
3256    ) -> Vec<String> {
3257        sidebar.read_with(cx, |sidebar, _cx| {
3258            sidebar
3259                .contents
3260                .entries
3261                .iter()
3262                .enumerate()
3263                .map(|(ix, entry)| {
3264                    let selected = if sidebar.selection == Some(ix) {
3265                        "  <== selected"
3266                    } else {
3267                        ""
3268                    };
3269                    match entry {
3270                        ListEntry::ProjectHeader {
3271                            label,
3272                            path_list,
3273                            highlight_positions: _,
3274                            ..
3275                        } => {
3276                            let icon = if sidebar.collapsed_groups.contains(path_list) {
3277                                ">"
3278                            } else {
3279                                "v"
3280                            };
3281                            format!("{} [{}]{}", icon, label, selected)
3282                        }
3283                        ListEntry::Thread(thread) => {
3284                            let title = thread
3285                                .session_info
3286                                .title
3287                                .as_ref()
3288                                .map(|s| s.as_ref())
3289                                .unwrap_or("Untitled");
3290                            let active = if thread.is_live { " *" } else { "" };
3291                            let status_str = match thread.status {
3292                                AgentThreadStatus::Running => " (running)",
3293                                AgentThreadStatus::Error => " (error)",
3294                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
3295                                _ => "",
3296                            };
3297                            let notified = if sidebar
3298                                .contents
3299                                .is_thread_notified(&thread.session_info.session_id)
3300                            {
3301                                " (!)"
3302                            } else {
3303                                ""
3304                            };
3305                            let worktree = thread
3306                                .worktree_name
3307                                .as_ref()
3308                                .map(|name| format!(" {{{}}}", name))
3309                                .unwrap_or_default();
3310                            format!(
3311                                "  {}{}{}{}{}{}",
3312                                title, worktree, active, status_str, notified, selected
3313                            )
3314                        }
3315                        ListEntry::ViewMore {
3316                            is_fully_expanded, ..
3317                        } => {
3318                            if *is_fully_expanded {
3319                                format!("  - Collapse{}", selected)
3320                            } else {
3321                                format!("  + View More{}", selected)
3322                            }
3323                        }
3324                        ListEntry::NewThread { .. } => {
3325                            format!("  [+ New Thread]{}", selected)
3326                        }
3327                    }
3328                })
3329                .collect()
3330        })
3331    }
3332
3333    #[test]
3334    fn test_clean_mention_links() {
3335        // Simple mention link
3336        assert_eq!(
3337            Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
3338            "check @Button.tsx"
3339        );
3340
3341        // Multiple mention links
3342        assert_eq!(
3343            Sidebar::clean_mention_links(
3344                "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
3345            ),
3346            "look at @foo.rs and @bar.rs"
3347        );
3348
3349        // No mention links — passthrough
3350        assert_eq!(
3351            Sidebar::clean_mention_links("plain text with no mentions"),
3352            "plain text with no mentions"
3353        );
3354
3355        // Incomplete link syntax — preserved as-is
3356        assert_eq!(
3357            Sidebar::clean_mention_links("broken [@mention without closing"),
3358            "broken [@mention without closing"
3359        );
3360
3361        // Regular markdown link (no @) — not touched
3362        assert_eq!(
3363            Sidebar::clean_mention_links("see [docs](https://example.com)"),
3364            "see [docs](https://example.com)"
3365        );
3366
3367        // Empty input
3368        assert_eq!(Sidebar::clean_mention_links(""), "");
3369    }
3370
3371    #[gpui::test]
3372    async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
3373        let project = init_test_project("/my-project", cx).await;
3374        let (multi_workspace, cx) =
3375            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3376        let sidebar = setup_sidebar(&multi_workspace, cx);
3377
3378        let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
3379        let weak_sidebar = sidebar.downgrade();
3380        let weak_multi_workspace = multi_workspace.downgrade();
3381
3382        drop(sidebar);
3383        drop(multi_workspace);
3384        cx.update(|window, _cx| window.remove_window());
3385        cx.run_until_parked();
3386
3387        weak_multi_workspace.assert_released();
3388        weak_sidebar.assert_released();
3389        weak_workspace.assert_released();
3390    }
3391
3392    #[gpui::test]
3393    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
3394        let project = init_test_project("/my-project", cx).await;
3395        let (multi_workspace, cx) =
3396            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3397        let sidebar = setup_sidebar(&multi_workspace, cx);
3398
3399        assert_eq!(
3400            visible_entries_as_strings(&sidebar, cx),
3401            vec!["v [my-project]", "  [+ New Thread]"]
3402        );
3403    }
3404
3405    #[gpui::test]
3406    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
3407        let project = init_test_project("/my-project", cx).await;
3408        let (multi_workspace, cx) =
3409            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3410        let sidebar = setup_sidebar(&multi_workspace, cx);
3411
3412        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3413
3414        save_thread_metadata(
3415            acp::SessionId::new(Arc::from("thread-1")),
3416            "Fix crash in project panel".into(),
3417            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
3418            path_list.clone(),
3419            cx,
3420        )
3421        .await;
3422
3423        save_thread_metadata(
3424            acp::SessionId::new(Arc::from("thread-2")),
3425            "Add inline diff view".into(),
3426            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3427            path_list.clone(),
3428            cx,
3429        )
3430        .await;
3431        cx.run_until_parked();
3432
3433        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3434        cx.run_until_parked();
3435
3436        assert_eq!(
3437            visible_entries_as_strings(&sidebar, cx),
3438            vec![
3439                "v [my-project]",
3440                "  Fix crash in project panel",
3441                "  Add inline diff view",
3442            ]
3443        );
3444    }
3445
3446    #[gpui::test]
3447    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
3448        let project = init_test_project("/project-a", cx).await;
3449        let (multi_workspace, cx) =
3450            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3451        let sidebar = setup_sidebar(&multi_workspace, cx);
3452
3453        // Single workspace with a thread
3454        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3455
3456        save_thread_metadata(
3457            acp::SessionId::new(Arc::from("thread-a1")),
3458            "Thread A1".into(),
3459            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3460            path_list.clone(),
3461            cx,
3462        )
3463        .await;
3464        cx.run_until_parked();
3465
3466        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3467        cx.run_until_parked();
3468
3469        assert_eq!(
3470            visible_entries_as_strings(&sidebar, cx),
3471            vec!["v [project-a]", "  Thread A1"]
3472        );
3473
3474        // Add a second workspace
3475        multi_workspace.update_in(cx, |mw, window, cx| {
3476            mw.create_test_workspace(window, cx).detach();
3477        });
3478        cx.run_until_parked();
3479
3480        assert_eq!(
3481            visible_entries_as_strings(&sidebar, cx),
3482            vec!["v [project-a]", "  Thread A1",]
3483        );
3484
3485        // Remove the second workspace
3486        multi_workspace.update_in(cx, |mw, window, cx| {
3487            mw.remove_workspace(1, window, cx);
3488        });
3489        cx.run_until_parked();
3490
3491        assert_eq!(
3492            visible_entries_as_strings(&sidebar, cx),
3493            vec!["v [project-a]", "  Thread A1"]
3494        );
3495    }
3496
3497    #[gpui::test]
3498    async fn test_view_more_pagination(cx: &mut TestAppContext) {
3499        let project = init_test_project("/my-project", cx).await;
3500        let (multi_workspace, cx) =
3501            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3502        let sidebar = setup_sidebar(&multi_workspace, cx);
3503
3504        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3505        save_n_test_threads(12, &path_list, cx).await;
3506
3507        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3508        cx.run_until_parked();
3509
3510        assert_eq!(
3511            visible_entries_as_strings(&sidebar, cx),
3512            vec![
3513                "v [my-project]",
3514                "  Thread 12",
3515                "  Thread 11",
3516                "  Thread 10",
3517                "  Thread 9",
3518                "  Thread 8",
3519                "  + View More",
3520            ]
3521        );
3522    }
3523
3524    #[gpui::test]
3525    async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
3526        let project = init_test_project("/my-project", cx).await;
3527        let (multi_workspace, cx) =
3528            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3529        let sidebar = setup_sidebar(&multi_workspace, cx);
3530
3531        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3532        // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
3533        save_n_test_threads(17, &path_list, cx).await;
3534
3535        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3536        cx.run_until_parked();
3537
3538        // Initially shows 5 threads + View More
3539        let entries = visible_entries_as_strings(&sidebar, cx);
3540        assert_eq!(entries.len(), 7); // header + 5 threads + View More
3541        assert!(entries.iter().any(|e| e.contains("View More")));
3542
3543        // Focus and navigate to View More, then confirm to expand by one batch
3544        open_and_focus_sidebar(&sidebar, cx);
3545        for _ in 0..7 {
3546            cx.dispatch_action(SelectNext);
3547        }
3548        cx.dispatch_action(Confirm);
3549        cx.run_until_parked();
3550
3551        // Now shows 10 threads + View More
3552        let entries = visible_entries_as_strings(&sidebar, cx);
3553        assert_eq!(entries.len(), 12); // header + 10 threads + View More
3554        assert!(entries.iter().any(|e| e.contains("View More")));
3555
3556        // Expand again by one batch
3557        sidebar.update_in(cx, |s, _window, cx| {
3558            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
3559            s.expanded_groups.insert(path_list.clone(), current + 1);
3560            s.update_entries(cx);
3561        });
3562        cx.run_until_parked();
3563
3564        // Now shows 15 threads + View More
3565        let entries = visible_entries_as_strings(&sidebar, cx);
3566        assert_eq!(entries.len(), 17); // header + 15 threads + View More
3567        assert!(entries.iter().any(|e| e.contains("View More")));
3568
3569        // Expand one more time - should show all 17 threads with Collapse button
3570        sidebar.update_in(cx, |s, _window, cx| {
3571            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
3572            s.expanded_groups.insert(path_list.clone(), current + 1);
3573            s.update_entries(cx);
3574        });
3575        cx.run_until_parked();
3576
3577        // All 17 threads shown with Collapse button
3578        let entries = visible_entries_as_strings(&sidebar, cx);
3579        assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
3580        assert!(!entries.iter().any(|e| e.contains("View More")));
3581        assert!(entries.iter().any(|e| e.contains("Collapse")));
3582
3583        // Click collapse - should go back to showing 5 threads
3584        sidebar.update_in(cx, |s, _window, cx| {
3585            s.expanded_groups.remove(&path_list);
3586            s.update_entries(cx);
3587        });
3588        cx.run_until_parked();
3589
3590        // Back to initial state: 5 threads + View More
3591        let entries = visible_entries_as_strings(&sidebar, cx);
3592        assert_eq!(entries.len(), 7); // header + 5 threads + View More
3593        assert!(entries.iter().any(|e| e.contains("View More")));
3594    }
3595
3596    #[gpui::test]
3597    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
3598        let project = init_test_project("/my-project", cx).await;
3599        let (multi_workspace, cx) =
3600            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3601        let sidebar = setup_sidebar(&multi_workspace, cx);
3602
3603        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3604        save_n_test_threads(1, &path_list, cx).await;
3605
3606        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3607        cx.run_until_parked();
3608
3609        assert_eq!(
3610            visible_entries_as_strings(&sidebar, cx),
3611            vec!["v [my-project]", "  Thread 1"]
3612        );
3613
3614        // Collapse
3615        sidebar.update_in(cx, |s, window, cx| {
3616            s.toggle_collapse(&path_list, window, cx);
3617        });
3618        cx.run_until_parked();
3619
3620        assert_eq!(
3621            visible_entries_as_strings(&sidebar, cx),
3622            vec!["> [my-project]"]
3623        );
3624
3625        // Expand
3626        sidebar.update_in(cx, |s, window, cx| {
3627            s.toggle_collapse(&path_list, window, cx);
3628        });
3629        cx.run_until_parked();
3630
3631        assert_eq!(
3632            visible_entries_as_strings(&sidebar, cx),
3633            vec!["v [my-project]", "  Thread 1"]
3634        );
3635    }
3636
3637    #[gpui::test]
3638    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
3639        let project = init_test_project("/my-project", cx).await;
3640        let (multi_workspace, cx) =
3641            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3642        let sidebar = setup_sidebar(&multi_workspace, cx);
3643
3644        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3645        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
3646        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
3647
3648        sidebar.update_in(cx, |s, _window, _cx| {
3649            s.collapsed_groups.insert(collapsed_path.clone());
3650            s.contents
3651                .notified_threads
3652                .insert(acp::SessionId::new(Arc::from("t-5")));
3653            s.contents.entries = vec![
3654                // Expanded project header
3655                ListEntry::ProjectHeader {
3656                    path_list: expanded_path.clone(),
3657                    label: "expanded-project".into(),
3658                    workspace: workspace.clone(),
3659                    highlight_positions: Vec::new(),
3660                    has_running_threads: false,
3661                    waiting_thread_count: 0,
3662                },
3663                ListEntry::Thread(ThreadEntry {
3664                    agent: Agent::NativeAgent,
3665                    session_info: acp_thread::AgentSessionInfo {
3666                        session_id: acp::SessionId::new(Arc::from("t-1")),
3667                        work_dirs: None,
3668                        title: Some("Completed thread".into()),
3669                        updated_at: Some(Utc::now()),
3670                        created_at: Some(Utc::now()),
3671                        meta: None,
3672                    },
3673                    icon: IconName::ZedAgent,
3674                    icon_from_external_svg: None,
3675                    status: AgentThreadStatus::Completed,
3676                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3677                    is_live: false,
3678                    is_background: false,
3679                    is_title_generating: false,
3680                    highlight_positions: Vec::new(),
3681                    worktree_name: None,
3682                    worktree_full_path: None,
3683                    worktree_highlight_positions: Vec::new(),
3684                    diff_stats: DiffStats::default(),
3685                }),
3686                // Active thread with Running status
3687                ListEntry::Thread(ThreadEntry {
3688                    agent: Agent::NativeAgent,
3689                    session_info: acp_thread::AgentSessionInfo {
3690                        session_id: acp::SessionId::new(Arc::from("t-2")),
3691                        work_dirs: None,
3692                        title: Some("Running thread".into()),
3693                        updated_at: Some(Utc::now()),
3694                        created_at: Some(Utc::now()),
3695                        meta: None,
3696                    },
3697                    icon: IconName::ZedAgent,
3698                    icon_from_external_svg: None,
3699                    status: AgentThreadStatus::Running,
3700                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3701                    is_live: true,
3702                    is_background: false,
3703                    is_title_generating: false,
3704                    highlight_positions: Vec::new(),
3705                    worktree_name: None,
3706                    worktree_full_path: None,
3707                    worktree_highlight_positions: Vec::new(),
3708                    diff_stats: DiffStats::default(),
3709                }),
3710                // Active thread with Error status
3711                ListEntry::Thread(ThreadEntry {
3712                    agent: Agent::NativeAgent,
3713                    session_info: acp_thread::AgentSessionInfo {
3714                        session_id: acp::SessionId::new(Arc::from("t-3")),
3715                        work_dirs: None,
3716                        title: Some("Error thread".into()),
3717                        updated_at: Some(Utc::now()),
3718                        created_at: Some(Utc::now()),
3719                        meta: None,
3720                    },
3721                    icon: IconName::ZedAgent,
3722                    icon_from_external_svg: None,
3723                    status: AgentThreadStatus::Error,
3724                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3725                    is_live: true,
3726                    is_background: false,
3727                    is_title_generating: false,
3728                    highlight_positions: Vec::new(),
3729                    worktree_name: None,
3730                    worktree_full_path: None,
3731                    worktree_highlight_positions: Vec::new(),
3732                    diff_stats: DiffStats::default(),
3733                }),
3734                // Thread with WaitingForConfirmation status, not active
3735                ListEntry::Thread(ThreadEntry {
3736                    agent: Agent::NativeAgent,
3737                    session_info: acp_thread::AgentSessionInfo {
3738                        session_id: acp::SessionId::new(Arc::from("t-4")),
3739                        work_dirs: None,
3740                        title: Some("Waiting thread".into()),
3741                        updated_at: Some(Utc::now()),
3742                        created_at: Some(Utc::now()),
3743                        meta: None,
3744                    },
3745                    icon: IconName::ZedAgent,
3746                    icon_from_external_svg: None,
3747                    status: AgentThreadStatus::WaitingForConfirmation,
3748                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3749                    is_live: false,
3750                    is_background: false,
3751                    is_title_generating: false,
3752                    highlight_positions: Vec::new(),
3753                    worktree_name: None,
3754                    worktree_full_path: None,
3755                    worktree_highlight_positions: Vec::new(),
3756                    diff_stats: DiffStats::default(),
3757                }),
3758                // Background thread that completed (should show notification)
3759                ListEntry::Thread(ThreadEntry {
3760                    agent: Agent::NativeAgent,
3761                    session_info: acp_thread::AgentSessionInfo {
3762                        session_id: acp::SessionId::new(Arc::from("t-5")),
3763                        work_dirs: None,
3764                        title: Some("Notified thread".into()),
3765                        updated_at: Some(Utc::now()),
3766                        created_at: Some(Utc::now()),
3767                        meta: None,
3768                    },
3769                    icon: IconName::ZedAgent,
3770                    icon_from_external_svg: None,
3771                    status: AgentThreadStatus::Completed,
3772                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3773                    is_live: true,
3774                    is_background: true,
3775                    is_title_generating: false,
3776                    highlight_positions: Vec::new(),
3777                    worktree_name: None,
3778                    worktree_full_path: None,
3779                    worktree_highlight_positions: Vec::new(),
3780                    diff_stats: DiffStats::default(),
3781                }),
3782                // View More entry
3783                ListEntry::ViewMore {
3784                    path_list: expanded_path.clone(),
3785                    is_fully_expanded: false,
3786                },
3787                // Collapsed project header
3788                ListEntry::ProjectHeader {
3789                    path_list: collapsed_path.clone(),
3790                    label: "collapsed-project".into(),
3791                    workspace: workspace.clone(),
3792                    highlight_positions: Vec::new(),
3793                    has_running_threads: false,
3794                    waiting_thread_count: 0,
3795                },
3796            ];
3797
3798            // Select the Running thread (index 2)
3799            s.selection = Some(2);
3800        });
3801
3802        assert_eq!(
3803            visible_entries_as_strings(&sidebar, cx),
3804            vec![
3805                "v [expanded-project]",
3806                "  Completed thread",
3807                "  Running thread * (running)  <== selected",
3808                "  Error thread * (error)",
3809                "  Waiting thread (waiting)",
3810                "  Notified thread * (!)",
3811                "  + View More",
3812                "> [collapsed-project]",
3813            ]
3814        );
3815
3816        // Move selection to the collapsed header
3817        sidebar.update_in(cx, |s, _window, _cx| {
3818            s.selection = Some(7);
3819        });
3820
3821        assert_eq!(
3822            visible_entries_as_strings(&sidebar, cx).last().cloned(),
3823            Some("> [collapsed-project]  <== selected".to_string()),
3824        );
3825
3826        // Clear selection
3827        sidebar.update_in(cx, |s, _window, _cx| {
3828            s.selection = None;
3829        });
3830
3831        // No entry should have the selected marker
3832        let entries = visible_entries_as_strings(&sidebar, cx);
3833        for entry in &entries {
3834            assert!(
3835                !entry.contains("<== selected"),
3836                "unexpected selection marker in: {}",
3837                entry
3838            );
3839        }
3840    }
3841
3842    #[gpui::test]
3843    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
3844        let project = init_test_project("/my-project", cx).await;
3845        let (multi_workspace, cx) =
3846            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3847        let sidebar = setup_sidebar(&multi_workspace, cx);
3848
3849        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3850        save_n_test_threads(3, &path_list, cx).await;
3851
3852        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3853        cx.run_until_parked();
3854
3855        // Entries: [header, thread3, thread2, thread1]
3856        // Focusing the sidebar does not set a selection; select_next/select_previous
3857        // handle None gracefully by starting from the first or last entry.
3858        open_and_focus_sidebar(&sidebar, cx);
3859        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3860
3861        // First SelectNext from None starts at index 0
3862        cx.dispatch_action(SelectNext);
3863        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3864
3865        // Move down through remaining entries
3866        cx.dispatch_action(SelectNext);
3867        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3868
3869        cx.dispatch_action(SelectNext);
3870        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
3871
3872        cx.dispatch_action(SelectNext);
3873        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
3874
3875        // At the end, wraps back to first entry
3876        cx.dispatch_action(SelectNext);
3877        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3878
3879        // Navigate back to the end
3880        cx.dispatch_action(SelectNext);
3881        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3882        cx.dispatch_action(SelectNext);
3883        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
3884        cx.dispatch_action(SelectNext);
3885        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
3886
3887        // Move back up
3888        cx.dispatch_action(SelectPrevious);
3889        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
3890
3891        cx.dispatch_action(SelectPrevious);
3892        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3893
3894        cx.dispatch_action(SelectPrevious);
3895        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3896
3897        // At the top, selection clears (focus returns to editor)
3898        cx.dispatch_action(SelectPrevious);
3899        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3900    }
3901
3902    #[gpui::test]
3903    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
3904        let project = init_test_project("/my-project", cx).await;
3905        let (multi_workspace, cx) =
3906            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3907        let sidebar = setup_sidebar(&multi_workspace, cx);
3908
3909        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3910        save_n_test_threads(3, &path_list, cx).await;
3911        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3912        cx.run_until_parked();
3913
3914        open_and_focus_sidebar(&sidebar, cx);
3915
3916        // SelectLast jumps to the end
3917        cx.dispatch_action(SelectLast);
3918        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
3919
3920        // SelectFirst jumps to the beginning
3921        cx.dispatch_action(SelectFirst);
3922        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3923    }
3924
3925    #[gpui::test]
3926    async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
3927        let project = init_test_project("/my-project", cx).await;
3928        let (multi_workspace, cx) =
3929            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3930        let sidebar = setup_sidebar(&multi_workspace, cx);
3931
3932        // Initially no selection
3933        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3934
3935        // Open the sidebar so it's rendered, then focus it to trigger focus_in.
3936        // focus_in no longer sets a default selection.
3937        open_and_focus_sidebar(&sidebar, cx);
3938        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3939
3940        // Manually set a selection, blur, then refocus — selection should be preserved
3941        sidebar.update_in(cx, |sidebar, _window, _cx| {
3942            sidebar.selection = Some(0);
3943        });
3944
3945        cx.update(|window, _cx| {
3946            window.blur();
3947        });
3948        cx.run_until_parked();
3949
3950        sidebar.update_in(cx, |_, window, cx| {
3951            cx.focus_self(window);
3952        });
3953        cx.run_until_parked();
3954        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3955    }
3956
3957    #[gpui::test]
3958    async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
3959        let project = init_test_project("/my-project", cx).await;
3960        let (multi_workspace, cx) =
3961            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3962        let sidebar = setup_sidebar(&multi_workspace, cx);
3963
3964        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3965        save_n_test_threads(1, &path_list, cx).await;
3966        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3967        cx.run_until_parked();
3968
3969        assert_eq!(
3970            visible_entries_as_strings(&sidebar, cx),
3971            vec!["v [my-project]", "  Thread 1"]
3972        );
3973
3974        // Focus the sidebar and select the header (index 0)
3975        open_and_focus_sidebar(&sidebar, cx);
3976        sidebar.update_in(cx, |sidebar, _window, _cx| {
3977            sidebar.selection = Some(0);
3978        });
3979
3980        // Confirm on project header collapses the group
3981        cx.dispatch_action(Confirm);
3982        cx.run_until_parked();
3983
3984        assert_eq!(
3985            visible_entries_as_strings(&sidebar, cx),
3986            vec!["> [my-project]  <== selected"]
3987        );
3988
3989        // Confirm again expands the group
3990        cx.dispatch_action(Confirm);
3991        cx.run_until_parked();
3992
3993        assert_eq!(
3994            visible_entries_as_strings(&sidebar, cx),
3995            vec!["v [my-project]  <== selected", "  Thread 1",]
3996        );
3997    }
3998
3999    #[gpui::test]
4000    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
4001        let project = init_test_project("/my-project", cx).await;
4002        let (multi_workspace, cx) =
4003            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4004        let sidebar = setup_sidebar(&multi_workspace, cx);
4005
4006        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4007        save_n_test_threads(8, &path_list, cx).await;
4008        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4009        cx.run_until_parked();
4010
4011        // Should show header + 5 threads + "View More"
4012        let entries = visible_entries_as_strings(&sidebar, cx);
4013        assert_eq!(entries.len(), 7);
4014        assert!(entries.iter().any(|e| e.contains("View More")));
4015
4016        // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
4017        open_and_focus_sidebar(&sidebar, cx);
4018        for _ in 0..7 {
4019            cx.dispatch_action(SelectNext);
4020        }
4021        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
4022
4023        // Confirm on "View More" to expand
4024        cx.dispatch_action(Confirm);
4025        cx.run_until_parked();
4026
4027        // All 8 threads should now be visible with a "Collapse" button
4028        let entries = visible_entries_as_strings(&sidebar, cx);
4029        assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
4030        assert!(!entries.iter().any(|e| e.contains("View More")));
4031        assert!(entries.iter().any(|e| e.contains("Collapse")));
4032    }
4033
4034    #[gpui::test]
4035    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
4036        let project = init_test_project("/my-project", cx).await;
4037        let (multi_workspace, cx) =
4038            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4039        let sidebar = setup_sidebar(&multi_workspace, cx);
4040
4041        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4042        save_n_test_threads(1, &path_list, cx).await;
4043        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4044        cx.run_until_parked();
4045
4046        assert_eq!(
4047            visible_entries_as_strings(&sidebar, cx),
4048            vec!["v [my-project]", "  Thread 1"]
4049        );
4050
4051        // Focus sidebar and manually select the header (index 0). Press left to collapse.
4052        open_and_focus_sidebar(&sidebar, cx);
4053        sidebar.update_in(cx, |sidebar, _window, _cx| {
4054            sidebar.selection = Some(0);
4055        });
4056
4057        cx.dispatch_action(SelectParent);
4058        cx.run_until_parked();
4059
4060        assert_eq!(
4061            visible_entries_as_strings(&sidebar, cx),
4062            vec!["> [my-project]  <== selected"]
4063        );
4064
4065        // Press right to expand
4066        cx.dispatch_action(SelectChild);
4067        cx.run_until_parked();
4068
4069        assert_eq!(
4070            visible_entries_as_strings(&sidebar, cx),
4071            vec!["v [my-project]  <== selected", "  Thread 1",]
4072        );
4073
4074        // Press right again on already-expanded header moves selection down
4075        cx.dispatch_action(SelectChild);
4076        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4077    }
4078
4079    #[gpui::test]
4080    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
4081        let project = init_test_project("/my-project", cx).await;
4082        let (multi_workspace, cx) =
4083            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4084        let sidebar = setup_sidebar(&multi_workspace, cx);
4085
4086        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4087        save_n_test_threads(1, &path_list, cx).await;
4088        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4089        cx.run_until_parked();
4090
4091        // Focus sidebar (selection starts at None), then navigate down to the thread (child)
4092        open_and_focus_sidebar(&sidebar, cx);
4093        cx.dispatch_action(SelectNext);
4094        cx.dispatch_action(SelectNext);
4095        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4096
4097        assert_eq!(
4098            visible_entries_as_strings(&sidebar, cx),
4099            vec!["v [my-project]", "  Thread 1  <== selected",]
4100        );
4101
4102        // Pressing left on a child collapses the parent group and selects it
4103        cx.dispatch_action(SelectParent);
4104        cx.run_until_parked();
4105
4106        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4107        assert_eq!(
4108            visible_entries_as_strings(&sidebar, cx),
4109            vec!["> [my-project]  <== selected"]
4110        );
4111    }
4112
4113    #[gpui::test]
4114    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
4115        let project = init_test_project("/empty-project", cx).await;
4116        let (multi_workspace, cx) =
4117            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4118        let sidebar = setup_sidebar(&multi_workspace, cx);
4119
4120        // An empty project has the header and a new thread button.
4121        assert_eq!(
4122            visible_entries_as_strings(&sidebar, cx),
4123            vec!["v [empty-project]", "  [+ New Thread]"]
4124        );
4125
4126        // Focus sidebar — focus_in does not set a selection
4127        open_and_focus_sidebar(&sidebar, cx);
4128        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4129
4130        // First SelectNext from None starts at index 0 (header)
4131        cx.dispatch_action(SelectNext);
4132        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4133
4134        // SelectNext moves to the new thread button
4135        cx.dispatch_action(SelectNext);
4136        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4137
4138        // At the end, wraps back to first entry
4139        cx.dispatch_action(SelectNext);
4140        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4141
4142        // SelectPrevious from first entry clears selection (returns to editor)
4143        cx.dispatch_action(SelectPrevious);
4144        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4145    }
4146
4147    #[gpui::test]
4148    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
4149        let project = init_test_project("/my-project", cx).await;
4150        let (multi_workspace, cx) =
4151            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4152        let sidebar = setup_sidebar(&multi_workspace, cx);
4153
4154        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4155        save_n_test_threads(1, &path_list, cx).await;
4156        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4157        cx.run_until_parked();
4158
4159        // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
4160        open_and_focus_sidebar(&sidebar, cx);
4161        cx.dispatch_action(SelectNext);
4162        cx.dispatch_action(SelectNext);
4163        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4164
4165        // Collapse the group, which removes the thread from the list
4166        cx.dispatch_action(SelectParent);
4167        cx.run_until_parked();
4168
4169        // Selection should be clamped to the last valid index (0 = header)
4170        let selection = sidebar.read_with(cx, |s, _| s.selection);
4171        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
4172        assert!(
4173            selection.unwrap_or(0) < entry_count,
4174            "selection {} should be within bounds (entries: {})",
4175            selection.unwrap_or(0),
4176            entry_count,
4177        );
4178    }
4179
4180    async fn init_test_project_with_agent_panel(
4181        worktree_path: &str,
4182        cx: &mut TestAppContext,
4183    ) -> Entity<project::Project> {
4184        agent_ui::test_support::init_test(cx);
4185        cx.update(|cx| {
4186            cx.update_flags(false, vec!["agent-v2".into()]);
4187            ThreadStore::init_global(cx);
4188            SidebarThreadMetadataStore::init_global(cx);
4189            language_model::LanguageModelRegistry::test(cx);
4190            prompt_store::init(cx);
4191        });
4192
4193        let fs = FakeFs::new(cx.executor());
4194        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
4195            .await;
4196        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4197        project::Project::test(fs, [worktree_path.as_ref()], cx).await
4198    }
4199
4200    fn add_agent_panel(
4201        workspace: &Entity<Workspace>,
4202        project: &Entity<project::Project>,
4203        cx: &mut gpui::VisualTestContext,
4204    ) -> Entity<AgentPanel> {
4205        workspace.update_in(cx, |workspace, window, cx| {
4206            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4207            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
4208            workspace.add_panel(panel.clone(), window, cx);
4209            panel
4210        })
4211    }
4212
4213    fn setup_sidebar_with_agent_panel(
4214        multi_workspace: &Entity<MultiWorkspace>,
4215        project: &Entity<project::Project>,
4216        cx: &mut gpui::VisualTestContext,
4217    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
4218        let sidebar = setup_sidebar(multi_workspace, cx);
4219        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
4220        let panel = add_agent_panel(&workspace, project, cx);
4221        (sidebar, panel)
4222    }
4223
4224    #[gpui::test]
4225    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
4226        let project = init_test_project_with_agent_panel("/my-project", cx).await;
4227        let (multi_workspace, cx) =
4228            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4229        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
4230
4231        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4232
4233        // Open thread A and keep it generating.
4234        let connection = StubAgentConnection::new();
4235        open_thread_with_connection(&panel, connection.clone(), cx);
4236        send_message(&panel, cx);
4237
4238        let session_id_a = active_session_id(&panel, cx);
4239        save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await;
4240
4241        cx.update(|_, cx| {
4242            connection.send_update(
4243                session_id_a.clone(),
4244                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4245                cx,
4246            );
4247        });
4248        cx.run_until_parked();
4249
4250        // Open thread B (idle, default response) — thread A goes to background.
4251        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4252            acp::ContentChunk::new("Done".into()),
4253        )]);
4254        open_thread_with_connection(&panel, connection, cx);
4255        send_message(&panel, cx);
4256
4257        let session_id_b = active_session_id(&panel, cx);
4258        save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await;
4259
4260        cx.run_until_parked();
4261
4262        let mut entries = visible_entries_as_strings(&sidebar, cx);
4263        entries[1..].sort();
4264        assert_eq!(
4265            entries,
4266            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
4267        );
4268    }
4269
4270    #[gpui::test]
4271    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
4272        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
4273        let (multi_workspace, cx) = cx
4274            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4275        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
4276
4277        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4278
4279        // Open thread on workspace A and keep it generating.
4280        let connection_a = StubAgentConnection::new();
4281        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
4282        send_message(&panel_a, cx);
4283
4284        let session_id_a = active_session_id(&panel_a, cx);
4285        save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
4286
4287        cx.update(|_, cx| {
4288            connection_a.send_update(
4289                session_id_a.clone(),
4290                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4291                cx,
4292            );
4293        });
4294        cx.run_until_parked();
4295
4296        // Add a second workspace and activate it (making workspace A the background).
4297        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
4298        let project_b = project::Project::test(fs, [], cx).await;
4299        multi_workspace.update_in(cx, |mw, window, cx| {
4300            mw.test_add_workspace(project_b, window, cx);
4301        });
4302        cx.run_until_parked();
4303
4304        // Thread A is still running; no notification yet.
4305        assert_eq!(
4306            visible_entries_as_strings(&sidebar, cx),
4307            vec!["v [project-a]", "  Hello * (running)",]
4308        );
4309
4310        // Complete thread A's turn (transition Running → Completed).
4311        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
4312        cx.run_until_parked();
4313
4314        // The completed background thread shows a notification indicator.
4315        assert_eq!(
4316            visible_entries_as_strings(&sidebar, cx),
4317            vec!["v [project-a]", "  Hello * (!)",]
4318        );
4319    }
4320
4321    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
4322        sidebar.update_in(cx, |sidebar, window, cx| {
4323            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
4324            sidebar.filter_editor.update(cx, |editor, cx| {
4325                editor.set_text(query, window, cx);
4326            });
4327        });
4328        cx.run_until_parked();
4329    }
4330
4331    #[gpui::test]
4332    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
4333        let project = init_test_project("/my-project", cx).await;
4334        let (multi_workspace, cx) =
4335            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4336        let sidebar = setup_sidebar(&multi_workspace, cx);
4337
4338        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4339
4340        for (id, title, hour) in [
4341            ("t-1", "Fix crash in project panel", 3),
4342            ("t-2", "Add inline diff view", 2),
4343            ("t-3", "Refactor settings module", 1),
4344        ] {
4345            save_thread_metadata(
4346                acp::SessionId::new(Arc::from(id)),
4347                title.into(),
4348                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4349                path_list.clone(),
4350                cx,
4351            )
4352            .await;
4353        }
4354        cx.run_until_parked();
4355
4356        assert_eq!(
4357            visible_entries_as_strings(&sidebar, cx),
4358            vec![
4359                "v [my-project]",
4360                "  Fix crash in project panel",
4361                "  Add inline diff view",
4362                "  Refactor settings module",
4363            ]
4364        );
4365
4366        // User types "diff" in the search box — only the matching thread remains,
4367        // with its workspace header preserved for context.
4368        type_in_search(&sidebar, "diff", cx);
4369        assert_eq!(
4370            visible_entries_as_strings(&sidebar, cx),
4371            vec!["v [my-project]", "  Add inline diff view  <== selected",]
4372        );
4373
4374        // User changes query to something with no matches — list is empty.
4375        type_in_search(&sidebar, "nonexistent", cx);
4376        assert_eq!(
4377            visible_entries_as_strings(&sidebar, cx),
4378            Vec::<String>::new()
4379        );
4380    }
4381
4382    #[gpui::test]
4383    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
4384        // Scenario: A user remembers a thread title but not the exact casing.
4385        // Search should match case-insensitively so they can still find it.
4386        let project = init_test_project("/my-project", cx).await;
4387        let (multi_workspace, cx) =
4388            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4389        let sidebar = setup_sidebar(&multi_workspace, cx);
4390
4391        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4392
4393        save_thread_metadata(
4394            acp::SessionId::new(Arc::from("thread-1")),
4395            "Fix Crash In Project Panel".into(),
4396            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4397            path_list.clone(),
4398            cx,
4399        )
4400        .await;
4401        cx.run_until_parked();
4402
4403        // Lowercase query matches mixed-case title.
4404        type_in_search(&sidebar, "fix crash", cx);
4405        assert_eq!(
4406            visible_entries_as_strings(&sidebar, cx),
4407            vec![
4408                "v [my-project]",
4409                "  Fix Crash In Project Panel  <== selected",
4410            ]
4411        );
4412
4413        // Uppercase query also matches the same title.
4414        type_in_search(&sidebar, "FIX CRASH", cx);
4415        assert_eq!(
4416            visible_entries_as_strings(&sidebar, cx),
4417            vec![
4418                "v [my-project]",
4419                "  Fix Crash In Project Panel  <== selected",
4420            ]
4421        );
4422    }
4423
4424    #[gpui::test]
4425    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
4426        // Scenario: A user searches, finds what they need, then presses Escape
4427        // to dismiss the filter and see the full list again.
4428        let project = init_test_project("/my-project", cx).await;
4429        let (multi_workspace, cx) =
4430            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4431        let sidebar = setup_sidebar(&multi_workspace, cx);
4432
4433        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4434
4435        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
4436            save_thread_metadata(
4437                acp::SessionId::new(Arc::from(id)),
4438                title.into(),
4439                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4440                path_list.clone(),
4441                cx,
4442            )
4443            .await;
4444        }
4445        cx.run_until_parked();
4446
4447        // Confirm the full list is showing.
4448        assert_eq!(
4449            visible_entries_as_strings(&sidebar, cx),
4450            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
4451        );
4452
4453        // User types a search query to filter down.
4454        open_and_focus_sidebar(&sidebar, cx);
4455        type_in_search(&sidebar, "alpha", cx);
4456        assert_eq!(
4457            visible_entries_as_strings(&sidebar, cx),
4458            vec!["v [my-project]", "  Alpha thread  <== selected",]
4459        );
4460
4461        // User presses Escape — filter clears, full list is restored.
4462        // The selection index (1) now points at the first thread entry.
4463        cx.dispatch_action(Cancel);
4464        cx.run_until_parked();
4465        assert_eq!(
4466            visible_entries_as_strings(&sidebar, cx),
4467            vec![
4468                "v [my-project]",
4469                "  Alpha thread  <== selected",
4470                "  Beta thread",
4471            ]
4472        );
4473    }
4474
4475    #[gpui::test]
4476    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
4477        let project_a = init_test_project("/project-a", cx).await;
4478        let (multi_workspace, cx) =
4479            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4480        let sidebar = setup_sidebar(&multi_workspace, cx);
4481
4482        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4483
4484        for (id, title, hour) in [
4485            ("a1", "Fix bug in sidebar", 2),
4486            ("a2", "Add tests for editor", 1),
4487        ] {
4488            save_thread_metadata(
4489                acp::SessionId::new(Arc::from(id)),
4490                title.into(),
4491                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4492                path_list_a.clone(),
4493                cx,
4494            )
4495            .await;
4496        }
4497
4498        // Add a second workspace.
4499        multi_workspace.update_in(cx, |mw, window, cx| {
4500            mw.create_test_workspace(window, cx).detach();
4501        });
4502        cx.run_until_parked();
4503
4504        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
4505
4506        for (id, title, hour) in [
4507            ("b1", "Refactor sidebar layout", 3),
4508            ("b2", "Fix typo in README", 1),
4509        ] {
4510            save_thread_metadata(
4511                acp::SessionId::new(Arc::from(id)),
4512                title.into(),
4513                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4514                path_list_b.clone(),
4515                cx,
4516            )
4517            .await;
4518        }
4519        cx.run_until_parked();
4520
4521        assert_eq!(
4522            visible_entries_as_strings(&sidebar, cx),
4523            vec![
4524                "v [project-a]",
4525                "  Fix bug in sidebar",
4526                "  Add tests for editor",
4527            ]
4528        );
4529
4530        // "sidebar" matches a thread in each workspace — both headers stay visible.
4531        type_in_search(&sidebar, "sidebar", cx);
4532        assert_eq!(
4533            visible_entries_as_strings(&sidebar, cx),
4534            vec!["v [project-a]", "  Fix bug in sidebar  <== selected",]
4535        );
4536
4537        // "typo" only matches in the second workspace — the first header disappears.
4538        type_in_search(&sidebar, "typo", cx);
4539        assert_eq!(
4540            visible_entries_as_strings(&sidebar, cx),
4541            Vec::<String>::new()
4542        );
4543
4544        // "project-a" matches the first workspace name — the header appears
4545        // with all child threads included.
4546        type_in_search(&sidebar, "project-a", cx);
4547        assert_eq!(
4548            visible_entries_as_strings(&sidebar, cx),
4549            vec![
4550                "v [project-a]",
4551                "  Fix bug in sidebar  <== selected",
4552                "  Add tests for editor",
4553            ]
4554        );
4555    }
4556
4557    #[gpui::test]
4558    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
4559        let project_a = init_test_project("/alpha-project", cx).await;
4560        let (multi_workspace, cx) =
4561            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4562        let sidebar = setup_sidebar(&multi_workspace, cx);
4563
4564        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
4565
4566        for (id, title, hour) in [
4567            ("a1", "Fix bug in sidebar", 2),
4568            ("a2", "Add tests for editor", 1),
4569        ] {
4570            save_thread_metadata(
4571                acp::SessionId::new(Arc::from(id)),
4572                title.into(),
4573                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4574                path_list_a.clone(),
4575                cx,
4576            )
4577            .await;
4578        }
4579
4580        // Add a second workspace.
4581        multi_workspace.update_in(cx, |mw, window, cx| {
4582            mw.create_test_workspace(window, cx).detach();
4583        });
4584        cx.run_until_parked();
4585
4586        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
4587
4588        for (id, title, hour) in [
4589            ("b1", "Refactor sidebar layout", 3),
4590            ("b2", "Fix typo in README", 1),
4591        ] {
4592            save_thread_metadata(
4593                acp::SessionId::new(Arc::from(id)),
4594                title.into(),
4595                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4596                path_list_b.clone(),
4597                cx,
4598            )
4599            .await;
4600        }
4601        cx.run_until_parked();
4602
4603        // "alpha" matches the workspace name "alpha-project" but no thread titles.
4604        // The workspace header should appear with all child threads included.
4605        type_in_search(&sidebar, "alpha", cx);
4606        assert_eq!(
4607            visible_entries_as_strings(&sidebar, cx),
4608            vec![
4609                "v [alpha-project]",
4610                "  Fix bug in sidebar  <== selected",
4611                "  Add tests for editor",
4612            ]
4613        );
4614
4615        // "sidebar" matches thread titles in both workspaces but not workspace names.
4616        // Both headers appear with their matching threads.
4617        type_in_search(&sidebar, "sidebar", cx);
4618        assert_eq!(
4619            visible_entries_as_strings(&sidebar, cx),
4620            vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
4621        );
4622
4623        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
4624        // doesn't match) — but does not match either workspace name or any thread.
4625        // Actually let's test something simpler: a query that matches both a workspace
4626        // name AND some threads in that workspace. Matching threads should still appear.
4627        type_in_search(&sidebar, "fix", cx);
4628        assert_eq!(
4629            visible_entries_as_strings(&sidebar, cx),
4630            vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
4631        );
4632
4633        // A query that matches a workspace name AND a thread in that same workspace.
4634        // Both the header (highlighted) and all child threads should appear.
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        // Now search for something that matches only a workspace name when there
4646        // are also threads with matching titles — the non-matching workspace's
4647        // threads should still appear if their titles match.
4648        type_in_search(&sidebar, "alp", cx);
4649        assert_eq!(
4650            visible_entries_as_strings(&sidebar, cx),
4651            vec![
4652                "v [alpha-project]",
4653                "  Fix bug in sidebar  <== selected",
4654                "  Add tests for editor",
4655            ]
4656        );
4657    }
4658
4659    #[gpui::test]
4660    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
4661        let project = init_test_project("/my-project", cx).await;
4662        let (multi_workspace, cx) =
4663            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4664        let sidebar = setup_sidebar(&multi_workspace, cx);
4665
4666        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4667
4668        // Create 8 threads. The oldest one has a unique name and will be
4669        // behind View More (only 5 shown by default).
4670        for i in 0..8u32 {
4671            let title = if i == 0 {
4672                "Hidden gem thread".to_string()
4673            } else {
4674                format!("Thread {}", i + 1)
4675            };
4676            save_thread_metadata(
4677                acp::SessionId::new(Arc::from(format!("thread-{}", i))),
4678                title.into(),
4679                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
4680                path_list.clone(),
4681                cx,
4682            )
4683            .await;
4684        }
4685        cx.run_until_parked();
4686
4687        // Confirm the thread is not visible and View More is shown.
4688        let entries = visible_entries_as_strings(&sidebar, cx);
4689        assert!(
4690            entries.iter().any(|e| e.contains("View More")),
4691            "should have View More button"
4692        );
4693        assert!(
4694            !entries.iter().any(|e| e.contains("Hidden gem")),
4695            "Hidden gem should be behind View More"
4696        );
4697
4698        // User searches for the hidden thread — it appears, and View More is gone.
4699        type_in_search(&sidebar, "hidden gem", cx);
4700        let filtered = visible_entries_as_strings(&sidebar, cx);
4701        assert_eq!(
4702            filtered,
4703            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
4704        );
4705        assert!(
4706            !filtered.iter().any(|e| e.contains("View More")),
4707            "View More should not appear when filtering"
4708        );
4709    }
4710
4711    #[gpui::test]
4712    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
4713        let project = init_test_project("/my-project", cx).await;
4714        let (multi_workspace, cx) =
4715            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4716        let sidebar = setup_sidebar(&multi_workspace, cx);
4717
4718        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4719
4720        save_thread_metadata(
4721            acp::SessionId::new(Arc::from("thread-1")),
4722            "Important thread".into(),
4723            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4724            path_list.clone(),
4725            cx,
4726        )
4727        .await;
4728        cx.run_until_parked();
4729
4730        // User focuses the sidebar and collapses the group using keyboard:
4731        // manually select the header, then press SelectParent to collapse.
4732        open_and_focus_sidebar(&sidebar, cx);
4733        sidebar.update_in(cx, |sidebar, _window, _cx| {
4734            sidebar.selection = Some(0);
4735        });
4736        cx.dispatch_action(SelectParent);
4737        cx.run_until_parked();
4738
4739        assert_eq!(
4740            visible_entries_as_strings(&sidebar, cx),
4741            vec!["> [my-project]  <== selected"]
4742        );
4743
4744        // User types a search — the thread appears even though its group is collapsed.
4745        type_in_search(&sidebar, "important", cx);
4746        assert_eq!(
4747            visible_entries_as_strings(&sidebar, cx),
4748            vec!["> [my-project]", "  Important thread  <== selected",]
4749        );
4750    }
4751
4752    #[gpui::test]
4753    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
4754        let project = init_test_project("/my-project", cx).await;
4755        let (multi_workspace, cx) =
4756            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4757        let sidebar = setup_sidebar(&multi_workspace, cx);
4758
4759        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4760
4761        for (id, title, hour) in [
4762            ("t-1", "Fix crash in panel", 3),
4763            ("t-2", "Fix lint warnings", 2),
4764            ("t-3", "Add new feature", 1),
4765        ] {
4766            save_thread_metadata(
4767                acp::SessionId::new(Arc::from(id)),
4768                title.into(),
4769                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4770                path_list.clone(),
4771                cx,
4772            )
4773            .await;
4774        }
4775        cx.run_until_parked();
4776
4777        open_and_focus_sidebar(&sidebar, cx);
4778
4779        // User types "fix" — two threads match.
4780        type_in_search(&sidebar, "fix", cx);
4781        assert_eq!(
4782            visible_entries_as_strings(&sidebar, cx),
4783            vec![
4784                "v [my-project]",
4785                "  Fix crash in panel  <== selected",
4786                "  Fix lint warnings",
4787            ]
4788        );
4789
4790        // Selection starts on the first matching thread. User presses
4791        // SelectNext to move to the second match.
4792        cx.dispatch_action(SelectNext);
4793        assert_eq!(
4794            visible_entries_as_strings(&sidebar, cx),
4795            vec![
4796                "v [my-project]",
4797                "  Fix crash in panel",
4798                "  Fix lint warnings  <== selected",
4799            ]
4800        );
4801
4802        // User can also jump back with SelectPrevious.
4803        cx.dispatch_action(SelectPrevious);
4804        assert_eq!(
4805            visible_entries_as_strings(&sidebar, cx),
4806            vec![
4807                "v [my-project]",
4808                "  Fix crash in panel  <== selected",
4809                "  Fix lint warnings",
4810            ]
4811        );
4812    }
4813
4814    #[gpui::test]
4815    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
4816        let project = init_test_project("/my-project", cx).await;
4817        let (multi_workspace, cx) =
4818            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4819        let sidebar = setup_sidebar(&multi_workspace, cx);
4820
4821        multi_workspace.update_in(cx, |mw, window, cx| {
4822            mw.create_test_workspace(window, cx).detach();
4823        });
4824        cx.run_until_parked();
4825
4826        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4827
4828        save_thread_metadata(
4829            acp::SessionId::new(Arc::from("hist-1")),
4830            "Historical Thread".into(),
4831            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
4832            path_list.clone(),
4833            cx,
4834        )
4835        .await;
4836        cx.run_until_parked();
4837        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4838        cx.run_until_parked();
4839
4840        assert_eq!(
4841            visible_entries_as_strings(&sidebar, cx),
4842            vec!["v [my-project]", "  Historical Thread",]
4843        );
4844
4845        // Switch to workspace 1 so we can verify the confirm switches back.
4846        multi_workspace.update_in(cx, |mw, window, cx| {
4847            mw.activate_index(1, window, cx);
4848        });
4849        cx.run_until_parked();
4850        assert_eq!(
4851            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4852            1
4853        );
4854
4855        // Confirm on the historical (non-live) thread at index 1.
4856        // Before a previous fix, the workspace field was Option<usize> and
4857        // historical threads had None, so activate_thread early-returned
4858        // without switching the workspace.
4859        sidebar.update_in(cx, |sidebar, window, cx| {
4860            sidebar.selection = Some(1);
4861            sidebar.confirm(&Confirm, window, cx);
4862        });
4863        cx.run_until_parked();
4864
4865        assert_eq!(
4866            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4867            0
4868        );
4869    }
4870
4871    #[gpui::test]
4872    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
4873        let project = init_test_project("/my-project", cx).await;
4874        let (multi_workspace, cx) =
4875            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4876        let sidebar = setup_sidebar(&multi_workspace, cx);
4877
4878        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4879
4880        save_thread_metadata(
4881            acp::SessionId::new(Arc::from("t-1")),
4882            "Thread A".into(),
4883            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4884            path_list.clone(),
4885            cx,
4886        )
4887        .await;
4888
4889        save_thread_metadata(
4890            acp::SessionId::new(Arc::from("t-2")),
4891            "Thread B".into(),
4892            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4893            path_list.clone(),
4894            cx,
4895        )
4896        .await;
4897
4898        cx.run_until_parked();
4899        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4900        cx.run_until_parked();
4901
4902        assert_eq!(
4903            visible_entries_as_strings(&sidebar, cx),
4904            vec!["v [my-project]", "  Thread A", "  Thread B",]
4905        );
4906
4907        // Keyboard confirm preserves selection.
4908        sidebar.update_in(cx, |sidebar, window, cx| {
4909            sidebar.selection = Some(1);
4910            sidebar.confirm(&Confirm, window, cx);
4911        });
4912        assert_eq!(
4913            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
4914            Some(1)
4915        );
4916
4917        // Click handlers clear selection to None so no highlight lingers
4918        // after a click regardless of focus state. The hover style provides
4919        // visual feedback during mouse interaction instead.
4920        sidebar.update_in(cx, |sidebar, window, cx| {
4921            sidebar.selection = None;
4922            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4923            sidebar.toggle_collapse(&path_list, window, cx);
4924        });
4925        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
4926
4927        // When the user tabs back into the sidebar, focus_in no longer
4928        // restores selection — it stays None.
4929        sidebar.update_in(cx, |sidebar, window, cx| {
4930            sidebar.focus_in(window, cx);
4931        });
4932        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
4933    }
4934
4935    #[gpui::test]
4936    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
4937        let project = init_test_project_with_agent_panel("/my-project", cx).await;
4938        let (multi_workspace, cx) =
4939            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4940        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
4941
4942        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4943
4944        let connection = StubAgentConnection::new();
4945        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4946            acp::ContentChunk::new("Hi there!".into()),
4947        )]);
4948        open_thread_with_connection(&panel, connection, cx);
4949        send_message(&panel, cx);
4950
4951        let session_id = active_session_id(&panel, cx);
4952        save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
4953        cx.run_until_parked();
4954
4955        assert_eq!(
4956            visible_entries_as_strings(&sidebar, cx),
4957            vec!["v [my-project]", "  Hello *"]
4958        );
4959
4960        // Simulate the agent generating a title. The notification chain is:
4961        // AcpThread::set_title emits TitleUpdated →
4962        // ConnectionView::handle_thread_event calls cx.notify() →
4963        // AgentPanel observer fires and emits AgentPanelEvent →
4964        // Sidebar subscription calls update_entries / rebuild_contents.
4965        //
4966        // Before the fix, handle_thread_event did NOT call cx.notify() for
4967        // TitleUpdated, so the AgentPanel observer never fired and the
4968        // sidebar kept showing the old title.
4969        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
4970        thread.update(cx, |thread, cx| {
4971            thread
4972                .set_title("Friendly Greeting with AI".into(), cx)
4973                .detach();
4974        });
4975        cx.run_until_parked();
4976
4977        assert_eq!(
4978            visible_entries_as_strings(&sidebar, cx),
4979            vec!["v [my-project]", "  Friendly Greeting with AI *"]
4980        );
4981    }
4982
4983    #[gpui::test]
4984    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
4985        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
4986        let (multi_workspace, cx) = cx
4987            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4988        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
4989
4990        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4991
4992        // Save a thread so it appears in the list.
4993        let connection_a = StubAgentConnection::new();
4994        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4995            acp::ContentChunk::new("Done".into()),
4996        )]);
4997        open_thread_with_connection(&panel_a, connection_a, cx);
4998        send_message(&panel_a, cx);
4999        let session_id_a = active_session_id(&panel_a, cx);
5000        save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
5001
5002        // Add a second workspace with its own agent panel.
5003        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
5004        fs.as_fake()
5005            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
5006            .await;
5007        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
5008        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5009            mw.test_add_workspace(project_b.clone(), window, cx)
5010        });
5011        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
5012        cx.run_until_parked();
5013
5014        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
5015
5016        // ── 1. Initial state: focused thread derived from active panel ─────
5017        sidebar.read_with(cx, |sidebar, _cx| {
5018            assert_eq!(
5019                sidebar.focused_thread.as_ref(),
5020                Some(&session_id_a),
5021                "The active panel's thread should be focused on startup"
5022            );
5023        });
5024
5025        sidebar.update_in(cx, |sidebar, window, cx| {
5026            sidebar.activate_thread(
5027                Agent::NativeAgent,
5028                acp_thread::AgentSessionInfo {
5029                    session_id: session_id_a.clone(),
5030                    work_dirs: None,
5031                    title: Some("Test".into()),
5032                    updated_at: None,
5033                    created_at: None,
5034                    meta: None,
5035                },
5036                &workspace_a,
5037                window,
5038                cx,
5039            );
5040        });
5041        cx.run_until_parked();
5042
5043        sidebar.read_with(cx, |sidebar, _cx| {
5044            assert_eq!(
5045                sidebar.focused_thread.as_ref(),
5046                Some(&session_id_a),
5047                "After clicking a thread, it should be the focused thread"
5048            );
5049            assert!(
5050                has_thread_entry(sidebar, &session_id_a),
5051                "The clicked thread should be present in the entries"
5052            );
5053        });
5054
5055        workspace_a.read_with(cx, |workspace, cx| {
5056            assert!(
5057                workspace.panel::<AgentPanel>(cx).is_some(),
5058                "Agent panel should exist"
5059            );
5060            let dock = workspace.right_dock().read(cx);
5061            assert!(
5062                dock.is_open(),
5063                "Clicking a thread should open the agent panel dock"
5064            );
5065        });
5066
5067        let connection_b = StubAgentConnection::new();
5068        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5069            acp::ContentChunk::new("Thread B".into()),
5070        )]);
5071        open_thread_with_connection(&panel_b, connection_b, cx);
5072        send_message(&panel_b, cx);
5073        let session_id_b = active_session_id(&panel_b, cx);
5074        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
5075        save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await;
5076        cx.run_until_parked();
5077
5078        // Workspace A is currently active. Click a thread in workspace B,
5079        // which also triggers a workspace switch.
5080        sidebar.update_in(cx, |sidebar, window, cx| {
5081            sidebar.activate_thread(
5082                Agent::NativeAgent,
5083                acp_thread::AgentSessionInfo {
5084                    session_id: session_id_b.clone(),
5085                    work_dirs: None,
5086                    title: Some("Thread B".into()),
5087                    updated_at: None,
5088                    created_at: None,
5089                    meta: None,
5090                },
5091                &workspace_b,
5092                window,
5093                cx,
5094            );
5095        });
5096        cx.run_until_parked();
5097
5098        sidebar.read_with(cx, |sidebar, _cx| {
5099            assert_eq!(
5100                sidebar.focused_thread.as_ref(),
5101                Some(&session_id_b),
5102                "Clicking a thread in another workspace should focus that thread"
5103            );
5104            assert!(
5105                has_thread_entry(sidebar, &session_id_b),
5106                "The cross-workspace thread should be present in the entries"
5107            );
5108        });
5109
5110        multi_workspace.update_in(cx, |mw, window, cx| {
5111            mw.activate_index(0, window, cx);
5112        });
5113        cx.run_until_parked();
5114
5115        sidebar.read_with(cx, |sidebar, _cx| {
5116            assert_eq!(
5117                sidebar.focused_thread.as_ref(),
5118                Some(&session_id_a),
5119                "Switching workspace should seed focused_thread from the new active panel"
5120            );
5121            assert!(
5122                has_thread_entry(sidebar, &session_id_a),
5123                "The seeded thread should be present in the entries"
5124            );
5125        });
5126
5127        let connection_b2 = StubAgentConnection::new();
5128        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5129            acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
5130        )]);
5131        open_thread_with_connection(&panel_b, connection_b2, cx);
5132        send_message(&panel_b, cx);
5133        let session_id_b2 = active_session_id(&panel_b, cx);
5134        save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await;
5135        cx.run_until_parked();
5136
5137        // Panel B is not the active workspace's panel (workspace A is
5138        // active), so opening a thread there should not change focused_thread.
5139        // This prevents running threads in background workspaces from causing
5140        // the selection highlight to jump around.
5141        sidebar.read_with(cx, |sidebar, _cx| {
5142            assert_eq!(
5143                sidebar.focused_thread.as_ref(),
5144                Some(&session_id_a),
5145                "Opening a thread in a non-active panel should not change focused_thread"
5146            );
5147        });
5148
5149        workspace_b.update_in(cx, |workspace, window, cx| {
5150            workspace.focus_handle(cx).focus(window, cx);
5151        });
5152        cx.run_until_parked();
5153
5154        sidebar.read_with(cx, |sidebar, _cx| {
5155            assert_eq!(
5156                sidebar.focused_thread.as_ref(),
5157                Some(&session_id_a),
5158                "Defocusing the sidebar should not change focused_thread"
5159            );
5160        });
5161
5162        // Switching workspaces via the multi_workspace (simulates clicking
5163        // a workspace header) should clear focused_thread.
5164        multi_workspace.update_in(cx, |mw, window, cx| {
5165            if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) {
5166                mw.activate_index(index, window, cx);
5167            }
5168        });
5169        cx.run_until_parked();
5170
5171        sidebar.read_with(cx, |sidebar, _cx| {
5172            assert_eq!(
5173                sidebar.focused_thread.as_ref(),
5174                Some(&session_id_b2),
5175                "Switching workspace should seed focused_thread from the new active panel"
5176            );
5177            assert!(
5178                has_thread_entry(sidebar, &session_id_b2),
5179                "The seeded thread should be present in the entries"
5180            );
5181        });
5182
5183        // ── 8. Focusing the agent panel thread keeps focused_thread ────
5184        // Workspace B still has session_id_b2 loaded in the agent panel.
5185        // Clicking into the thread (simulated by focusing its view) should
5186        // keep focused_thread since it was already seeded on workspace switch.
5187        panel_b.update_in(cx, |panel, window, cx| {
5188            if let Some(thread_view) = panel.active_conversation_view() {
5189                thread_view.read(cx).focus_handle(cx).focus(window, cx);
5190            }
5191        });
5192        cx.run_until_parked();
5193
5194        sidebar.read_with(cx, |sidebar, _cx| {
5195            assert_eq!(
5196                sidebar.focused_thread.as_ref(),
5197                Some(&session_id_b2),
5198                "Focusing the agent panel thread should set focused_thread"
5199            );
5200            assert!(
5201                has_thread_entry(sidebar, &session_id_b2),
5202                "The focused thread should be present in the entries"
5203            );
5204        });
5205    }
5206
5207    #[gpui::test]
5208    async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
5209        let project = init_test_project_with_agent_panel("/project-a", cx).await;
5210        let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
5211        let (multi_workspace, cx) =
5212            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5213        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5214
5215        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
5216
5217        // Start a thread and send a message so it has history.
5218        let connection = StubAgentConnection::new();
5219        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5220            acp::ContentChunk::new("Done".into()),
5221        )]);
5222        open_thread_with_connection(&panel, connection, cx);
5223        send_message(&panel, cx);
5224        let session_id = active_session_id(&panel, cx);
5225        save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await;
5226        cx.run_until_parked();
5227
5228        // Verify the thread appears in the sidebar.
5229        assert_eq!(
5230            visible_entries_as_strings(&sidebar, cx),
5231            vec!["v [project-a]", "  Hello *",]
5232        );
5233
5234        // The "New Thread" button should NOT be in "active/draft" state
5235        // because the panel has a thread with messages.
5236        sidebar.read_with(cx, |sidebar, _cx| {
5237            assert!(
5238                !sidebar.active_thread_is_draft,
5239                "Panel has a thread with messages, so it should not be a draft"
5240            );
5241        });
5242
5243        // Now add a second folder to the workspace, changing the path_list.
5244        fs.as_fake()
5245            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
5246            .await;
5247        project
5248            .update(cx, |project, cx| {
5249                project.find_or_create_worktree("/project-b", true, cx)
5250            })
5251            .await
5252            .expect("should add worktree");
5253        cx.run_until_parked();
5254
5255        // The workspace path_list is now [project-a, project-b]. The old
5256        // thread was stored under [project-a], so it no longer appears in
5257        // the sidebar list for this workspace.
5258        let entries = visible_entries_as_strings(&sidebar, cx);
5259        assert!(
5260            !entries.iter().any(|e| e.contains("Hello")),
5261            "Thread stored under the old path_list should not appear: {:?}",
5262            entries
5263        );
5264
5265        // The "New Thread" button must still be clickable (not stuck in
5266        // "active/draft" state). Verify that `active_thread_is_draft` is
5267        // false — the panel still has the old thread with messages.
5268        sidebar.read_with(cx, |sidebar, _cx| {
5269            assert!(
5270                !sidebar.active_thread_is_draft,
5271                "After adding a folder the panel still has a thread with messages, \
5272                 so active_thread_is_draft should be false"
5273            );
5274        });
5275
5276        // Actually click "New Thread" by calling create_new_thread and
5277        // verify a new draft is created.
5278        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
5279        sidebar.update_in(cx, |sidebar, window, cx| {
5280            sidebar.create_new_thread(&workspace, window, cx);
5281        });
5282        cx.run_until_parked();
5283
5284        // After creating a new thread, the panel should now be in draft
5285        // state (no messages on the new thread).
5286        sidebar.read_with(cx, |sidebar, _cx| {
5287            assert!(
5288                sidebar.active_thread_is_draft,
5289                "After creating a new thread the panel should be in draft state"
5290            );
5291        });
5292    }
5293
5294    #[gpui::test]
5295    async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
5296        // When the user presses Cmd-N (NewThread action) while viewing a
5297        // non-empty thread, the sidebar should show the "New Thread" entry.
5298        // This exercises the same code path as the workspace action handler
5299        // (which bypasses the sidebar's create_new_thread method).
5300        let project = init_test_project_with_agent_panel("/my-project", cx).await;
5301        let (multi_workspace, cx) =
5302            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5303        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5304
5305        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5306
5307        // Create a non-empty thread (has messages).
5308        let connection = StubAgentConnection::new();
5309        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5310            acp::ContentChunk::new("Done".into()),
5311        )]);
5312        open_thread_with_connection(&panel, connection, cx);
5313        send_message(&panel, cx);
5314
5315        let session_id = active_session_id(&panel, cx);
5316        save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
5317        cx.run_until_parked();
5318
5319        assert_eq!(
5320            visible_entries_as_strings(&sidebar, cx),
5321            vec!["v [my-project]", "  Hello *"]
5322        );
5323
5324        // Simulate cmd-n
5325        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
5326        panel.update_in(cx, |panel, window, cx| {
5327            panel.new_thread(&NewThread, window, cx);
5328        });
5329        workspace.update_in(cx, |workspace, window, cx| {
5330            workspace.focus_panel::<AgentPanel>(window, cx);
5331        });
5332        cx.run_until_parked();
5333
5334        assert_eq!(
5335            visible_entries_as_strings(&sidebar, cx),
5336            vec!["v [my-project]", "  [+ New Thread]", "  Hello *"],
5337            "After Cmd-N the sidebar should show a highlighted New Thread entry"
5338        );
5339
5340        sidebar.read_with(cx, |sidebar, _cx| {
5341            assert!(
5342                sidebar.focused_thread.is_none(),
5343                "focused_thread should be cleared after Cmd-N"
5344            );
5345            assert!(
5346                sidebar.active_thread_is_draft,
5347                "the new blank thread should be a draft"
5348            );
5349        });
5350    }
5351
5352    #[gpui::test]
5353    async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
5354        // When the active workspace is an absorbed git worktree, cmd-n
5355        // should still show the "New Thread" entry under the main repo's
5356        // header and highlight it as active.
5357        agent_ui::test_support::init_test(cx);
5358        cx.update(|cx| {
5359            cx.update_flags(false, vec!["agent-v2".into()]);
5360            ThreadStore::init_global(cx);
5361            SidebarThreadMetadataStore::init_global(cx);
5362            language_model::LanguageModelRegistry::test(cx);
5363            prompt_store::init(cx);
5364        });
5365
5366        let fs = FakeFs::new(cx.executor());
5367
5368        // Main repo with a linked worktree.
5369        fs.insert_tree(
5370            "/project",
5371            serde_json::json!({
5372                ".git": {
5373                    "worktrees": {
5374                        "feature-a": {
5375                            "commondir": "../../",
5376                            "HEAD": "ref: refs/heads/feature-a",
5377                        },
5378                    },
5379                },
5380                "src": {},
5381            }),
5382        )
5383        .await;
5384
5385        // Worktree checkout pointing back to the main repo.
5386        fs.insert_tree(
5387            "/wt-feature-a",
5388            serde_json::json!({
5389                ".git": "gitdir: /project/.git/worktrees/feature-a",
5390                "src": {},
5391            }),
5392        )
5393        .await;
5394
5395        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5396            state.worktrees.push(git::repository::Worktree {
5397                path: std::path::PathBuf::from("/wt-feature-a"),
5398                ref_name: Some("refs/heads/feature-a".into()),
5399                sha: "aaa".into(),
5400            });
5401        })
5402        .unwrap();
5403
5404        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5405
5406        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5407        let worktree_project =
5408            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5409
5410        main_project
5411            .update(cx, |p, cx| p.git_scans_complete(cx))
5412            .await;
5413        worktree_project
5414            .update(cx, |p, cx| p.git_scans_complete(cx))
5415            .await;
5416
5417        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5418            MultiWorkspace::test_new(main_project.clone(), window, cx)
5419        });
5420
5421        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5422            mw.test_add_workspace(worktree_project.clone(), window, cx)
5423        });
5424
5425        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
5426
5427        // Switch to the worktree workspace.
5428        multi_workspace.update_in(cx, |mw, window, cx| {
5429            mw.activate_index(1, window, cx);
5430        });
5431
5432        let sidebar = setup_sidebar(&multi_workspace, cx);
5433
5434        // Create a non-empty thread in the worktree workspace.
5435        let connection = StubAgentConnection::new();
5436        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5437            acp::ContentChunk::new("Done".into()),
5438        )]);
5439        open_thread_with_connection(&worktree_panel, connection, cx);
5440        send_message(&worktree_panel, cx);
5441
5442        let session_id = active_session_id(&worktree_panel, cx);
5443        let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5444        save_test_thread_metadata(&session_id, wt_path_list, cx).await;
5445        cx.run_until_parked();
5446
5447        assert_eq!(
5448            visible_entries_as_strings(&sidebar, cx),
5449            vec!["v [project]", "  Hello {wt-feature-a} *"]
5450        );
5451
5452        // Simulate Cmd-N in the worktree workspace.
5453        worktree_panel.update_in(cx, |panel, window, cx| {
5454            panel.new_thread(&NewThread, window, cx);
5455        });
5456        worktree_workspace.update_in(cx, |workspace, window, cx| {
5457            workspace.focus_panel::<AgentPanel>(window, cx);
5458        });
5459        cx.run_until_parked();
5460
5461        assert_eq!(
5462            visible_entries_as_strings(&sidebar, cx),
5463            vec![
5464                "v [project]",
5465                "  [+ New Thread]",
5466                "  Hello {wt-feature-a} *"
5467            ],
5468            "After Cmd-N in an absorbed worktree, the sidebar should show \
5469             a highlighted New Thread entry under the main repo header"
5470        );
5471
5472        sidebar.read_with(cx, |sidebar, _cx| {
5473            assert!(
5474                sidebar.focused_thread.is_none(),
5475                "focused_thread should be cleared after Cmd-N"
5476            );
5477            assert!(
5478                sidebar.active_thread_is_draft,
5479                "the new blank thread should be a draft"
5480            );
5481        });
5482    }
5483
5484    async fn init_test_project_with_git(
5485        worktree_path: &str,
5486        cx: &mut TestAppContext,
5487    ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
5488        init_test(cx);
5489        let fs = FakeFs::new(cx.executor());
5490        fs.insert_tree(
5491            worktree_path,
5492            serde_json::json!({
5493                ".git": {},
5494                "src": {},
5495            }),
5496        )
5497        .await;
5498        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5499        let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
5500        (project, fs)
5501    }
5502
5503    #[gpui::test]
5504    async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
5505        let (project, fs) = init_test_project_with_git("/project", cx).await;
5506
5507        fs.as_fake()
5508            .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5509                state.worktrees.push(git::repository::Worktree {
5510                    path: std::path::PathBuf::from("/wt/rosewood"),
5511                    ref_name: Some("refs/heads/rosewood".into()),
5512                    sha: "abc".into(),
5513                });
5514            })
5515            .unwrap();
5516
5517        project
5518            .update(cx, |project, cx| project.git_scans_complete(cx))
5519            .await;
5520
5521        let (multi_workspace, cx) =
5522            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5523        let sidebar = setup_sidebar(&multi_workspace, cx);
5524
5525        let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
5526        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
5527        save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await;
5528        save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await;
5529
5530        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5531        cx.run_until_parked();
5532
5533        // Search for "rosewood" — should match the worktree name, not the title.
5534        type_in_search(&sidebar, "rosewood", cx);
5535
5536        assert_eq!(
5537            visible_entries_as_strings(&sidebar, cx),
5538            vec!["v [project]", "  Fix Bug {rosewood}  <== selected"],
5539        );
5540    }
5541
5542    #[gpui::test]
5543    async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
5544        let (project, fs) = init_test_project_with_git("/project", cx).await;
5545
5546        project
5547            .update(cx, |project, cx| project.git_scans_complete(cx))
5548            .await;
5549
5550        let (multi_workspace, cx) =
5551            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5552        let sidebar = setup_sidebar(&multi_workspace, cx);
5553
5554        // Save a thread against a worktree path that doesn't exist yet.
5555        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
5556        save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
5557
5558        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5559        cx.run_until_parked();
5560
5561        // Thread is not visible yet — no worktree knows about this path.
5562        assert_eq!(
5563            visible_entries_as_strings(&sidebar, cx),
5564            vec!["v [project]", "  [+ New Thread]"]
5565        );
5566
5567        // Now add the worktree to the git state and trigger a rescan.
5568        fs.as_fake()
5569            .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
5570                state.worktrees.push(git::repository::Worktree {
5571                    path: std::path::PathBuf::from("/wt/rosewood"),
5572                    ref_name: Some("refs/heads/rosewood".into()),
5573                    sha: "abc".into(),
5574                });
5575            })
5576            .unwrap();
5577
5578        cx.run_until_parked();
5579
5580        assert_eq!(
5581            visible_entries_as_strings(&sidebar, cx),
5582            vec!["v [project]", "  Worktree Thread {rosewood}",]
5583        );
5584    }
5585
5586    #[gpui::test]
5587    async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
5588        init_test(cx);
5589        let fs = FakeFs::new(cx.executor());
5590
5591        // Create the main repo directory (not opened as a workspace yet).
5592        fs.insert_tree(
5593            "/project",
5594            serde_json::json!({
5595                ".git": {
5596                    "worktrees": {
5597                        "feature-a": {
5598                            "commondir": "../../",
5599                            "HEAD": "ref: refs/heads/feature-a",
5600                        },
5601                        "feature-b": {
5602                            "commondir": "../../",
5603                            "HEAD": "ref: refs/heads/feature-b",
5604                        },
5605                    },
5606                },
5607                "src": {},
5608            }),
5609        )
5610        .await;
5611
5612        // Two worktree checkouts whose .git files point back to the main repo.
5613        fs.insert_tree(
5614            "/wt-feature-a",
5615            serde_json::json!({
5616                ".git": "gitdir: /project/.git/worktrees/feature-a",
5617                "src": {},
5618            }),
5619        )
5620        .await;
5621        fs.insert_tree(
5622            "/wt-feature-b",
5623            serde_json::json!({
5624                ".git": "gitdir: /project/.git/worktrees/feature-b",
5625                "src": {},
5626            }),
5627        )
5628        .await;
5629
5630        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5631
5632        let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5633        let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
5634
5635        project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5636        project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5637
5638        // Open both worktrees as workspaces — no main repo yet.
5639        let (multi_workspace, cx) = cx
5640            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5641        multi_workspace.update_in(cx, |mw, window, cx| {
5642            mw.test_add_workspace(project_b.clone(), window, cx);
5643        });
5644        let sidebar = setup_sidebar(&multi_workspace, cx);
5645
5646        let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5647        let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
5648        save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
5649        save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await;
5650
5651        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5652        cx.run_until_parked();
5653
5654        // Without the main repo, each worktree has its own header.
5655        assert_eq!(
5656            visible_entries_as_strings(&sidebar, cx),
5657            vec![
5658                "v [wt-feature-a]",
5659                "  Thread A",
5660                "v [wt-feature-b]",
5661                "  Thread B",
5662            ]
5663        );
5664
5665        // Configure the main repo to list both worktrees before opening
5666        // it so the initial git scan picks them up.
5667        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5668            state.worktrees.push(git::repository::Worktree {
5669                path: std::path::PathBuf::from("/wt-feature-a"),
5670                ref_name: Some("refs/heads/feature-a".into()),
5671                sha: "aaa".into(),
5672            });
5673            state.worktrees.push(git::repository::Worktree {
5674                path: std::path::PathBuf::from("/wt-feature-b"),
5675                ref_name: Some("refs/heads/feature-b".into()),
5676                sha: "bbb".into(),
5677            });
5678        })
5679        .unwrap();
5680
5681        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5682        main_project
5683            .update(cx, |p, cx| p.git_scans_complete(cx))
5684            .await;
5685
5686        multi_workspace.update_in(cx, |mw, window, cx| {
5687            mw.test_add_workspace(main_project.clone(), window, cx);
5688        });
5689        cx.run_until_parked();
5690
5691        // Both worktree workspaces should now be absorbed under the main
5692        // repo header, with worktree chips.
5693        assert_eq!(
5694            visible_entries_as_strings(&sidebar, cx),
5695            vec![
5696                "v [project]",
5697                "  Thread A {wt-feature-a}",
5698                "  Thread B {wt-feature-b}",
5699            ]
5700        );
5701
5702        // Remove feature-b from the main repo's linked worktrees.
5703        // The feature-b workspace should be pruned automatically.
5704        fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
5705            state
5706                .worktrees
5707                .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
5708        })
5709        .unwrap();
5710
5711        cx.run_until_parked();
5712
5713        // feature-b's workspace is pruned; feature-a remains absorbed
5714        // under the main repo.
5715        assert_eq!(
5716            visible_entries_as_strings(&sidebar, cx),
5717            vec!["v [project]", "  Thread A {wt-feature-a}",]
5718        );
5719    }
5720
5721    #[gpui::test]
5722    async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
5723        // When a worktree workspace is absorbed under the main repo, a
5724        // running thread in the worktree's agent panel should still show
5725        // live status (spinner + "(running)") in the sidebar.
5726        agent_ui::test_support::init_test(cx);
5727        cx.update(|cx| {
5728            cx.update_flags(false, vec!["agent-v2".into()]);
5729            ThreadStore::init_global(cx);
5730            SidebarThreadMetadataStore::init_global(cx);
5731            language_model::LanguageModelRegistry::test(cx);
5732            prompt_store::init(cx);
5733        });
5734
5735        let fs = FakeFs::new(cx.executor());
5736
5737        // Main repo with a linked worktree.
5738        fs.insert_tree(
5739            "/project",
5740            serde_json::json!({
5741                ".git": {
5742                    "worktrees": {
5743                        "feature-a": {
5744                            "commondir": "../../",
5745                            "HEAD": "ref: refs/heads/feature-a",
5746                        },
5747                    },
5748                },
5749                "src": {},
5750            }),
5751        )
5752        .await;
5753
5754        // Worktree checkout pointing back to the main repo.
5755        fs.insert_tree(
5756            "/wt-feature-a",
5757            serde_json::json!({
5758                ".git": "gitdir: /project/.git/worktrees/feature-a",
5759                "src": {},
5760            }),
5761        )
5762        .await;
5763
5764        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5765            state.worktrees.push(git::repository::Worktree {
5766                path: std::path::PathBuf::from("/wt-feature-a"),
5767                ref_name: Some("refs/heads/feature-a".into()),
5768                sha: "aaa".into(),
5769            });
5770        })
5771        .unwrap();
5772
5773        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5774
5775        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5776        let worktree_project =
5777            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5778
5779        main_project
5780            .update(cx, |p, cx| p.git_scans_complete(cx))
5781            .await;
5782        worktree_project
5783            .update(cx, |p, cx| p.git_scans_complete(cx))
5784            .await;
5785
5786        // Create the MultiWorkspace with both projects.
5787        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5788            MultiWorkspace::test_new(main_project.clone(), window, cx)
5789        });
5790
5791        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5792            mw.test_add_workspace(worktree_project.clone(), window, cx)
5793        });
5794
5795        // Add an agent panel to the worktree workspace so we can run a
5796        // thread inside it.
5797        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
5798
5799        // Switch back to the main workspace before setting up the sidebar.
5800        multi_workspace.update_in(cx, |mw, window, cx| {
5801            mw.activate_index(0, window, cx);
5802        });
5803
5804        let sidebar = setup_sidebar(&multi_workspace, cx);
5805
5806        // Start a thread in the worktree workspace's panel and keep it
5807        // generating (don't resolve it).
5808        let connection = StubAgentConnection::new();
5809        open_thread_with_connection(&worktree_panel, connection.clone(), cx);
5810        send_message(&worktree_panel, cx);
5811
5812        let session_id = active_session_id(&worktree_panel, cx);
5813
5814        // Save metadata so the sidebar knows about this thread.
5815        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5816        save_test_thread_metadata(&session_id, wt_paths, cx).await;
5817
5818        // Keep the thread generating by sending a chunk without ending
5819        // the turn.
5820        cx.update(|_, cx| {
5821            connection.send_update(
5822                session_id.clone(),
5823                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
5824                cx,
5825            );
5826        });
5827        cx.run_until_parked();
5828
5829        // The worktree thread should be absorbed under the main project
5830        // and show live running status.
5831        let entries = visible_entries_as_strings(&sidebar, cx);
5832        assert_eq!(
5833            entries,
5834            vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
5835        );
5836    }
5837
5838    #[gpui::test]
5839    async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
5840        agent_ui::test_support::init_test(cx);
5841        cx.update(|cx| {
5842            cx.update_flags(false, vec!["agent-v2".into()]);
5843            ThreadStore::init_global(cx);
5844            SidebarThreadMetadataStore::init_global(cx);
5845            language_model::LanguageModelRegistry::test(cx);
5846            prompt_store::init(cx);
5847        });
5848
5849        let fs = FakeFs::new(cx.executor());
5850
5851        fs.insert_tree(
5852            "/project",
5853            serde_json::json!({
5854                ".git": {
5855                    "worktrees": {
5856                        "feature-a": {
5857                            "commondir": "../../",
5858                            "HEAD": "ref: refs/heads/feature-a",
5859                        },
5860                    },
5861                },
5862                "src": {},
5863            }),
5864        )
5865        .await;
5866
5867        fs.insert_tree(
5868            "/wt-feature-a",
5869            serde_json::json!({
5870                ".git": "gitdir: /project/.git/worktrees/feature-a",
5871                "src": {},
5872            }),
5873        )
5874        .await;
5875
5876        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5877            state.worktrees.push(git::repository::Worktree {
5878                path: std::path::PathBuf::from("/wt-feature-a"),
5879                ref_name: Some("refs/heads/feature-a".into()),
5880                sha: "aaa".into(),
5881            });
5882        })
5883        .unwrap();
5884
5885        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5886
5887        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5888        let worktree_project =
5889            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5890
5891        main_project
5892            .update(cx, |p, cx| p.git_scans_complete(cx))
5893            .await;
5894        worktree_project
5895            .update(cx, |p, cx| p.git_scans_complete(cx))
5896            .await;
5897
5898        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5899            MultiWorkspace::test_new(main_project.clone(), window, cx)
5900        });
5901
5902        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5903            mw.test_add_workspace(worktree_project.clone(), window, cx)
5904        });
5905
5906        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
5907
5908        multi_workspace.update_in(cx, |mw, window, cx| {
5909            mw.activate_index(0, window, cx);
5910        });
5911
5912        let sidebar = setup_sidebar(&multi_workspace, cx);
5913
5914        let connection = StubAgentConnection::new();
5915        open_thread_with_connection(&worktree_panel, connection.clone(), cx);
5916        send_message(&worktree_panel, cx);
5917
5918        let session_id = active_session_id(&worktree_panel, cx);
5919        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5920        save_test_thread_metadata(&session_id, wt_paths, cx).await;
5921
5922        cx.update(|_, cx| {
5923            connection.send_update(
5924                session_id.clone(),
5925                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
5926                cx,
5927            );
5928        });
5929        cx.run_until_parked();
5930
5931        assert_eq!(
5932            visible_entries_as_strings(&sidebar, cx),
5933            vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
5934        );
5935
5936        connection.end_turn(session_id, acp::StopReason::EndTurn);
5937        cx.run_until_parked();
5938
5939        assert_eq!(
5940            visible_entries_as_strings(&sidebar, cx),
5941            vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
5942        );
5943    }
5944
5945    #[gpui::test]
5946    async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
5947        cx: &mut TestAppContext,
5948    ) {
5949        init_test(cx);
5950        let fs = FakeFs::new(cx.executor());
5951
5952        fs.insert_tree(
5953            "/project",
5954            serde_json::json!({
5955                ".git": {
5956                    "worktrees": {
5957                        "feature-a": {
5958                            "commondir": "../../",
5959                            "HEAD": "ref: refs/heads/feature-a",
5960                        },
5961                    },
5962                },
5963                "src": {},
5964            }),
5965        )
5966        .await;
5967
5968        fs.insert_tree(
5969            "/wt-feature-a",
5970            serde_json::json!({
5971                ".git": "gitdir: /project/.git/worktrees/feature-a",
5972                "src": {},
5973            }),
5974        )
5975        .await;
5976
5977        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5978            state.worktrees.push(git::repository::Worktree {
5979                path: std::path::PathBuf::from("/wt-feature-a"),
5980                ref_name: Some("refs/heads/feature-a".into()),
5981                sha: "aaa".into(),
5982            });
5983        })
5984        .unwrap();
5985
5986        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5987
5988        // Only open the main repo — no workspace for the worktree.
5989        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5990        main_project
5991            .update(cx, |p, cx| p.git_scans_complete(cx))
5992            .await;
5993
5994        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5995            MultiWorkspace::test_new(main_project.clone(), window, cx)
5996        });
5997        let sidebar = setup_sidebar(&multi_workspace, cx);
5998
5999        // Save a thread for the worktree path (no workspace for it).
6000        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6001        save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6002
6003        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6004        cx.run_until_parked();
6005
6006        // Thread should appear under the main repo with a worktree chip.
6007        assert_eq!(
6008            visible_entries_as_strings(&sidebar, cx),
6009            vec!["v [project]", "  WT Thread {wt-feature-a}"],
6010        );
6011
6012        // Only 1 workspace should exist.
6013        assert_eq!(
6014            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6015            1,
6016        );
6017
6018        // Focus the sidebar and select the worktree thread.
6019        open_and_focus_sidebar(&sidebar, cx);
6020        sidebar.update_in(cx, |sidebar, _window, _cx| {
6021            sidebar.selection = Some(1); // index 0 is header, 1 is the thread
6022        });
6023
6024        // Confirm to open the worktree thread.
6025        cx.dispatch_action(Confirm);
6026        cx.run_until_parked();
6027
6028        // A new workspace should have been created for the worktree path.
6029        let new_workspace = multi_workspace.read_with(cx, |mw, _| {
6030            assert_eq!(
6031                mw.workspaces().len(),
6032                2,
6033                "confirming a worktree thread without a workspace should open one",
6034            );
6035            mw.workspaces()[1].clone()
6036        });
6037
6038        let new_path_list =
6039            new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
6040        assert_eq!(
6041            new_path_list,
6042            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
6043            "the new workspace should have been opened for the worktree path",
6044        );
6045    }
6046
6047    #[gpui::test]
6048    async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
6049        cx: &mut TestAppContext,
6050    ) {
6051        init_test(cx);
6052        let fs = FakeFs::new(cx.executor());
6053
6054        fs.insert_tree(
6055            "/project",
6056            serde_json::json!({
6057                ".git": {
6058                    "worktrees": {
6059                        "feature-a": {
6060                            "commondir": "../../",
6061                            "HEAD": "ref: refs/heads/feature-a",
6062                        },
6063                    },
6064                },
6065                "src": {},
6066            }),
6067        )
6068        .await;
6069
6070        fs.insert_tree(
6071            "/wt-feature-a",
6072            serde_json::json!({
6073                ".git": "gitdir: /project/.git/worktrees/feature-a",
6074                "src": {},
6075            }),
6076        )
6077        .await;
6078
6079        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6080            state.worktrees.push(git::repository::Worktree {
6081                path: std::path::PathBuf::from("/wt-feature-a"),
6082                ref_name: Some("refs/heads/feature-a".into()),
6083                sha: "aaa".into(),
6084            });
6085        })
6086        .unwrap();
6087
6088        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6089
6090        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6091        let worktree_project =
6092            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6093
6094        main_project
6095            .update(cx, |p, cx| p.git_scans_complete(cx))
6096            .await;
6097        worktree_project
6098            .update(cx, |p, cx| p.git_scans_complete(cx))
6099            .await;
6100
6101        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6102            MultiWorkspace::test_new(main_project.clone(), window, cx)
6103        });
6104
6105        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6106            mw.test_add_workspace(worktree_project.clone(), window, cx)
6107        });
6108
6109        // Activate the main workspace before setting up the sidebar.
6110        multi_workspace.update_in(cx, |mw, window, cx| {
6111            mw.activate_index(0, window, cx);
6112        });
6113
6114        let sidebar = setup_sidebar(&multi_workspace, cx);
6115
6116        let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
6117        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6118        save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await;
6119        save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6120
6121        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6122        cx.run_until_parked();
6123
6124        // The worktree workspace should be absorbed under the main repo.
6125        let entries = visible_entries_as_strings(&sidebar, cx);
6126        assert_eq!(entries.len(), 3);
6127        assert_eq!(entries[0], "v [project]");
6128        assert!(entries.contains(&"  Main Thread".to_string()));
6129        assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
6130
6131        let wt_thread_index = entries
6132            .iter()
6133            .position(|e| e.contains("WT Thread"))
6134            .expect("should find the worktree thread entry");
6135
6136        assert_eq!(
6137            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6138            0,
6139            "main workspace should be active initially"
6140        );
6141
6142        // Focus the sidebar and select the absorbed worktree thread.
6143        open_and_focus_sidebar(&sidebar, cx);
6144        sidebar.update_in(cx, |sidebar, _window, _cx| {
6145            sidebar.selection = Some(wt_thread_index);
6146        });
6147
6148        // Confirm to activate the worktree thread.
6149        cx.dispatch_action(Confirm);
6150        cx.run_until_parked();
6151
6152        // The worktree workspace should now be active, not the main one.
6153        let active_workspace = multi_workspace.read_with(cx, |mw, _| {
6154            mw.workspaces()[mw.active_workspace_index()].clone()
6155        });
6156        assert_eq!(
6157            active_workspace, worktree_workspace,
6158            "clicking an absorbed worktree thread should activate the worktree workspace"
6159        );
6160    }
6161
6162    #[gpui::test]
6163    async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
6164        cx: &mut TestAppContext,
6165    ) {
6166        // Thread has saved metadata in ThreadStore. A matching workspace is
6167        // already open. Expected: activates the matching workspace.
6168        init_test(cx);
6169        let fs = FakeFs::new(cx.executor());
6170        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6171            .await;
6172        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6173            .await;
6174        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6175
6176        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6177        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6178
6179        let (multi_workspace, cx) =
6180            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6181
6182        multi_workspace.update_in(cx, |mw, window, cx| {
6183            mw.test_add_workspace(project_b, window, cx);
6184        });
6185
6186        let sidebar = setup_sidebar(&multi_workspace, cx);
6187
6188        // Save a thread with path_list pointing to project-b.
6189        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6190        let session_id = acp::SessionId::new(Arc::from("archived-1"));
6191        save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await;
6192
6193        // Ensure workspace A is active.
6194        multi_workspace.update_in(cx, |mw, window, cx| {
6195            mw.activate_index(0, window, cx);
6196        });
6197        cx.run_until_parked();
6198        assert_eq!(
6199            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6200            0
6201        );
6202
6203        // Call activate_archived_thread – should resolve saved paths and
6204        // switch to the workspace for project-b.
6205        sidebar.update_in(cx, |sidebar, window, cx| {
6206            sidebar.activate_archived_thread(
6207                Agent::NativeAgent,
6208                acp_thread::AgentSessionInfo {
6209                    session_id: session_id.clone(),
6210                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6211                    title: Some("Archived Thread".into()),
6212                    updated_at: None,
6213                    created_at: None,
6214                    meta: None,
6215                },
6216                window,
6217                cx,
6218            );
6219        });
6220        cx.run_until_parked();
6221
6222        assert_eq!(
6223            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6224            1,
6225            "should have activated the workspace matching the saved path_list"
6226        );
6227    }
6228
6229    #[gpui::test]
6230    async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
6231        cx: &mut TestAppContext,
6232    ) {
6233        // Thread has no saved metadata but session_info has cwd. A matching
6234        // workspace is open. Expected: uses cwd to find and activate it.
6235        init_test(cx);
6236        let fs = FakeFs::new(cx.executor());
6237        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6238            .await;
6239        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6240            .await;
6241        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6242
6243        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6244        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6245
6246        let (multi_workspace, cx) =
6247            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6248
6249        multi_workspace.update_in(cx, |mw, window, cx| {
6250            mw.test_add_workspace(project_b, window, cx);
6251        });
6252
6253        let sidebar = setup_sidebar(&multi_workspace, cx);
6254
6255        // Start with workspace A active.
6256        multi_workspace.update_in(cx, |mw, window, cx| {
6257            mw.activate_index(0, window, cx);
6258        });
6259        cx.run_until_parked();
6260        assert_eq!(
6261            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6262            0
6263        );
6264
6265        // No thread saved to the store – cwd is the only path hint.
6266        sidebar.update_in(cx, |sidebar, window, cx| {
6267            sidebar.activate_archived_thread(
6268                Agent::NativeAgent,
6269                acp_thread::AgentSessionInfo {
6270                    session_id: acp::SessionId::new(Arc::from("unknown-session")),
6271                    work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])),
6272                    title: Some("CWD Thread".into()),
6273                    updated_at: None,
6274                    created_at: None,
6275                    meta: None,
6276                },
6277                window,
6278                cx,
6279            );
6280        });
6281        cx.run_until_parked();
6282
6283        assert_eq!(
6284            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6285            1,
6286            "should have activated the workspace matching the cwd"
6287        );
6288    }
6289
6290    #[gpui::test]
6291    async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
6292        cx: &mut TestAppContext,
6293    ) {
6294        // Thread has no saved metadata and no cwd. Expected: falls back to
6295        // the currently active workspace.
6296        init_test(cx);
6297        let fs = FakeFs::new(cx.executor());
6298        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6299            .await;
6300        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6301            .await;
6302        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6303
6304        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6305        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6306
6307        let (multi_workspace, cx) =
6308            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6309
6310        multi_workspace.update_in(cx, |mw, window, cx| {
6311            mw.test_add_workspace(project_b, window, cx);
6312        });
6313
6314        let sidebar = setup_sidebar(&multi_workspace, cx);
6315
6316        // Activate workspace B (index 1) to make it the active one.
6317        multi_workspace.update_in(cx, |mw, window, cx| {
6318            mw.activate_index(1, window, cx);
6319        });
6320        cx.run_until_parked();
6321        assert_eq!(
6322            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6323            1
6324        );
6325
6326        // No saved thread, no cwd – should fall back to the active workspace.
6327        sidebar.update_in(cx, |sidebar, window, cx| {
6328            sidebar.activate_archived_thread(
6329                Agent::NativeAgent,
6330                acp_thread::AgentSessionInfo {
6331                    session_id: acp::SessionId::new(Arc::from("no-context-session")),
6332                    work_dirs: None,
6333                    title: Some("Contextless Thread".into()),
6334                    updated_at: None,
6335                    created_at: None,
6336                    meta: None,
6337                },
6338                window,
6339                cx,
6340            );
6341        });
6342        cx.run_until_parked();
6343
6344        assert_eq!(
6345            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6346            1,
6347            "should have stayed on the active workspace when no path info is available"
6348        );
6349    }
6350
6351    #[gpui::test]
6352    async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
6353        cx: &mut TestAppContext,
6354    ) {
6355        // Thread has saved metadata pointing to a path with no open workspace.
6356        // Expected: opens a new workspace for that path.
6357        init_test(cx);
6358        let fs = FakeFs::new(cx.executor());
6359        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6360            .await;
6361        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6362            .await;
6363        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6364
6365        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6366
6367        let (multi_workspace, cx) =
6368            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6369
6370        let sidebar = setup_sidebar(&multi_workspace, cx);
6371
6372        // Save a thread with path_list pointing to project-b – which has no
6373        // open workspace.
6374        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6375        let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
6376
6377        assert_eq!(
6378            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6379            1,
6380            "should start with one workspace"
6381        );
6382
6383        sidebar.update_in(cx, |sidebar, window, cx| {
6384            sidebar.activate_archived_thread(
6385                Agent::NativeAgent,
6386                acp_thread::AgentSessionInfo {
6387                    session_id: session_id.clone(),
6388                    work_dirs: Some(path_list_b),
6389                    title: Some("New WS Thread".into()),
6390                    updated_at: None,
6391                    created_at: None,
6392                    meta: None,
6393                },
6394                window,
6395                cx,
6396            );
6397        });
6398        cx.run_until_parked();
6399
6400        assert_eq!(
6401            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6402            2,
6403            "should have opened a second workspace for the archived thread's saved paths"
6404        );
6405    }
6406
6407    #[gpui::test]
6408    async fn test_activate_archived_thread_reuses_workspace_in_another_window(
6409        cx: &mut TestAppContext,
6410    ) {
6411        init_test(cx);
6412        let fs = FakeFs::new(cx.executor());
6413        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6414            .await;
6415        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6416            .await;
6417        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6418
6419        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6420        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6421
6422        let multi_workspace_a =
6423            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6424        let multi_workspace_b =
6425            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
6426
6427        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6428
6429        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6430        let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
6431
6432        let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
6433
6434        sidebar.update_in(cx_a, |sidebar, window, cx| {
6435            sidebar.activate_archived_thread(
6436                Agent::NativeAgent,
6437                acp_thread::AgentSessionInfo {
6438                    session_id: session_id.clone(),
6439                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6440                    title: Some("Cross Window Thread".into()),
6441                    updated_at: None,
6442                    created_at: None,
6443                    meta: None,
6444                },
6445                window,
6446                cx,
6447            );
6448        });
6449        cx_a.run_until_parked();
6450
6451        assert_eq!(
6452            multi_workspace_a
6453                .read_with(cx_a, |mw, _| mw.workspaces().len())
6454                .unwrap(),
6455            1,
6456            "should not add the other window's workspace into the current window"
6457        );
6458        assert_eq!(
6459            multi_workspace_b
6460                .read_with(cx_a, |mw, _| mw.workspaces().len())
6461                .unwrap(),
6462            1,
6463            "should reuse the existing workspace in the other window"
6464        );
6465        assert!(
6466            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
6467            "should activate the window that already owns the matching workspace"
6468        );
6469        sidebar.read_with(cx_a, |sidebar, _| {
6470            assert_eq!(
6471                sidebar.focused_thread, None,
6472                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
6473            );
6474        });
6475    }
6476
6477    #[gpui::test]
6478    async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
6479        cx: &mut TestAppContext,
6480    ) {
6481        init_test(cx);
6482        let fs = FakeFs::new(cx.executor());
6483        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6484            .await;
6485        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6486            .await;
6487        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6488
6489        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6490        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6491
6492        let multi_workspace_a =
6493            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6494        let multi_workspace_b =
6495            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
6496
6497        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6498        let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
6499
6500        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6501        let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
6502
6503        let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
6504        let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
6505        let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
6506        let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b);
6507
6508        let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
6509
6510        sidebar_a.update_in(cx_a, |sidebar, window, cx| {
6511            sidebar.activate_archived_thread(
6512                Agent::NativeAgent,
6513                acp_thread::AgentSessionInfo {
6514                    session_id: session_id.clone(),
6515                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6516                    title: Some("Cross Window Thread".into()),
6517                    updated_at: None,
6518                    created_at: None,
6519                    meta: None,
6520                },
6521                window,
6522                cx,
6523            );
6524        });
6525        cx_a.run_until_parked();
6526
6527        assert_eq!(
6528            multi_workspace_a
6529                .read_with(cx_a, |mw, _| mw.workspaces().len())
6530                .unwrap(),
6531            1,
6532            "should not add the other window's workspace into the current window"
6533        );
6534        assert_eq!(
6535            multi_workspace_b
6536                .read_with(cx_a, |mw, _| mw.workspaces().len())
6537                .unwrap(),
6538            1,
6539            "should reuse the existing workspace in the other window"
6540        );
6541        assert!(
6542            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
6543            "should activate the window that already owns the matching workspace"
6544        );
6545        sidebar_a.read_with(cx_a, |sidebar, _| {
6546            assert_eq!(
6547                sidebar.focused_thread, None,
6548                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
6549            );
6550        });
6551        sidebar_b.read_with(cx_b, |sidebar, _| {
6552            assert_eq!(
6553                sidebar.focused_thread.as_ref(),
6554                Some(&session_id),
6555                "target window's sidebar should eagerly focus the activated archived thread"
6556            );
6557        });
6558    }
6559
6560    #[gpui::test]
6561    async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
6562        cx: &mut TestAppContext,
6563    ) {
6564        init_test(cx);
6565        let fs = FakeFs::new(cx.executor());
6566        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6567            .await;
6568        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6569
6570        let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6571        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6572
6573        let multi_workspace_b =
6574            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
6575        let multi_workspace_a =
6576            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6577
6578        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6579
6580        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6581        let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
6582
6583        let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
6584
6585        sidebar_a.update_in(cx_a, |sidebar, window, cx| {
6586            sidebar.activate_archived_thread(
6587                Agent::NativeAgent,
6588                acp_thread::AgentSessionInfo {
6589                    session_id: session_id.clone(),
6590                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])),
6591                    title: Some("Current Window Thread".into()),
6592                    updated_at: None,
6593                    created_at: None,
6594                    meta: None,
6595                },
6596                window,
6597                cx,
6598            );
6599        });
6600        cx_a.run_until_parked();
6601
6602        assert!(
6603            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
6604            "should keep activation in the current window when it already has a matching workspace"
6605        );
6606        sidebar_a.read_with(cx_a, |sidebar, _| {
6607            assert_eq!(
6608                sidebar.focused_thread.as_ref(),
6609                Some(&session_id),
6610                "current window's sidebar should eagerly focus the activated archived thread"
6611            );
6612        });
6613        assert_eq!(
6614            multi_workspace_a
6615                .read_with(cx_a, |mw, _| mw.workspaces().len())
6616                .unwrap(),
6617            1,
6618            "current window should continue reusing its existing workspace"
6619        );
6620        assert_eq!(
6621            multi_workspace_b
6622                .read_with(cx_a, |mw, _| mw.workspaces().len())
6623                .unwrap(),
6624            1,
6625            "other windows should not be activated just because they also match the saved paths"
6626        );
6627    }
6628}