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