sidebar.rs

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