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