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