sidebar.rs

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