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