sidebar.rs

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