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