sidebar.rs

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