sidebar.rs

   1use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
   2use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread};
   3use acp_thread::ThreadStatus;
   4use action_log::DiffStats;
   5use agent::ThreadStore;
   6use agent_client_protocol as acp;
   7use agent_settings::AgentSettings;
   8use chrono::Utc;
   9use db::kvp::KEY_VALUE_STORE;
  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, actions, list, prelude::*, px,
  15};
  16use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
  17use project::Event as ProjectEvent;
  18use settings::Settings;
  19use std::collections::{HashMap, HashSet};
  20use std::mem;
  21use std::path::Path;
  22use std::sync::Arc;
  23use theme::ActiveTheme;
  24use ui::{
  25    AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
  26    Tooltip, WithScrollbar, prelude::*,
  27};
  28use util::ResultExt as _;
  29use util::path_list::PathList;
  30use workspace::{
  31    MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
  32};
  33use zed_actions::editor::{MoveDown, MoveUp};
  34
  35actions!(
  36    agents_sidebar,
  37    [
  38        /// Collapses the selected entry in the workspace sidebar.
  39        CollapseSelectedEntry,
  40        /// Expands the selected entry in the workspace sidebar.
  41        ExpandSelectedEntry,
  42    ]
  43);
  44
  45const DEFAULT_WIDTH: Pixels = px(320.0);
  46const MIN_WIDTH: Pixels = px(200.0);
  47const MAX_WIDTH: Pixels = px(800.0);
  48const DEFAULT_THREADS_SHOWN: usize = 5;
  49const SIDEBAR_STATE_KEY: &str = "sidebar_state";
  50
  51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
  52enum SidebarView {
  53    #[default]
  54    ThreadList,
  55    Archive,
  56}
  57
  58fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
  59    KEY_VALUE_STORE
  60        .scoped(SIDEBAR_STATE_KEY)
  61        .read(&multi_workspace_id.to_string())
  62        .log_err()
  63        .flatten()
  64        .and_then(|json| serde_json::from_str::<bool>(&json).ok())
  65        .unwrap_or(false)
  66}
  67
  68async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) {
  69    if let Ok(json) = serde_json::to_string(&is_open) {
  70        KEY_VALUE_STORE
  71            .scoped(SIDEBAR_STATE_KEY)
  72            .write(multi_workspace_id.to_string(), json)
  73            .await
  74            .log_err();
  75    }
  76}
  77
  78#[derive(Clone, Debug)]
  79struct ActiveThreadInfo {
  80    session_id: acp::SessionId,
  81    title: SharedString,
  82    status: AgentThreadStatus,
  83    icon: IconName,
  84    icon_from_external_svg: Option<SharedString>,
  85    is_background: bool,
  86    is_title_generating: bool,
  87    diff_stats: DiffStats,
  88}
  89
  90impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
  91    fn from(info: &ActiveThreadInfo) -> Self {
  92        Self {
  93            session_id: info.session_id.clone(),
  94            cwd: None,
  95            title: Some(info.title.clone()),
  96            updated_at: Some(Utc::now()),
  97            created_at: Some(Utc::now()),
  98            meta: None,
  99        }
 100    }
 101}
 102
 103#[derive(Clone)]
 104enum ThreadEntryWorkspace {
 105    Open(Entity<Workspace>),
 106    Closed(PathList),
 107}
 108
 109#[derive(Clone)]
 110struct ThreadEntry {
 111    agent: Agent,
 112    session_info: acp_thread::AgentSessionInfo,
 113    icon: IconName,
 114    icon_from_external_svg: Option<SharedString>,
 115    status: AgentThreadStatus,
 116    workspace: ThreadEntryWorkspace,
 117    is_live: bool,
 118    is_background: bool,
 119    is_title_generating: bool,
 120    highlight_positions: Vec<usize>,
 121    worktree_name: Option<SharedString>,
 122    worktree_highlight_positions: Vec<usize>,
 123    diff_stats: DiffStats,
 124}
 125
 126#[derive(Clone)]
 127enum ListEntry {
 128    ProjectHeader {
 129        path_list: PathList,
 130        label: SharedString,
 131        workspace: Entity<Workspace>,
 132        highlight_positions: Vec<usize>,
 133        has_threads: bool,
 134    },
 135    Thread(ThreadEntry),
 136    ViewMore {
 137        path_list: PathList,
 138        remaining_count: usize,
 139        is_fully_expanded: bool,
 140    },
 141    NewThread {
 142        path_list: PathList,
 143        workspace: Entity<Workspace>,
 144    },
 145}
 146
 147impl From<ThreadEntry> for ListEntry {
 148    fn from(thread: ThreadEntry) -> Self {
 149        ListEntry::Thread(thread)
 150    }
 151}
 152
 153#[derive(Default)]
 154struct SidebarContents {
 155    entries: Vec<ListEntry>,
 156    notified_threads: HashSet<acp::SessionId>,
 157    project_header_indices: Vec<usize>,
 158}
 159
 160impl SidebarContents {
 161    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
 162        self.notified_threads.contains(session_id)
 163    }
 164}
 165
 166fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 167    let mut positions = Vec::new();
 168    let mut query_chars = query.chars().peekable();
 169
 170    for (byte_idx, candidate_char) in candidate.char_indices() {
 171        if let Some(&query_char) = query_chars.peek() {
 172            if candidate_char.eq_ignore_ascii_case(&query_char) {
 173                positions.push(byte_idx);
 174                query_chars.next();
 175            }
 176        } else {
 177            break;
 178        }
 179    }
 180
 181    if query_chars.peek().is_none() {
 182        Some(positions)
 183    } else {
 184        None
 185    }
 186}
 187
 188// TODO: The mapping from workspace root paths to git repositories needs a
 189// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
 190// thread persistence (which PathList is saved to the database), and thread
 191// querying (which PathList is used to read threads back). All of these need
 192// to agree on how repos are resolved for a given workspace, especially in
 193// multi-root and nested-repo configurations.
 194fn root_repository_snapshots(
 195    workspace: &Entity<Workspace>,
 196    cx: &App,
 197) -> Vec<project::git_store::RepositorySnapshot> {
 198    let path_list = workspace_path_list(workspace, cx);
 199    let project = workspace.read(cx).project().read(cx);
 200    project
 201        .repositories(cx)
 202        .values()
 203        .filter_map(|repo| {
 204            let snapshot = repo.read(cx).snapshot();
 205            let is_root = path_list
 206                .paths()
 207                .iter()
 208                .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
 209            is_root.then_some(snapshot)
 210        })
 211        .collect()
 212}
 213
 214fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
 215    PathList::new(&workspace.read(cx).root_paths(cx))
 216}
 217
 218fn workspace_label_from_path_list(path_list: &PathList) -> SharedString {
 219    let mut names = Vec::with_capacity(path_list.paths().len());
 220    for abs_path in path_list.paths() {
 221        if let Some(name) = abs_path.file_name() {
 222            names.push(name.to_string_lossy().to_string());
 223        }
 224    }
 225    if names.is_empty() {
 226        // TODO: Can we do something better in this case?
 227        "Empty Workspace".into()
 228    } else {
 229        names.join(", ").into()
 230    }
 231}
 232
 233pub struct Sidebar {
 234    multi_workspace: WeakEntity<MultiWorkspace>,
 235    persistence_key: Option<u64>,
 236    is_open: bool,
 237    width: Pixels,
 238    focus_handle: FocusHandle,
 239    filter_editor: Entity<Editor>,
 240    list_state: ListState,
 241    contents: SidebarContents,
 242    /// The index of the list item that currently has the keyboard focus
 243    ///
 244    /// Note: This is NOT the same as the active item.
 245    selection: Option<usize>,
 246    focused_thread: Option<acp::SessionId>,
 247    active_entry_index: Option<usize>,
 248    hovered_thread_index: Option<usize>,
 249    collapsed_groups: HashSet<PathList>,
 250    expanded_groups: HashMap<PathList, usize>,
 251    view: SidebarView,
 252    archive_view: Option<Entity<ThreadsArchiveView>>,
 253    _subscriptions: Vec<gpui::Subscription>,
 254}
 255
 256impl Sidebar {
 257    pub fn new(
 258        multi_workspace: Entity<MultiWorkspace>,
 259        window: &mut Window,
 260        cx: &mut Context<Self>,
 261    ) -> Self {
 262        let focus_handle = cx.focus_handle();
 263        cx.on_focus_in(&focus_handle, window, Self::focus_in)
 264            .detach();
 265
 266        let filter_editor = cx.new(|cx| {
 267            let mut editor = Editor::single_line(window, cx);
 268            editor.set_placeholder_text("Search…", window, cx);
 269            editor
 270        });
 271
 272        cx.subscribe_in(
 273            &multi_workspace,
 274            window,
 275            |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
 276                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
 277                    this.update_entries(cx);
 278                }
 279                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
 280                    this.subscribe_to_workspace(workspace, window, cx);
 281                    this.update_entries(cx);
 282                }
 283                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
 284                    this.update_entries(cx);
 285                }
 286            },
 287        )
 288        .detach();
 289
 290        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 291            if let editor::EditorEvent::BufferEdited = event {
 292                let query = this.filter_editor.read(cx).text(cx);
 293                if !query.is_empty() {
 294                    this.selection.take();
 295                }
 296                this.update_entries(cx);
 297                if !query.is_empty() {
 298                    this.selection = this
 299                        .contents
 300                        .entries
 301                        .iter()
 302                        .position(|entry| matches!(entry, ListEntry::Thread(_)))
 303                        .or_else(|| {
 304                            if this.contents.entries.is_empty() {
 305                                None
 306                            } else {
 307                                Some(0)
 308                            }
 309                        });
 310                }
 311            }
 312        })
 313        .detach();
 314
 315        let thread_store = ThreadStore::global(cx);
 316        cx.observe_in(&thread_store, window, |this, _, _window, cx| {
 317            this.update_entries(cx);
 318        })
 319        .detach();
 320
 321        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
 322            this.update_entries(cx);
 323        })
 324        .detach();
 325
 326        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 327        cx.defer_in(window, move |this, window, cx| {
 328            for workspace in &workspaces {
 329                this.subscribe_to_workspace(workspace, window, cx);
 330            }
 331            this.update_entries(cx);
 332        });
 333
 334        let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0);
 335        let is_open = persistence_key
 336            .map(read_sidebar_open_state)
 337            .unwrap_or(false);
 338
 339        Self {
 340            multi_workspace: multi_workspace.downgrade(),
 341            persistence_key,
 342            is_open,
 343            width: DEFAULT_WIDTH,
 344            focus_handle,
 345            filter_editor,
 346            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 347            contents: SidebarContents::default(),
 348            selection: None,
 349            focused_thread: None,
 350            active_entry_index: None,
 351            hovered_thread_index: None,
 352            collapsed_groups: HashSet::new(),
 353            expanded_groups: HashMap::new(),
 354            view: SidebarView::default(),
 355            archive_view: None,
 356            _subscriptions: Vec::new(),
 357        }
 358    }
 359
 360    fn subscribe_to_workspace(
 361        &self,
 362        workspace: &Entity<Workspace>,
 363        window: &mut Window,
 364        cx: &mut Context<Self>,
 365    ) {
 366        let project = workspace.read(cx).project().clone();
 367        cx.subscribe_in(
 368            &project,
 369            window,
 370            |this, _project, event, _window, cx| match event {
 371                ProjectEvent::WorktreeAdded(_)
 372                | ProjectEvent::WorktreeRemoved(_)
 373                | ProjectEvent::WorktreeOrderChanged => {
 374                    this.update_entries(cx);
 375                }
 376                _ => {}
 377            },
 378        )
 379        .detach();
 380
 381        let git_store = workspace.read(cx).project().read(cx).git_store().clone();
 382        cx.subscribe_in(
 383            &git_store,
 384            window,
 385            |this, _, event: &project::git_store::GitStoreEvent, window, cx| {
 386                if matches!(
 387                    event,
 388                    project::git_store::GitStoreEvent::RepositoryUpdated(
 389                        _,
 390                        project::git_store::RepositoryEvent::GitWorktreeListChanged,
 391                        _,
 392                    )
 393                ) {
 394                    this.prune_stale_worktree_workspaces(window, cx);
 395                    this.update_entries(cx);
 396                }
 397            },
 398        )
 399        .detach();
 400
 401        cx.subscribe_in(
 402            workspace,
 403            window,
 404            |this, _workspace, event: &workspace::Event, window, cx| {
 405                if let workspace::Event::PanelAdded(view) = event {
 406                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
 407                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
 408                    }
 409                }
 410            },
 411        )
 412        .detach();
 413
 414        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 415            self.subscribe_to_agent_panel(&agent_panel, window, cx);
 416        }
 417    }
 418
 419    fn subscribe_to_agent_panel(
 420        &self,
 421        agent_panel: &Entity<AgentPanel>,
 422        window: &mut Window,
 423        cx: &mut Context<Self>,
 424    ) {
 425        cx.subscribe_in(
 426            agent_panel,
 427            window,
 428            |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
 429                AgentPanelEvent::ActiveViewChanged
 430                | AgentPanelEvent::ThreadFocused
 431                | AgentPanelEvent::BackgroundThreadChanged => {
 432                    this.update_entries(cx);
 433                }
 434            },
 435        )
 436        .detach();
 437    }
 438
 439    fn all_thread_infos_for_workspace(
 440        workspace: &Entity<Workspace>,
 441        cx: &App,
 442    ) -> Vec<ActiveThreadInfo> {
 443        let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
 444            return Vec::new();
 445        };
 446        let agent_panel_ref = agent_panel.read(cx);
 447
 448        agent_panel_ref
 449            .parent_threads(cx)
 450            .into_iter()
 451            .map(|thread_view| {
 452                let thread_view_ref = thread_view.read(cx);
 453                let thread = thread_view_ref.thread.read(cx);
 454
 455                let icon = thread_view_ref.agent_icon;
 456                let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
 457                let title = thread.title();
 458                let is_native = thread_view_ref.as_native_thread(cx).is_some();
 459                let is_title_generating = is_native && thread.has_provisional_title();
 460                let session_id = thread.session_id().clone();
 461                let is_background = agent_panel_ref.is_background_thread(&session_id);
 462
 463                let status = if thread.is_waiting_for_confirmation() {
 464                    AgentThreadStatus::WaitingForConfirmation
 465                } else if thread.had_error() {
 466                    AgentThreadStatus::Error
 467                } else {
 468                    match thread.status() {
 469                        ThreadStatus::Generating => AgentThreadStatus::Running,
 470                        ThreadStatus::Idle => AgentThreadStatus::Completed,
 471                    }
 472                };
 473
 474                let diff_stats = thread.action_log().read(cx).diff_stats(cx);
 475
 476                ActiveThreadInfo {
 477                    session_id,
 478                    title,
 479                    status,
 480                    icon,
 481                    icon_from_external_svg,
 482                    is_background,
 483                    is_title_generating,
 484                    diff_stats,
 485                }
 486            })
 487            .collect()
 488    }
 489
 490    fn rebuild_contents(&mut self, cx: &App) {
 491        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 492            return;
 493        };
 494        let mw = multi_workspace.read(cx);
 495        let workspaces = mw.workspaces().to_vec();
 496        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 497
 498        self.focused_thread = active_workspace
 499            .as_ref()
 500            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
 501            .and_then(|panel| panel.read(cx).active_connection_view().cloned())
 502            .and_then(|cv| cv.read(cx).parent_id(cx));
 503
 504        let thread_store = ThreadStore::try_global(cx);
 505        let query = self.filter_editor.read(cx).text(cx);
 506
 507        let previous = mem::take(&mut self.contents);
 508
 509        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
 510            .entries
 511            .iter()
 512            .filter_map(|entry| match entry {
 513                ListEntry::Thread(thread) if thread.is_live => {
 514                    Some((thread.session_info.session_id.clone(), thread.status))
 515                }
 516                _ => None,
 517            })
 518            .collect();
 519
 520        let mut entries = Vec::new();
 521        let mut notified_threads = previous.notified_threads;
 522        // Track all session IDs we add to entries so we can prune stale
 523        // notifications without a separate pass at the end.
 524        let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
 525        // Compute active_entry_index inline during the build pass.
 526        let mut active_entry_index: Option<usize> = None;
 527
 528        // Identify absorbed workspaces in a single pass. A workspace is
 529        // "absorbed" when it points at a git worktree checkout whose main
 530        // repo is open as another workspace — its threads appear under the
 531        // main repo's header instead of getting their own.
 532        let mut main_repo_workspace: HashMap<Arc<Path>, usize> = HashMap::new();
 533        let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
 534        let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString, Arc<Path>)>> = HashMap::new();
 535        let mut absorbed_workspace_by_path: HashMap<Arc<Path>, usize> = HashMap::new();
 536
 537        for (i, workspace) in workspaces.iter().enumerate() {
 538            for snapshot in root_repository_snapshots(workspace, cx) {
 539                if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path {
 540                    main_repo_workspace
 541                        .entry(snapshot.work_directory_abs_path.clone())
 542                        .or_insert(i);
 543                    if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) {
 544                        for (ws_idx, name, ws_path) in waiting {
 545                            absorbed.insert(ws_idx, (i, name));
 546                            absorbed_workspace_by_path.insert(ws_path, ws_idx);
 547                        }
 548                    }
 549                } else {
 550                    let name: SharedString = snapshot
 551                        .work_directory_abs_path
 552                        .file_name()
 553                        .unwrap_or_default()
 554                        .to_string_lossy()
 555                        .to_string()
 556                        .into();
 557                    if let Some(&main_idx) =
 558                        main_repo_workspace.get(&snapshot.original_repo_abs_path)
 559                    {
 560                        absorbed.insert(i, (main_idx, name));
 561                        absorbed_workspace_by_path
 562                            .insert(snapshot.work_directory_abs_path.clone(), i);
 563                    } else {
 564                        pending
 565                            .entry(snapshot.original_repo_abs_path.clone())
 566                            .or_default()
 567                            .push((i, name, snapshot.work_directory_abs_path.clone()));
 568                    }
 569                }
 570            }
 571        }
 572
 573        for (ws_index, workspace) in workspaces.iter().enumerate() {
 574            if absorbed.contains_key(&ws_index) {
 575                continue;
 576            }
 577
 578            let path_list = workspace_path_list(workspace, cx);
 579            let label = workspace_label_from_path_list(&path_list);
 580
 581            let is_collapsed = self.collapsed_groups.contains(&path_list);
 582            let should_load_threads = !is_collapsed || !query.is_empty();
 583
 584            let mut threads: Vec<ThreadEntry> = Vec::new();
 585
 586            if should_load_threads {
 587                let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
 588
 589                if let Some(ref thread_store) = thread_store {
 590                    for meta in thread_store.read(cx).threads_for_paths(&path_list) {
 591                        seen_session_ids.insert(meta.id.clone());
 592                        threads.push(ThreadEntry {
 593                            agent: Agent::NativeAgent,
 594                            session_info: meta.into(),
 595                            icon: IconName::ZedAgent,
 596                            icon_from_external_svg: None,
 597                            status: AgentThreadStatus::default(),
 598                            workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 599                            is_live: false,
 600                            is_background: false,
 601                            is_title_generating: false,
 602                            highlight_positions: Vec::new(),
 603                            worktree_name: None,
 604                            worktree_highlight_positions: Vec::new(),
 605                            diff_stats: DiffStats::default(),
 606                        });
 607                    }
 608                }
 609
 610                // Load threads from linked git worktrees of this workspace's repos.
 611                if let Some(ref thread_store) = thread_store {
 612                    let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc<Path>)> =
 613                        Vec::new();
 614                    for snapshot in root_repository_snapshots(workspace, cx) {
 615                        if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
 616                            continue;
 617                        }
 618                        for git_worktree in snapshot.linked_worktrees() {
 619                            let name = git_worktree
 620                                .path
 621                                .file_name()
 622                                .unwrap_or_default()
 623                                .to_string_lossy()
 624                                .to_string();
 625                            linked_worktree_queries.push((
 626                                PathList::new(std::slice::from_ref(&git_worktree.path)),
 627                                name.into(),
 628                                Arc::from(git_worktree.path.as_path()),
 629                            ));
 630                        }
 631                    }
 632
 633                    for (worktree_path_list, worktree_name, worktree_path) in
 634                        &linked_worktree_queries
 635                    {
 636                        let target_workspace =
 637                            match absorbed_workspace_by_path.get(worktree_path.as_ref()) {
 638                                Some(&idx) => ThreadEntryWorkspace::Open(workspaces[idx].clone()),
 639                                None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
 640                            };
 641
 642                        for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) {
 643                            if !seen_session_ids.insert(meta.id.clone()) {
 644                                continue;
 645                            }
 646                            threads.push(ThreadEntry {
 647                                agent: Agent::NativeAgent,
 648                                session_info: meta.into(),
 649                                icon: IconName::ZedAgent,
 650                                icon_from_external_svg: None,
 651                                status: AgentThreadStatus::default(),
 652                                workspace: target_workspace.clone(),
 653                                is_live: false,
 654                                is_background: false,
 655                                is_title_generating: false,
 656                                highlight_positions: Vec::new(),
 657                                worktree_name: Some(worktree_name.clone()),
 658                                worktree_highlight_positions: Vec::new(),
 659                                diff_stats: DiffStats::default(),
 660                            });
 661                        }
 662                    }
 663                }
 664
 665                let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
 666
 667                if !live_infos.is_empty() {
 668                    let thread_index_by_session: HashMap<acp::SessionId, usize> = threads
 669                        .iter()
 670                        .enumerate()
 671                        .map(|(i, t)| (t.session_info.session_id.clone(), i))
 672                        .collect();
 673
 674                    for info in &live_infos {
 675                        let Some(&idx) = thread_index_by_session.get(&info.session_id) else {
 676                            continue;
 677                        };
 678
 679                        let thread = &mut threads[idx];
 680                        thread.session_info.title = Some(info.title.clone());
 681                        thread.status = info.status;
 682                        thread.icon = info.icon;
 683                        thread.icon_from_external_svg = info.icon_from_external_svg.clone();
 684                        thread.is_live = true;
 685                        thread.is_background = info.is_background;
 686                        thread.is_title_generating = info.is_title_generating;
 687                        thread.diff_stats = info.diff_stats;
 688                    }
 689                }
 690
 691                // Update notification state for live threads in the same pass.
 692                let is_active_workspace = active_workspace
 693                    .as_ref()
 694                    .is_some_and(|active| active == workspace);
 695
 696                for thread in &threads {
 697                    let session_id = &thread.session_info.session_id;
 698                    if thread.is_background && thread.status == AgentThreadStatus::Completed {
 699                        notified_threads.insert(session_id.clone());
 700                    } else if thread.status == AgentThreadStatus::Completed
 701                        && !is_active_workspace
 702                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 703                    {
 704                        notified_threads.insert(session_id.clone());
 705                    }
 706
 707                    if is_active_workspace && !thread.is_background {
 708                        notified_threads.remove(session_id);
 709                    }
 710                }
 711
 712                // Sort by created_at (newest first), falling back to updated_at
 713                // for threads without a created_at (e.g., ACP sessions).
 714                threads.sort_by(|a, b| {
 715                    let a_time = a.session_info.created_at.or(a.session_info.updated_at);
 716                    let b_time = b.session_info.created_at.or(b.session_info.updated_at);
 717                    b_time.cmp(&a_time)
 718                });
 719            }
 720
 721            if !query.is_empty() {
 722                let has_threads = !threads.is_empty();
 723
 724                let workspace_highlight_positions =
 725                    fuzzy_match_positions(&query, &label).unwrap_or_default();
 726                let workspace_matched = !workspace_highlight_positions.is_empty();
 727
 728                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
 729                for mut thread in threads {
 730                    let title = thread
 731                        .session_info
 732                        .title
 733                        .as_ref()
 734                        .map(|s| s.as_ref())
 735                        .unwrap_or("");
 736                    if let Some(positions) = fuzzy_match_positions(&query, title) {
 737                        thread.highlight_positions = positions;
 738                    }
 739                    if let Some(worktree_name) = &thread.worktree_name {
 740                        if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
 741                            thread.worktree_highlight_positions = positions;
 742                        }
 743                    }
 744                    let worktree_matched = !thread.worktree_highlight_positions.is_empty();
 745                    if workspace_matched
 746                        || !thread.highlight_positions.is_empty()
 747                        || worktree_matched
 748                    {
 749                        matched_threads.push(thread);
 750                    }
 751                }
 752
 753                if matched_threads.is_empty() && !workspace_matched {
 754                    continue;
 755                }
 756
 757                if active_entry_index.is_none()
 758                    && self.focused_thread.is_none()
 759                    && active_workspace
 760                        .as_ref()
 761                        .is_some_and(|active| active == workspace)
 762                {
 763                    active_entry_index = Some(entries.len());
 764                }
 765
 766                entries.push(ListEntry::ProjectHeader {
 767                    path_list: path_list.clone(),
 768                    label,
 769                    workspace: workspace.clone(),
 770                    highlight_positions: workspace_highlight_positions,
 771                    has_threads,
 772                });
 773
 774                // Track session IDs and compute active_entry_index as we add
 775                // thread entries.
 776                for thread in matched_threads {
 777                    current_session_ids.insert(thread.session_info.session_id.clone());
 778                    if active_entry_index.is_none() {
 779                        if let Some(focused) = &self.focused_thread {
 780                            if &thread.session_info.session_id == focused {
 781                                active_entry_index = Some(entries.len());
 782                            }
 783                        }
 784                    }
 785                    entries.push(thread.into());
 786                }
 787            } else {
 788                let has_threads = !threads.is_empty();
 789
 790                // Check if this header is the active entry before pushing it.
 791                if active_entry_index.is_none()
 792                    && self.focused_thread.is_none()
 793                    && active_workspace
 794                        .as_ref()
 795                        .is_some_and(|active| active == workspace)
 796                {
 797                    active_entry_index = Some(entries.len());
 798                }
 799
 800                entries.push(ListEntry::ProjectHeader {
 801                    path_list: path_list.clone(),
 802                    label,
 803                    workspace: workspace.clone(),
 804                    highlight_positions: Vec::new(),
 805                    has_threads,
 806                });
 807
 808                if is_collapsed {
 809                    continue;
 810                }
 811
 812                let total = threads.len();
 813
 814                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
 815                let threads_to_show =
 816                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
 817                let count = threads_to_show.min(total);
 818                let is_fully_expanded = count >= total;
 819
 820                // Track session IDs and compute active_entry_index as we add
 821                // thread entries.
 822                for thread in threads.into_iter().take(count) {
 823                    current_session_ids.insert(thread.session_info.session_id.clone());
 824                    if active_entry_index.is_none() {
 825                        if let Some(focused) = &self.focused_thread {
 826                            if &thread.session_info.session_id == focused {
 827                                active_entry_index = Some(entries.len());
 828                            }
 829                        }
 830                    }
 831                    entries.push(thread.into());
 832                }
 833
 834                if total > DEFAULT_THREADS_SHOWN {
 835                    entries.push(ListEntry::ViewMore {
 836                        path_list: path_list.clone(),
 837                        remaining_count: total.saturating_sub(count),
 838                        is_fully_expanded,
 839                    });
 840                }
 841
 842                if total == 0 {
 843                    entries.push(ListEntry::NewThread {
 844                        path_list: path_list.clone(),
 845                        workspace: workspace.clone(),
 846                    });
 847                }
 848            }
 849        }
 850
 851        // Prune stale notifications using the session IDs we collected during
 852        // the build pass (no extra scan needed).
 853        notified_threads.retain(|id| current_session_ids.contains(id));
 854
 855        let project_header_indices = entries
 856            .iter()
 857            .enumerate()
 858            .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i))
 859            .collect();
 860
 861        self.active_entry_index = active_entry_index;
 862        self.contents = SidebarContents {
 863            entries,
 864            notified_threads,
 865            project_header_indices,
 866        };
 867    }
 868
 869    fn update_entries(&mut self, cx: &mut Context<Self>) {
 870        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 871            return;
 872        };
 873        if !multi_workspace_enabled(cx) {
 874            return;
 875        }
 876
 877        let had_notifications = self.has_notifications(cx);
 878
 879        let scroll_position = self.list_state.logical_scroll_top();
 880
 881        self.rebuild_contents(cx);
 882
 883        self.list_state.reset(self.contents.entries.len());
 884        self.list_state.scroll_to(scroll_position);
 885
 886        if had_notifications != self.has_notifications(cx) {
 887            multi_workspace.update(cx, |_, cx| {
 888                cx.notify();
 889            });
 890        }
 891
 892        cx.notify();
 893    }
 894
 895    fn render_list_entry(
 896        &mut self,
 897        ix: usize,
 898        window: &mut Window,
 899        cx: &mut Context<Self>,
 900    ) -> AnyElement {
 901        let Some(entry) = self.contents.entries.get(ix) else {
 902            return div().into_any_element();
 903        };
 904        let is_focused = self.focus_handle.is_focused(window)
 905            || self.filter_editor.focus_handle(cx).is_focused(window);
 906        // is_selected means the keyboard selector is here.
 907        let is_selected = is_focused && self.selection == Some(ix);
 908
 909        let is_group_header_after_first =
 910            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
 911
 912        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
 913
 914        let rendered = match entry {
 915            ListEntry::ProjectHeader {
 916                path_list,
 917                label,
 918                workspace,
 919                highlight_positions,
 920                has_threads,
 921            } => self.render_project_header(
 922                ix,
 923                false,
 924                path_list,
 925                label,
 926                workspace,
 927                highlight_positions,
 928                *has_threads,
 929                is_selected,
 930                docked_right,
 931                cx,
 932            ),
 933            ListEntry::Thread(thread) => {
 934                self.render_thread(ix, thread, is_selected, docked_right, cx)
 935            }
 936            ListEntry::ViewMore {
 937                path_list,
 938                remaining_count,
 939                is_fully_expanded,
 940            } => self.render_view_more(
 941                ix,
 942                path_list,
 943                *remaining_count,
 944                *is_fully_expanded,
 945                is_selected,
 946                cx,
 947            ),
 948            ListEntry::NewThread {
 949                path_list,
 950                workspace,
 951            } => self.render_new_thread(ix, path_list, workspace, is_selected, cx),
 952        };
 953
 954        if is_group_header_after_first {
 955            v_flex()
 956                .w_full()
 957                .border_t_1()
 958                .border_color(cx.theme().colors().border_variant)
 959                .child(rendered)
 960                .into_any_element()
 961        } else {
 962            rendered
 963        }
 964    }
 965
 966    fn render_project_header(
 967        &self,
 968        ix: usize,
 969        is_sticky: bool,
 970        path_list: &PathList,
 971        label: &SharedString,
 972        workspace: &Entity<Workspace>,
 973        highlight_positions: &[usize],
 974        has_threads: bool,
 975        is_selected: bool,
 976        docked_right: bool,
 977        cx: &mut Context<Self>,
 978    ) -> AnyElement {
 979        let id_prefix = if is_sticky { "sticky-" } else { "" };
 980        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
 981        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
 982        let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}"));
 983
 984        let is_collapsed = self.collapsed_groups.contains(path_list);
 985        let disclosure_icon = if is_collapsed {
 986            IconName::ChevronRight
 987        } else {
 988            IconName::ChevronDown
 989        };
 990        let workspace_for_new_thread = workspace.clone();
 991        let workspace_for_remove = workspace.clone();
 992        // let workspace_for_activate = workspace.clone();
 993
 994        let path_list_for_toggle = path_list.clone();
 995        let path_list_for_collapse = path_list.clone();
 996        let view_more_expanded = self.expanded_groups.contains_key(path_list);
 997
 998        let multi_workspace = self.multi_workspace.upgrade();
 999        let workspace_count = multi_workspace
1000            .as_ref()
1001            .map_or(0, |mw| mw.read(cx).workspaces().len());
1002        let is_active_workspace = self.focused_thread.is_none()
1003            && multi_workspace
1004                .as_ref()
1005                .is_some_and(|mw| mw.read(cx).workspace() == workspace);
1006
1007        let label = if highlight_positions.is_empty() {
1008            Label::new(label.clone())
1009                .size(LabelSize::Small)
1010                .color(Color::Muted)
1011                .into_any_element()
1012        } else {
1013            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1014                .size(LabelSize::Small)
1015                .color(Color::Muted)
1016                .into_any_element()
1017        };
1018
1019        ListItem::new(id)
1020            .group_name(group_name)
1021            .toggle_state(is_active_workspace)
1022            .focused(is_selected)
1023            .docked_right(docked_right)
1024            .child(
1025                h_flex()
1026                    .relative()
1027                    .min_w_0()
1028                    .w_full()
1029                    .py_1()
1030                    .gap_1p5()
1031                    .child(
1032                        Icon::new(disclosure_icon)
1033                            .size(IconSize::Small)
1034                            .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
1035                    )
1036                    .child(label),
1037            )
1038            .end_hover_gradient_overlay(true)
1039            .end_hover_slot(
1040                h_flex()
1041                    .gap_1()
1042                    .when(workspace_count > 1, |this| {
1043                        this.child(
1044                            IconButton::new(
1045                                SharedString::from(format!(
1046                                    "{id_prefix}project-header-remove-{ix}",
1047                                )),
1048                                IconName::Close,
1049                            )
1050                            .icon_size(IconSize::Small)
1051                            .icon_color(Color::Muted)
1052                            .tooltip(Tooltip::text("Remove Project"))
1053                            .on_click(cx.listener(
1054                                move |this, _, window, cx| {
1055                                    this.remove_workspace(&workspace_for_remove, window, cx);
1056                                },
1057                            )),
1058                        )
1059                    })
1060                    .when(view_more_expanded && !is_collapsed, |this| {
1061                        this.child(
1062                            IconButton::new(
1063                                SharedString::from(format!(
1064                                    "{id_prefix}project-header-collapse-{ix}",
1065                                )),
1066                                IconName::ListCollapse,
1067                            )
1068                            .icon_size(IconSize::Small)
1069                            .icon_color(Color::Muted)
1070                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
1071                            .on_click(cx.listener({
1072                                let path_list_for_collapse = path_list_for_collapse.clone();
1073                                move |this, _, _window, cx| {
1074                                    this.selection = None;
1075                                    this.expanded_groups.remove(&path_list_for_collapse);
1076                                    this.update_entries(cx);
1077                                }
1078                            })),
1079                        )
1080                    })
1081                    .when(has_threads, |this| {
1082                        this.child(
1083                            IconButton::new(ib_id, IconName::NewThread)
1084                                .icon_size(IconSize::Small)
1085                                .icon_color(Color::Muted)
1086                                .tooltip(Tooltip::text("New Thread"))
1087                                .on_click(cx.listener(move |this, _, window, cx| {
1088                                    this.selection = None;
1089                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
1090                                })),
1091                        )
1092                    }),
1093            )
1094            .on_click(cx.listener(move |this, _, window, cx| {
1095                this.selection = None;
1096                this.toggle_collapse(&path_list_for_toggle, window, cx);
1097            }))
1098            // TODO: Decide if we really want the header to be activating different workspaces
1099            // .on_click(cx.listener(move |this, _, window, cx| {
1100            //     this.selection = None;
1101            //     this.activate_workspace(&workspace_for_activate, window, cx);
1102            // }))
1103            .into_any_element()
1104    }
1105
1106    fn render_sticky_header(
1107        &self,
1108        docked_right: bool,
1109        window: &mut Window,
1110        cx: &mut Context<Self>,
1111    ) -> Option<AnyElement> {
1112        let scroll_top = self.list_state.logical_scroll_top();
1113
1114        let &header_idx = self
1115            .contents
1116            .project_header_indices
1117            .iter()
1118            .rev()
1119            .find(|&&idx| idx <= scroll_top.item_ix)?;
1120
1121        let needs_sticky = header_idx < scroll_top.item_ix
1122            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1123
1124        if !needs_sticky {
1125            return None;
1126        }
1127
1128        let ListEntry::ProjectHeader {
1129            path_list,
1130            label,
1131            workspace,
1132            highlight_positions,
1133            has_threads,
1134        } = self.contents.entries.get(header_idx)?
1135        else {
1136            return None;
1137        };
1138
1139        let is_focused = self.focus_handle.is_focused(window)
1140            || self.filter_editor.focus_handle(cx).is_focused(window);
1141        let is_selected = is_focused && self.selection == Some(header_idx);
1142
1143        let header_element = self.render_project_header(
1144            header_idx,
1145            true,
1146            &path_list,
1147            &label,
1148            &workspace,
1149            &highlight_positions,
1150            *has_threads,
1151            is_selected,
1152            docked_right,
1153            cx,
1154        );
1155
1156        let top_offset = self
1157            .contents
1158            .project_header_indices
1159            .iter()
1160            .find(|&&idx| idx > header_idx)
1161            .and_then(|&next_idx| {
1162                let bounds = self.list_state.bounds_for_item(next_idx)?;
1163                let viewport = self.list_state.viewport_bounds();
1164                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1165                let header_height = bounds.size.height;
1166                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1167            })
1168            .unwrap_or(px(0.));
1169
1170        let element = v_flex()
1171            .absolute()
1172            .top(top_offset)
1173            .left_0()
1174            .w_full()
1175            .bg(cx.theme().colors().surface_background)
1176            .border_b_1()
1177            .border_color(cx.theme().colors().border_variant)
1178            .child(header_element)
1179            .into_any_element();
1180
1181        Some(element)
1182    }
1183
1184    fn activate_workspace(
1185        &mut self,
1186        workspace: &Entity<Workspace>,
1187        window: &mut Window,
1188        cx: &mut Context<Self>,
1189    ) {
1190        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1191            return;
1192        };
1193
1194        multi_workspace.update(cx, |multi_workspace, cx| {
1195            multi_workspace.activate(workspace.clone(), cx);
1196        });
1197
1198        multi_workspace.update(cx, |multi_workspace, cx| {
1199            multi_workspace.focus_active_workspace(window, cx);
1200        });
1201    }
1202
1203    fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1204        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1205            return;
1206        };
1207        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
1208
1209        // Collect all worktree paths that are currently listed by any main
1210        // repo open in any workspace.
1211        let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
1212        for workspace in &workspaces {
1213            for snapshot in root_repository_snapshots(workspace, cx) {
1214                if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
1215                    continue;
1216                }
1217                for git_worktree in snapshot.linked_worktrees() {
1218                    known_worktree_paths.insert(git_worktree.path.to_path_buf());
1219                }
1220            }
1221        }
1222
1223        // Find workspaces that consist of exactly one root folder which is a
1224        // stale worktree checkout. Multi-root workspaces are never pruned —
1225        // losing one worktree shouldn't destroy a workspace that also
1226        // contains other folders.
1227        let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
1228        for workspace in &workspaces {
1229            let path_list = workspace_path_list(workspace, cx);
1230            if path_list.paths().len() != 1 {
1231                continue;
1232            }
1233            let should_prune = root_repository_snapshots(workspace, cx)
1234                .iter()
1235                .any(|snapshot| {
1236                    snapshot.work_directory_abs_path != snapshot.original_repo_abs_path
1237                        && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
1238                });
1239            if should_prune {
1240                to_remove.push(workspace.clone());
1241            }
1242        }
1243
1244        for workspace in &to_remove {
1245            self.remove_workspace(workspace, window, cx);
1246        }
1247    }
1248
1249    fn remove_workspace(
1250        &mut self,
1251        workspace: &Entity<Workspace>,
1252        window: &mut Window,
1253        cx: &mut Context<Self>,
1254    ) {
1255        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1256            return;
1257        };
1258
1259        multi_workspace.update(cx, |multi_workspace, cx| {
1260            let Some(index) = multi_workspace
1261                .workspaces()
1262                .iter()
1263                .position(|w| w == workspace)
1264            else {
1265                return;
1266            };
1267            multi_workspace.remove_workspace(index, window, cx);
1268        });
1269    }
1270
1271    fn toggle_collapse(
1272        &mut self,
1273        path_list: &PathList,
1274        _window: &mut Window,
1275        cx: &mut Context<Self>,
1276    ) {
1277        if self.collapsed_groups.contains(path_list) {
1278            self.collapsed_groups.remove(path_list);
1279        } else {
1280            self.collapsed_groups.insert(path_list.clone());
1281        }
1282        self.update_entries(cx);
1283    }
1284
1285    fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
1286
1287    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1288        if self.reset_filter_editor_text(window, cx) {
1289            self.update_entries(cx);
1290        } else {
1291            self.focus_handle.focus(window, cx);
1292        }
1293    }
1294
1295    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1296        self.filter_editor.update(cx, |editor, cx| {
1297            if editor.buffer().read(cx).len(cx).0 > 0 {
1298                editor.set_text("", window, cx);
1299                true
1300            } else {
1301                false
1302            }
1303        })
1304    }
1305
1306    fn has_filter_query(&self, cx: &App) -> bool {
1307        !self.filter_editor.read(cx).text(cx).is_empty()
1308    }
1309
1310    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1311        self.select_next(&SelectNext, window, cx);
1312    }
1313
1314    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1315        self.select_previous(&SelectPrevious, window, cx);
1316    }
1317
1318    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1319        let next = match self.selection {
1320            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1321            None if !self.contents.entries.is_empty() => 0,
1322            _ => return,
1323        };
1324        self.selection = Some(next);
1325        self.list_state.scroll_to_reveal_item(next);
1326        cx.notify();
1327    }
1328
1329    fn select_previous(
1330        &mut self,
1331        _: &SelectPrevious,
1332        _window: &mut Window,
1333        cx: &mut Context<Self>,
1334    ) {
1335        let prev = match self.selection {
1336            Some(ix) if ix > 0 => ix - 1,
1337            None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1,
1338            _ => return,
1339        };
1340        self.selection = Some(prev);
1341        self.list_state.scroll_to_reveal_item(prev);
1342        cx.notify();
1343    }
1344
1345    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1346        if !self.contents.entries.is_empty() {
1347            self.selection = Some(0);
1348            self.list_state.scroll_to_reveal_item(0);
1349            cx.notify();
1350        }
1351    }
1352
1353    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1354        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1355            self.selection = Some(last);
1356            self.list_state.scroll_to_reveal_item(last);
1357            cx.notify();
1358        }
1359    }
1360
1361    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1362        let Some(ix) = self.selection else { return };
1363        let Some(entry) = self.contents.entries.get(ix) else {
1364            return;
1365        };
1366
1367        match entry {
1368            ListEntry::ProjectHeader { workspace, .. } => {
1369                let workspace = workspace.clone();
1370                self.activate_workspace(&workspace, window, cx);
1371            }
1372            ListEntry::Thread(thread) => {
1373                let session_info = thread.session_info.clone();
1374                match &thread.workspace {
1375                    ThreadEntryWorkspace::Open(workspace) => {
1376                        let workspace = workspace.clone();
1377                        self.activate_thread(
1378                            thread.agent.clone(),
1379                            session_info,
1380                            &workspace,
1381                            window,
1382                            cx,
1383                        );
1384                    }
1385                    ThreadEntryWorkspace::Closed(path_list) => {
1386                        self.open_workspace_and_activate_thread(
1387                            thread.agent.clone(),
1388                            session_info,
1389                            path_list.clone(),
1390                            window,
1391                            cx,
1392                        );
1393                    }
1394                }
1395            }
1396            ListEntry::ViewMore {
1397                path_list,
1398                is_fully_expanded,
1399                ..
1400            } => {
1401                let path_list = path_list.clone();
1402                if *is_fully_expanded {
1403                    self.expanded_groups.remove(&path_list);
1404                } else {
1405                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1406                    self.expanded_groups.insert(path_list, current + 1);
1407                }
1408                self.update_entries(cx);
1409            }
1410            ListEntry::NewThread { workspace, .. } => {
1411                let workspace = workspace.clone();
1412                self.create_new_thread(&workspace, window, cx);
1413            }
1414        }
1415    }
1416
1417    fn activate_thread(
1418        &mut self,
1419        agent: Agent,
1420        session_info: acp_thread::AgentSessionInfo,
1421        workspace: &Entity<Workspace>,
1422        window: &mut Window,
1423        cx: &mut Context<Self>,
1424    ) {
1425        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1426            return;
1427        };
1428
1429        multi_workspace.update(cx, |multi_workspace, cx| {
1430            multi_workspace.activate(workspace.clone(), cx);
1431        });
1432
1433        workspace.update(cx, |workspace, cx| {
1434            workspace.open_panel::<AgentPanel>(window, cx);
1435        });
1436
1437        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1438            agent_panel.update(cx, |panel, cx| {
1439                panel.load_agent_thread(
1440                    agent,
1441                    session_info.session_id,
1442                    session_info.cwd,
1443                    session_info.title,
1444                    true,
1445                    window,
1446                    cx,
1447                );
1448            });
1449        }
1450
1451        self.update_entries(cx);
1452    }
1453
1454    fn open_workspace_and_activate_thread(
1455        &mut self,
1456        agent: Agent,
1457        session_info: acp_thread::AgentSessionInfo,
1458        path_list: PathList,
1459        window: &mut Window,
1460        cx: &mut Context<Self>,
1461    ) {
1462        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1463            return;
1464        };
1465
1466        let paths: Vec<std::path::PathBuf> =
1467            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
1468
1469        let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
1470
1471        cx.spawn_in(window, async move |this, cx| {
1472            let workspace = open_task.await?;
1473            this.update_in(cx, |this, window, cx| {
1474                this.activate_thread(agent, session_info, &workspace, window, cx);
1475            })?;
1476            anyhow::Ok(())
1477        })
1478        .detach_and_log_err(cx);
1479    }
1480
1481    fn find_open_workspace_for_path_list(
1482        &self,
1483        path_list: &PathList,
1484        cx: &App,
1485    ) -> Option<Entity<Workspace>> {
1486        let multi_workspace = self.multi_workspace.upgrade()?;
1487        multi_workspace
1488            .read(cx)
1489            .workspaces()
1490            .iter()
1491            .find(|workspace| workspace_path_list(workspace, cx).paths() == path_list.paths())
1492            .cloned()
1493    }
1494
1495    fn activate_archived_thread(
1496        &mut self,
1497        agent: Agent,
1498        session_info: acp_thread::AgentSessionInfo,
1499        window: &mut Window,
1500        cx: &mut Context<Self>,
1501    ) {
1502        let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| {
1503            thread_store
1504                .read(cx)
1505                .thread_from_session_id(&session_info.session_id)
1506                .map(|thread| thread.folder_paths.clone())
1507        });
1508        let path_list = saved_path_list.or_else(|| {
1509            // we don't have saved metadata, so create path list based on the cwd
1510            session_info
1511                .cwd
1512                .as_ref()
1513                .map(|cwd| PathList::new(&[cwd.to_path_buf()]))
1514        });
1515
1516        if let Some(path_list) = path_list {
1517            if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) {
1518                self.activate_thread(agent, session_info, &workspace, window, cx);
1519            } else {
1520                self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
1521            }
1522            return;
1523        }
1524
1525        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
1526            w.read(cx)
1527                .workspaces()
1528                .get(w.read(cx).active_workspace_index())
1529                .cloned()
1530        });
1531
1532        if let Some(workspace) = active_workspace {
1533            self.activate_thread(agent, session_info, &workspace, window, cx);
1534        }
1535    }
1536
1537    fn expand_selected_entry(
1538        &mut self,
1539        _: &ExpandSelectedEntry,
1540        _window: &mut Window,
1541        cx: &mut Context<Self>,
1542    ) {
1543        let Some(ix) = self.selection else { return };
1544
1545        match self.contents.entries.get(ix) {
1546            Some(ListEntry::ProjectHeader { path_list, .. }) => {
1547                if self.collapsed_groups.contains(path_list) {
1548                    let path_list = path_list.clone();
1549                    self.collapsed_groups.remove(&path_list);
1550                    self.update_entries(cx);
1551                } else if ix + 1 < self.contents.entries.len() {
1552                    self.selection = Some(ix + 1);
1553                    self.list_state.scroll_to_reveal_item(ix + 1);
1554                    cx.notify();
1555                }
1556            }
1557            _ => {}
1558        }
1559    }
1560
1561    fn collapse_selected_entry(
1562        &mut self,
1563        _: &CollapseSelectedEntry,
1564        _window: &mut Window,
1565        cx: &mut Context<Self>,
1566    ) {
1567        let Some(ix) = self.selection else { return };
1568
1569        match self.contents.entries.get(ix) {
1570            Some(ListEntry::ProjectHeader { path_list, .. }) => {
1571                if !self.collapsed_groups.contains(path_list) {
1572                    let path_list = path_list.clone();
1573                    self.collapsed_groups.insert(path_list);
1574                    self.update_entries(cx);
1575                }
1576            }
1577            Some(
1578                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
1579            ) => {
1580                for i in (0..ix).rev() {
1581                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
1582                        self.contents.entries.get(i)
1583                    {
1584                        let path_list = path_list.clone();
1585                        self.selection = Some(i);
1586                        self.collapsed_groups.insert(path_list);
1587                        self.update_entries(cx);
1588                        break;
1589                    }
1590                }
1591            }
1592            None => {}
1593        }
1594    }
1595
1596    fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
1597        let Some(thread_store) = ThreadStore::try_global(cx) else {
1598            return;
1599        };
1600        thread_store.update(cx, |store, cx| {
1601            store
1602                .delete_thread(session_id.clone(), cx)
1603                .detach_and_log_err(cx);
1604        });
1605    }
1606
1607    fn remove_selected_thread(
1608        &mut self,
1609        _: &RemoveSelectedThread,
1610        _window: &mut Window,
1611        cx: &mut Context<Self>,
1612    ) {
1613        let Some(ix) = self.selection else {
1614            return;
1615        };
1616        let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
1617            return;
1618        };
1619        if thread.agent != Agent::NativeAgent {
1620            return;
1621        }
1622        let session_id = thread.session_info.session_id.clone();
1623        self.delete_thread(&session_id, cx);
1624    }
1625
1626    fn render_thread(
1627        &self,
1628        ix: usize,
1629        thread: &ThreadEntry,
1630        is_focused: bool,
1631        docked_right: bool,
1632        cx: &mut Context<Self>,
1633    ) -> AnyElement {
1634        let has_notification = self
1635            .contents
1636            .is_thread_notified(&thread.session_info.session_id);
1637
1638        let title: SharedString = thread
1639            .session_info
1640            .title
1641            .clone()
1642            .unwrap_or_else(|| "Untitled".into());
1643        let session_info = thread.session_info.clone();
1644        let thread_workspace = thread.workspace.clone();
1645
1646        let is_hovered = self.hovered_thread_index == Some(ix);
1647        let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id);
1648        let can_delete = thread.agent == Agent::NativeAgent;
1649        let session_id_for_delete = thread.session_info.session_id.clone();
1650        let focus_handle = self.focus_handle.clone();
1651
1652        let id = SharedString::from(format!("thread-entry-{}", ix));
1653
1654        let timestamp = thread
1655            .session_info
1656            .created_at
1657            .or(thread.session_info.updated_at)
1658            .map(|entry_time| {
1659                let now = Utc::now();
1660                let duration = now.signed_duration_since(entry_time);
1661
1662                let minutes = duration.num_minutes();
1663                let hours = duration.num_hours();
1664                let days = duration.num_days();
1665                let weeks = days / 7;
1666                let months = days / 30;
1667
1668                if minutes < 60 {
1669                    format!("{}m", minutes.max(1))
1670                } else if hours < 24 {
1671                    format!("{}h", hours)
1672                } else if weeks < 4 {
1673                    format!("{}w", weeks.max(1))
1674                } else {
1675                    format!("{}mo", months.max(1))
1676                }
1677            });
1678
1679        ThreadItem::new(id, title)
1680            .icon(thread.icon)
1681            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
1682                this.custom_icon_from_external_svg(svg)
1683            })
1684            .when_some(thread.worktree_name.clone(), |this, name| {
1685                this.worktree(name)
1686            })
1687            .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
1688            .when_some(timestamp, |this, ts| this.timestamp(ts))
1689            .highlight_positions(thread.highlight_positions.to_vec())
1690            .status(thread.status)
1691            .generating_title(thread.is_title_generating)
1692            .notified(has_notification)
1693            .when(thread.diff_stats.lines_added > 0, |this| {
1694                this.added(thread.diff_stats.lines_added as usize)
1695            })
1696            .when(thread.diff_stats.lines_removed > 0, |this| {
1697                this.removed(thread.diff_stats.lines_removed as usize)
1698            })
1699            .selected(is_selected)
1700            .focused(is_focused)
1701            .docked_right(docked_right)
1702            .hovered(is_hovered)
1703            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
1704                if *is_hovered {
1705                    this.hovered_thread_index = Some(ix);
1706                } else if this.hovered_thread_index == Some(ix) {
1707                    this.hovered_thread_index = None;
1708                }
1709                cx.notify();
1710            }))
1711            .when(is_hovered && can_delete, |this| {
1712                this.action_slot(
1713                    IconButton::new("delete-thread", IconName::Trash)
1714                        .icon_size(IconSize::Small)
1715                        .icon_color(Color::Muted)
1716                        .tooltip({
1717                            let focus_handle = focus_handle.clone();
1718                            move |_window, cx| {
1719                                Tooltip::for_action_in(
1720                                    "Delete Thread",
1721                                    &RemoveSelectedThread,
1722                                    &focus_handle,
1723                                    cx,
1724                                )
1725                            }
1726                        })
1727                        .on_click({
1728                            let session_id = session_id_for_delete.clone();
1729                            cx.listener(move |this, _, _window, cx| {
1730                                this.delete_thread(&session_id, cx);
1731                                cx.stop_propagation();
1732                            })
1733                        }),
1734                )
1735            })
1736            .on_click({
1737                let agent = thread.agent.clone();
1738                cx.listener(move |this, _, window, cx| {
1739                    this.selection = None;
1740                    match &thread_workspace {
1741                        ThreadEntryWorkspace::Open(workspace) => {
1742                            this.activate_thread(
1743                                agent.clone(),
1744                                session_info.clone(),
1745                                workspace,
1746                                window,
1747                                cx,
1748                            );
1749                        }
1750                        ThreadEntryWorkspace::Closed(path_list) => {
1751                            this.open_workspace_and_activate_thread(
1752                                agent.clone(),
1753                                session_info.clone(),
1754                                path_list.clone(),
1755                                window,
1756                                cx,
1757                            );
1758                        }
1759                    }
1760                })
1761            })
1762            .into_any_element()
1763    }
1764
1765    fn render_filter_input(&self) -> impl IntoElement {
1766        self.filter_editor.clone()
1767    }
1768
1769    fn render_view_more(
1770        &self,
1771        ix: usize,
1772        path_list: &PathList,
1773        remaining_count: usize,
1774        is_fully_expanded: bool,
1775        is_selected: bool,
1776        cx: &mut Context<Self>,
1777    ) -> AnyElement {
1778        let path_list = path_list.clone();
1779        let id = SharedString::from(format!("view-more-{}", ix));
1780
1781        let (icon, label) = if is_fully_expanded {
1782            (IconName::ListCollapse, "Collapse")
1783        } else {
1784            (IconName::Plus, "View More")
1785        };
1786
1787        ListItem::new(id)
1788            .focused(is_selected)
1789            .child(
1790                h_flex()
1791                    .py_1()
1792                    .gap_1p5()
1793                    .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
1794                    .child(Label::new(label).color(Color::Muted))
1795                    .when(!is_fully_expanded, |this| {
1796                        this.child(
1797                            Label::new(format!("({})", remaining_count))
1798                                .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
1799                        )
1800                    }),
1801            )
1802            .on_click(cx.listener(move |this, _, _window, cx| {
1803                this.selection = None;
1804                if is_fully_expanded {
1805                    this.expanded_groups.remove(&path_list);
1806                } else {
1807                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
1808                    this.expanded_groups.insert(path_list.clone(), current + 1);
1809                }
1810                this.update_entries(cx);
1811            }))
1812            .into_any_element()
1813    }
1814
1815    fn create_new_thread(
1816        &mut self,
1817        workspace: &Entity<Workspace>,
1818        window: &mut Window,
1819        cx: &mut Context<Self>,
1820    ) {
1821        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1822            return;
1823        };
1824
1825        multi_workspace.update(cx, |multi_workspace, cx| {
1826            multi_workspace.activate(workspace.clone(), cx);
1827        });
1828
1829        workspace.update(cx, |workspace, cx| {
1830            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
1831                agent_panel.update(cx, |panel, cx| {
1832                    panel.new_thread(&NewThread, window, cx);
1833                });
1834            }
1835            workspace.focus_panel::<AgentPanel>(window, cx);
1836        });
1837    }
1838
1839    fn render_new_thread(
1840        &self,
1841        ix: usize,
1842        _path_list: &PathList,
1843        workspace: &Entity<Workspace>,
1844        is_selected: bool,
1845        cx: &mut Context<Self>,
1846    ) -> AnyElement {
1847        let workspace = workspace.clone();
1848
1849        div()
1850            .w_full()
1851            .p_2()
1852            .pt_1p5()
1853            .child(
1854                Button::new(
1855                    SharedString::from(format!("new-thread-btn-{}", ix)),
1856                    "New Thread",
1857                )
1858                .full_width()
1859                .style(ButtonStyle::Outlined)
1860                .start_icon(
1861                    Icon::new(IconName::Plus)
1862                        .size(IconSize::Small)
1863                        .color(Color::Muted),
1864                )
1865                .toggle_state(is_selected)
1866                .on_click(cx.listener(move |this, _, window, cx| {
1867                    this.selection = None;
1868                    this.create_new_thread(&workspace, window, cx);
1869                })),
1870            )
1871            .into_any_element()
1872    }
1873
1874    fn render_thread_list_header(
1875        &self,
1876        docked_right: bool,
1877        cx: &mut Context<Self>,
1878    ) -> impl IntoElement {
1879        let has_query = self.has_filter_query(cx);
1880
1881        h_flex()
1882            .h(Tab::container_height(cx))
1883            .flex_none()
1884            .gap_1p5()
1885            .border_b_1()
1886            .border_color(cx.theme().colors().border)
1887            .when(!docked_right, |this| {
1888                this.child(self.render_sidebar_toggle_button(false, cx))
1889            })
1890            .child(self.render_filter_input())
1891            .child(
1892                h_flex()
1893                    .gap_0p5()
1894                    .when(!docked_right, |this| this.pr_1p5())
1895                    .when(has_query, |this| {
1896                        this.child(
1897                            IconButton::new("clear_filter", IconName::Close)
1898                                .shape(IconButtonShape::Square)
1899                                .tooltip(Tooltip::text("Clear Search"))
1900                                .on_click(cx.listener(|this, _, window, cx| {
1901                                    this.reset_filter_editor_text(window, cx);
1902                                    this.update_entries(cx);
1903                                })),
1904                        )
1905                    })
1906                    .child(
1907                        IconButton::new("archive", IconName::Archive)
1908                            .icon_size(IconSize::Small)
1909                            .tooltip(Tooltip::text("Archive"))
1910                            .on_click(cx.listener(|this, _, window, cx| {
1911                                this.show_archive(window, cx);
1912                            })),
1913                    ),
1914            )
1915            .when(docked_right, |this| {
1916                this.pl_2()
1917                    .pr_0p5()
1918                    .child(self.render_sidebar_toggle_button(true, cx))
1919            })
1920    }
1921
1922    fn render_sidebar_toggle_button(
1923        &self,
1924        docked_right: bool,
1925        cx: &mut Context<Self>,
1926    ) -> impl IntoElement {
1927        let icon = if docked_right {
1928            IconName::ThreadsSidebarRightOpen
1929        } else {
1930            IconName::ThreadsSidebarLeftOpen
1931        };
1932
1933        h_flex()
1934            .h_full()
1935            .px_1()
1936            .map(|this| {
1937                if docked_right {
1938                    this.pr_1p5().border_l_1()
1939                } else {
1940                    this.border_r_1()
1941                }
1942            })
1943            .border_color(cx.theme().colors().border_variant)
1944            .child(
1945                IconButton::new("sidebar-close-toggle", icon)
1946                    .icon_size(IconSize::Small)
1947                    .tooltip(move |_, cx| {
1948                        Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
1949                    })
1950                    .on_click(|_, window, cx| {
1951                        window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
1952                    }),
1953            )
1954    }
1955}
1956
1957impl Sidebar {
1958    pub fn is_open(&self) -> bool {
1959        self.is_open
1960    }
1961
1962    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1963        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
1964            w.read(cx)
1965                .workspaces()
1966                .get(w.read(cx).active_workspace_index())
1967                .cloned()
1968        }) else {
1969            return;
1970        };
1971
1972        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
1973            return;
1974        };
1975
1976        let thread_store = agent_panel.read(cx).thread_store().clone();
1977        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
1978        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
1979        let agent_server_store = active_workspace
1980            .read(cx)
1981            .project()
1982            .read(cx)
1983            .agent_server_store()
1984            .clone();
1985
1986        let archive_view = cx.new(|cx| {
1987            ThreadsArchiveView::new(
1988                agent_connection_store,
1989                agent_server_store,
1990                thread_store,
1991                fs,
1992                window,
1993                cx,
1994            )
1995        });
1996        let subscription = cx.subscribe_in(
1997            &archive_view,
1998            window,
1999            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
2000                ThreadsArchiveViewEvent::Close => {
2001                    this.show_thread_list(window, cx);
2002                }
2003                ThreadsArchiveViewEvent::OpenThread {
2004                    agent,
2005                    session_info,
2006                } => {
2007                    this.show_thread_list(window, cx);
2008                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
2009                }
2010            },
2011        );
2012
2013        self._subscriptions.push(subscription);
2014        self.archive_view = Some(archive_view);
2015        self.view = SidebarView::Archive;
2016        cx.notify();
2017    }
2018
2019    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2020        self.view = SidebarView::ThreadList;
2021        self.archive_view = None;
2022        self._subscriptions.clear();
2023        window.focus(&self.focus_handle, cx);
2024        cx.notify();
2025    }
2026
2027    pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
2028        if self.is_open == open {
2029            return;
2030        }
2031        self.is_open = open;
2032        cx.notify();
2033        if let Some(key) = self.persistence_key {
2034            let is_open = self.is_open;
2035            cx.background_spawn(async move {
2036                save_sidebar_open_state(key, is_open).await;
2037            })
2038            .detach();
2039        }
2040    }
2041
2042    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2043        let new_state = !self.is_open;
2044        self.set_open(new_state, cx);
2045        if new_state {
2046            cx.focus_self(window);
2047        }
2048    }
2049
2050    pub fn focus_or_unfocus(
2051        &mut self,
2052        workspace: &mut Workspace,
2053        window: &mut Window,
2054        cx: &mut Context<Self>,
2055    ) {
2056        if self.is_open {
2057            let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
2058            if sidebar_is_focused {
2059                let active_pane = workspace.active_pane().clone();
2060                let pane_focus = active_pane.read(cx).focus_handle(cx);
2061                window.focus(&pane_focus, cx);
2062            } else {
2063                cx.focus_self(window);
2064            }
2065        } else {
2066            self.set_open(true, cx);
2067            cx.focus_self(window);
2068        }
2069    }
2070
2071    pub fn width(&self, _cx: &App) -> Pixels {
2072        self.width
2073    }
2074
2075    pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
2076        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
2077        cx.notify();
2078    }
2079
2080    pub fn has_notifications(&self, _cx: &App) -> bool {
2081        !self.contents.notified_threads.is_empty()
2082    }
2083}
2084
2085impl Focusable for Sidebar {
2086    fn focus_handle(&self, cx: &App) -> FocusHandle {
2087        self.filter_editor.focus_handle(cx)
2088    }
2089}
2090
2091impl Render for Sidebar {
2092    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2093        let ui_font = theme::setup_ui_font(window, cx);
2094        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
2095        let sticky_header = self.render_sticky_header(docked_right, window, cx);
2096
2097        v_flex()
2098            .id("workspace-sidebar")
2099            .key_context("ThreadsSidebar")
2100            .track_focus(&self.focus_handle)
2101            .on_action(cx.listener(Self::select_next))
2102            .on_action(cx.listener(Self::select_previous))
2103            .on_action(cx.listener(Self::editor_move_down))
2104            .on_action(cx.listener(Self::editor_move_up))
2105            .on_action(cx.listener(Self::select_first))
2106            .on_action(cx.listener(Self::select_last))
2107            .on_action(cx.listener(Self::confirm))
2108            .on_action(cx.listener(Self::expand_selected_entry))
2109            .on_action(cx.listener(Self::collapse_selected_entry))
2110            .on_action(cx.listener(Self::cancel))
2111            .on_action(cx.listener(Self::remove_selected_thread))
2112            .font(ui_font)
2113            .size_full()
2114            .bg(cx.theme().colors().surface_background)
2115            .map(|this| match self.view {
2116                SidebarView::ThreadList => this
2117                    .child(self.render_thread_list_header(docked_right, cx))
2118                    .child(
2119                        v_flex()
2120                            .relative()
2121                            .flex_1()
2122                            .overflow_hidden()
2123                            .child(
2124                                list(
2125                                    self.list_state.clone(),
2126                                    cx.processor(Self::render_list_entry),
2127                                )
2128                                .flex_1()
2129                                .size_full(),
2130                            )
2131                            .when_some(sticky_header, |this, header| this.child(header))
2132                            .vertical_scrollbar_for(&self.list_state, window, cx),
2133                    ),
2134                SidebarView::Archive => {
2135                    if let Some(archive_view) = &self.archive_view {
2136                        this.child(archive_view.clone())
2137                    } else {
2138                        this
2139                    }
2140                }
2141            })
2142    }
2143}
2144
2145#[cfg(test)]
2146mod tests {
2147    use super::*;
2148    use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
2149    use acp_thread::StubAgentConnection;
2150    use agent::ThreadStore;
2151    use assistant_text_thread::TextThreadStore;
2152    use chrono::DateTime;
2153    use feature_flags::FeatureFlagAppExt as _;
2154    use fs::FakeFs;
2155    use gpui::TestAppContext;
2156    use std::sync::Arc;
2157    use util::path_list::PathList;
2158
2159    fn init_test(cx: &mut TestAppContext) {
2160        crate::test_support::init_test(cx);
2161        cx.update(|cx| {
2162            cx.update_flags(false, vec!["agent-v2".into()]);
2163            ThreadStore::init_global(cx);
2164            language_model::LanguageModelRegistry::test(cx);
2165            prompt_store::init(cx);
2166        });
2167    }
2168
2169    fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
2170        agent::DbThread {
2171            title: title.to_string().into(),
2172            messages: Vec::new(),
2173            updated_at,
2174            detailed_summary: None,
2175            initial_project_snapshot: None,
2176            cumulative_token_usage: Default::default(),
2177            request_token_usage: Default::default(),
2178            model: None,
2179            profile: None,
2180            imported: false,
2181            subagent_context: None,
2182            speed: None,
2183            thinking_enabled: false,
2184            thinking_effort: None,
2185            draft_prompt: None,
2186            ui_scroll_position: None,
2187        }
2188    }
2189
2190    async fn init_test_project(
2191        worktree_path: &str,
2192        cx: &mut TestAppContext,
2193    ) -> Entity<project::Project> {
2194        init_test(cx);
2195        let fs = FakeFs::new(cx.executor());
2196        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
2197            .await;
2198        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2199        project::Project::test(fs, [worktree_path.as_ref()], cx).await
2200    }
2201
2202    fn setup_sidebar(
2203        multi_workspace: &Entity<MultiWorkspace>,
2204        cx: &mut gpui::VisualTestContext,
2205    ) -> Entity<Sidebar> {
2206        let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
2207        sidebar
2208    }
2209
2210    fn setup_sidebar_with_agent_panel(
2211        multi_workspace: &Entity<MultiWorkspace>,
2212        cx: &mut gpui::VisualTestContext,
2213    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
2214        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2215        let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
2216        let panel = add_agent_panel(&workspace, &project, cx);
2217        workspace.update_in(cx, |workspace, window, cx| {
2218            workspace.right_dock().update(cx, |dock, cx| {
2219                if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
2220                    dock.activate_panel(panel_ix, window, cx);
2221                }
2222                dock.set_open(true, window, cx);
2223            });
2224        });
2225        cx.run_until_parked();
2226        let sidebar = panel.read_with(cx, |panel, _cx| {
2227            panel
2228                .sidebar
2229                .clone()
2230                .expect("AgentPanel should have created a sidebar")
2231        });
2232        (sidebar, panel)
2233    }
2234
2235    async fn save_n_test_threads(
2236        count: u32,
2237        path_list: &PathList,
2238        cx: &mut gpui::VisualTestContext,
2239    ) {
2240        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2241        for i in 0..count {
2242            let save_task = thread_store.update(cx, |store, cx| {
2243                store.save_thread(
2244                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
2245                    make_test_thread(
2246                        &format!("Thread {}", i + 1),
2247                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
2248                    ),
2249                    path_list.clone(),
2250                    cx,
2251                )
2252            });
2253            save_task.await.unwrap();
2254        }
2255        cx.run_until_parked();
2256    }
2257
2258    async fn save_thread_to_store(
2259        session_id: &acp::SessionId,
2260        path_list: &PathList,
2261        cx: &mut gpui::VisualTestContext,
2262    ) {
2263        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2264        let save_task = thread_store.update(cx, |store, cx| {
2265            store.save_thread(
2266                session_id.clone(),
2267                make_test_thread(
2268                    "Test",
2269                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2270                ),
2271                path_list.clone(),
2272                cx,
2273            )
2274        });
2275        save_task.await.unwrap();
2276        cx.run_until_parked();
2277    }
2278
2279    fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
2280        cx.run_until_parked();
2281        sidebar.update_in(cx, |sidebar, window, cx| {
2282            sidebar.set_open(true, cx);
2283            cx.focus_self(window);
2284        });
2285        cx.run_until_parked();
2286    }
2287
2288    fn visible_entries_as_strings(
2289        sidebar: &Entity<Sidebar>,
2290        cx: &mut gpui::VisualTestContext,
2291    ) -> Vec<String> {
2292        sidebar.read_with(cx, |sidebar, _cx| {
2293            sidebar
2294                .contents
2295                .entries
2296                .iter()
2297                .enumerate()
2298                .map(|(ix, entry)| {
2299                    let selected = if sidebar.selection == Some(ix) {
2300                        "  <== selected"
2301                    } else {
2302                        ""
2303                    };
2304                    match entry {
2305                        ListEntry::ProjectHeader {
2306                            label,
2307                            path_list,
2308                            highlight_positions: _,
2309                            ..
2310                        } => {
2311                            let icon = if sidebar.collapsed_groups.contains(path_list) {
2312                                ">"
2313                            } else {
2314                                "v"
2315                            };
2316                            format!("{} [{}]{}", icon, label, selected)
2317                        }
2318                        ListEntry::Thread(thread) => {
2319                            let title = thread
2320                                .session_info
2321                                .title
2322                                .as_ref()
2323                                .map(|s| s.as_ref())
2324                                .unwrap_or("Untitled");
2325                            let active = if thread.is_live { " *" } else { "" };
2326                            let status_str = match thread.status {
2327                                AgentThreadStatus::Running => " (running)",
2328                                AgentThreadStatus::Error => " (error)",
2329                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
2330                                _ => "",
2331                            };
2332                            let notified = if sidebar
2333                                .contents
2334                                .is_thread_notified(&thread.session_info.session_id)
2335                            {
2336                                " (!)"
2337                            } else {
2338                                ""
2339                            };
2340                            let worktree = thread
2341                                .worktree_name
2342                                .as_ref()
2343                                .map(|name| format!(" {{{}}}", name))
2344                                .unwrap_or_default();
2345                            format!(
2346                                "  {}{}{}{}{}{}",
2347                                title, worktree, active, status_str, notified, selected
2348                            )
2349                        }
2350                        ListEntry::ViewMore {
2351                            remaining_count,
2352                            is_fully_expanded,
2353                            ..
2354                        } => {
2355                            if *is_fully_expanded {
2356                                format!("  - Collapse{}", selected)
2357                            } else {
2358                                format!("  + View More ({}){}", remaining_count, selected)
2359                            }
2360                        }
2361                        ListEntry::NewThread { .. } => {
2362                            format!("  [+ New Thread]{}", selected)
2363                        }
2364                    }
2365                })
2366                .collect()
2367        })
2368    }
2369
2370    #[gpui::test]
2371    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
2372        let project = init_test_project("/my-project", cx).await;
2373        let (multi_workspace, cx) =
2374            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2375        let sidebar = setup_sidebar(&multi_workspace, cx);
2376
2377        assert_eq!(
2378            visible_entries_as_strings(&sidebar, cx),
2379            vec!["v [my-project]", "  [+ New Thread]"]
2380        );
2381    }
2382
2383    #[gpui::test]
2384    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
2385        let project = init_test_project("/my-project", cx).await;
2386        let (multi_workspace, cx) =
2387            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2388        let sidebar = setup_sidebar(&multi_workspace, cx);
2389
2390        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2391        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2392
2393        let save_task = thread_store.update(cx, |store, cx| {
2394            store.save_thread(
2395                acp::SessionId::new(Arc::from("thread-1")),
2396                make_test_thread(
2397                    "Fix crash in project panel",
2398                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
2399                ),
2400                path_list.clone(),
2401                cx,
2402            )
2403        });
2404        save_task.await.unwrap();
2405
2406        let save_task = thread_store.update(cx, |store, cx| {
2407            store.save_thread(
2408                acp::SessionId::new(Arc::from("thread-2")),
2409                make_test_thread(
2410                    "Add inline diff view",
2411                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2412                ),
2413                path_list.clone(),
2414                cx,
2415            )
2416        });
2417        save_task.await.unwrap();
2418        cx.run_until_parked();
2419
2420        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2421        cx.run_until_parked();
2422
2423        assert_eq!(
2424            visible_entries_as_strings(&sidebar, cx),
2425            vec![
2426                "v [my-project]",
2427                "  Fix crash in project panel",
2428                "  Add inline diff view",
2429            ]
2430        );
2431    }
2432
2433    #[gpui::test]
2434    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
2435        let project = init_test_project("/project-a", cx).await;
2436        let (multi_workspace, cx) =
2437            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2438        let sidebar = setup_sidebar(&multi_workspace, cx);
2439
2440        // Single workspace with a thread
2441        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
2442        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2443
2444        let save_task = thread_store.update(cx, |store, cx| {
2445            store.save_thread(
2446                acp::SessionId::new(Arc::from("thread-a1")),
2447                make_test_thread(
2448                    "Thread A1",
2449                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2450                ),
2451                path_list.clone(),
2452                cx,
2453            )
2454        });
2455        save_task.await.unwrap();
2456        cx.run_until_parked();
2457
2458        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2459        cx.run_until_parked();
2460
2461        assert_eq!(
2462            visible_entries_as_strings(&sidebar, cx),
2463            vec!["v [project-a]", "  Thread A1"]
2464        );
2465
2466        // Add a second workspace
2467        multi_workspace.update_in(cx, |mw, window, cx| {
2468            mw.create_workspace(window, cx);
2469        });
2470        cx.run_until_parked();
2471
2472        assert_eq!(
2473            visible_entries_as_strings(&sidebar, cx),
2474            vec![
2475                "v [project-a]",
2476                "  Thread A1",
2477                "v [Empty Workspace]",
2478                "  [+ New Thread]"
2479            ]
2480        );
2481
2482        // Remove the second workspace
2483        multi_workspace.update_in(cx, |mw, window, cx| {
2484            mw.remove_workspace(1, window, cx);
2485        });
2486        cx.run_until_parked();
2487
2488        assert_eq!(
2489            visible_entries_as_strings(&sidebar, cx),
2490            vec!["v [project-a]", "  Thread A1"]
2491        );
2492    }
2493
2494    #[gpui::test]
2495    async fn test_view_more_pagination(cx: &mut TestAppContext) {
2496        let project = init_test_project("/my-project", cx).await;
2497        let (multi_workspace, cx) =
2498            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2499        let sidebar = setup_sidebar(&multi_workspace, cx);
2500
2501        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2502        save_n_test_threads(12, &path_list, cx).await;
2503
2504        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2505        cx.run_until_parked();
2506
2507        assert_eq!(
2508            visible_entries_as_strings(&sidebar, cx),
2509            vec![
2510                "v [my-project]",
2511                "  Thread 12",
2512                "  Thread 11",
2513                "  Thread 10",
2514                "  Thread 9",
2515                "  Thread 8",
2516                "  + View More (7)",
2517            ]
2518        );
2519    }
2520
2521    #[gpui::test]
2522    async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
2523        let project = init_test_project("/my-project", cx).await;
2524        let (multi_workspace, cx) =
2525            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2526        let sidebar = setup_sidebar(&multi_workspace, cx);
2527
2528        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2529        // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
2530        save_n_test_threads(17, &path_list, cx).await;
2531
2532        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2533        cx.run_until_parked();
2534
2535        // Initially shows 5 threads + View More (12 remaining)
2536        let entries = visible_entries_as_strings(&sidebar, cx);
2537        assert_eq!(entries.len(), 7); // header + 5 threads + View More
2538        assert!(entries.iter().any(|e| e.contains("View More (12)")));
2539
2540        // Focus and navigate to View More, then confirm to expand by one batch
2541        open_and_focus_sidebar(&sidebar, cx);
2542        for _ in 0..7 {
2543            cx.dispatch_action(SelectNext);
2544        }
2545        cx.dispatch_action(Confirm);
2546        cx.run_until_parked();
2547
2548        // Now shows 10 threads + View More (7 remaining)
2549        let entries = visible_entries_as_strings(&sidebar, cx);
2550        assert_eq!(entries.len(), 12); // header + 10 threads + View More
2551        assert!(entries.iter().any(|e| e.contains("View More (7)")));
2552
2553        // Expand again by one batch
2554        sidebar.update_in(cx, |s, _window, cx| {
2555            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
2556            s.expanded_groups.insert(path_list.clone(), current + 1);
2557            s.update_entries(cx);
2558        });
2559        cx.run_until_parked();
2560
2561        // Now shows 15 threads + View More (2 remaining)
2562        let entries = visible_entries_as_strings(&sidebar, cx);
2563        assert_eq!(entries.len(), 17); // header + 15 threads + View More
2564        assert!(entries.iter().any(|e| e.contains("View More (2)")));
2565
2566        // Expand one more time - should show all 17 threads with Collapse button
2567        sidebar.update_in(cx, |s, _window, cx| {
2568            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
2569            s.expanded_groups.insert(path_list.clone(), current + 1);
2570            s.update_entries(cx);
2571        });
2572        cx.run_until_parked();
2573
2574        // All 17 threads shown with Collapse button
2575        let entries = visible_entries_as_strings(&sidebar, cx);
2576        assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
2577        assert!(!entries.iter().any(|e| e.contains("View More")));
2578        assert!(entries.iter().any(|e| e.contains("Collapse")));
2579
2580        // Click collapse - should go back to showing 5 threads
2581        sidebar.update_in(cx, |s, _window, cx| {
2582            s.expanded_groups.remove(&path_list);
2583            s.update_entries(cx);
2584        });
2585        cx.run_until_parked();
2586
2587        // Back to initial state: 5 threads + View More (12 remaining)
2588        let entries = visible_entries_as_strings(&sidebar, cx);
2589        assert_eq!(entries.len(), 7); // header + 5 threads + View More
2590        assert!(entries.iter().any(|e| e.contains("View More (12)")));
2591    }
2592
2593    #[gpui::test]
2594    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
2595        let project = init_test_project("/my-project", cx).await;
2596        let (multi_workspace, cx) =
2597            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2598        let sidebar = setup_sidebar(&multi_workspace, cx);
2599
2600        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2601        save_n_test_threads(1, &path_list, cx).await;
2602
2603        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2604        cx.run_until_parked();
2605
2606        assert_eq!(
2607            visible_entries_as_strings(&sidebar, cx),
2608            vec!["v [my-project]", "  Thread 1"]
2609        );
2610
2611        // Collapse
2612        sidebar.update_in(cx, |s, window, cx| {
2613            s.toggle_collapse(&path_list, window, cx);
2614        });
2615        cx.run_until_parked();
2616
2617        assert_eq!(
2618            visible_entries_as_strings(&sidebar, cx),
2619            vec!["> [my-project]"]
2620        );
2621
2622        // Expand
2623        sidebar.update_in(cx, |s, window, cx| {
2624            s.toggle_collapse(&path_list, window, cx);
2625        });
2626        cx.run_until_parked();
2627
2628        assert_eq!(
2629            visible_entries_as_strings(&sidebar, cx),
2630            vec!["v [my-project]", "  Thread 1"]
2631        );
2632    }
2633
2634    #[gpui::test]
2635    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
2636        let project = init_test_project("/my-project", cx).await;
2637        let (multi_workspace, cx) =
2638            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2639        let sidebar = setup_sidebar(&multi_workspace, cx);
2640
2641        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2642        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
2643        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
2644
2645        sidebar.update_in(cx, |s, _window, _cx| {
2646            s.collapsed_groups.insert(collapsed_path.clone());
2647            s.contents
2648                .notified_threads
2649                .insert(acp::SessionId::new(Arc::from("t-5")));
2650            s.contents.entries = vec![
2651                // Expanded project header
2652                ListEntry::ProjectHeader {
2653                    path_list: expanded_path.clone(),
2654                    label: "expanded-project".into(),
2655                    workspace: workspace.clone(),
2656                    highlight_positions: Vec::new(),
2657                    has_threads: true,
2658                },
2659                // Thread with default (Completed) status, not active
2660                ListEntry::Thread(ThreadEntry {
2661                    agent: Agent::NativeAgent,
2662                    session_info: acp_thread::AgentSessionInfo {
2663                        session_id: acp::SessionId::new(Arc::from("t-1")),
2664                        cwd: None,
2665                        title: Some("Completed thread".into()),
2666                        updated_at: Some(Utc::now()),
2667                        created_at: Some(Utc::now()),
2668                        meta: None,
2669                    },
2670                    icon: IconName::ZedAgent,
2671                    icon_from_external_svg: None,
2672                    status: AgentThreadStatus::Completed,
2673                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2674                    is_live: false,
2675                    is_background: false,
2676                    is_title_generating: false,
2677                    highlight_positions: Vec::new(),
2678                    worktree_name: None,
2679                    worktree_highlight_positions: Vec::new(),
2680                    diff_stats: DiffStats::default(),
2681                }),
2682                // Active thread with Running status
2683                ListEntry::Thread(ThreadEntry {
2684                    agent: Agent::NativeAgent,
2685                    session_info: acp_thread::AgentSessionInfo {
2686                        session_id: acp::SessionId::new(Arc::from("t-2")),
2687                        cwd: None,
2688                        title: Some("Running thread".into()),
2689                        updated_at: Some(Utc::now()),
2690                        created_at: Some(Utc::now()),
2691                        meta: None,
2692                    },
2693                    icon: IconName::ZedAgent,
2694                    icon_from_external_svg: None,
2695                    status: AgentThreadStatus::Running,
2696                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2697                    is_live: true,
2698                    is_background: false,
2699                    is_title_generating: false,
2700                    highlight_positions: Vec::new(),
2701                    worktree_name: None,
2702                    worktree_highlight_positions: Vec::new(),
2703                    diff_stats: DiffStats::default(),
2704                }),
2705                // Active thread with Error status
2706                ListEntry::Thread(ThreadEntry {
2707                    agent: Agent::NativeAgent,
2708                    session_info: acp_thread::AgentSessionInfo {
2709                        session_id: acp::SessionId::new(Arc::from("t-3")),
2710                        cwd: None,
2711                        title: Some("Error thread".into()),
2712                        updated_at: Some(Utc::now()),
2713                        created_at: Some(Utc::now()),
2714                        meta: None,
2715                    },
2716                    icon: IconName::ZedAgent,
2717                    icon_from_external_svg: None,
2718                    status: AgentThreadStatus::Error,
2719                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2720                    is_live: true,
2721                    is_background: false,
2722                    is_title_generating: false,
2723                    highlight_positions: Vec::new(),
2724                    worktree_name: None,
2725                    worktree_highlight_positions: Vec::new(),
2726                    diff_stats: DiffStats::default(),
2727                }),
2728                // Thread with WaitingForConfirmation status, not active
2729                ListEntry::Thread(ThreadEntry {
2730                    agent: Agent::NativeAgent,
2731                    session_info: acp_thread::AgentSessionInfo {
2732                        session_id: acp::SessionId::new(Arc::from("t-4")),
2733                        cwd: None,
2734                        title: Some("Waiting thread".into()),
2735                        updated_at: Some(Utc::now()),
2736                        created_at: Some(Utc::now()),
2737                        meta: None,
2738                    },
2739                    icon: IconName::ZedAgent,
2740                    icon_from_external_svg: None,
2741                    status: AgentThreadStatus::WaitingForConfirmation,
2742                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2743                    is_live: false,
2744                    is_background: false,
2745                    is_title_generating: false,
2746                    highlight_positions: Vec::new(),
2747                    worktree_name: None,
2748                    worktree_highlight_positions: Vec::new(),
2749                    diff_stats: DiffStats::default(),
2750                }),
2751                // Background thread that completed (should show notification)
2752                ListEntry::Thread(ThreadEntry {
2753                    agent: Agent::NativeAgent,
2754                    session_info: acp_thread::AgentSessionInfo {
2755                        session_id: acp::SessionId::new(Arc::from("t-5")),
2756                        cwd: None,
2757                        title: Some("Notified thread".into()),
2758                        updated_at: Some(Utc::now()),
2759                        created_at: Some(Utc::now()),
2760                        meta: None,
2761                    },
2762                    icon: IconName::ZedAgent,
2763                    icon_from_external_svg: None,
2764                    status: AgentThreadStatus::Completed,
2765                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2766                    is_live: true,
2767                    is_background: true,
2768                    is_title_generating: false,
2769                    highlight_positions: Vec::new(),
2770                    worktree_name: None,
2771                    worktree_highlight_positions: Vec::new(),
2772                    diff_stats: DiffStats::default(),
2773                }),
2774                // View More entry
2775                ListEntry::ViewMore {
2776                    path_list: expanded_path.clone(),
2777                    remaining_count: 42,
2778                    is_fully_expanded: false,
2779                },
2780                // Collapsed project header
2781                ListEntry::ProjectHeader {
2782                    path_list: collapsed_path.clone(),
2783                    label: "collapsed-project".into(),
2784                    workspace: workspace.clone(),
2785                    highlight_positions: Vec::new(),
2786                    has_threads: true,
2787                },
2788            ];
2789            // Select the Running thread (index 2)
2790            s.selection = Some(2);
2791        });
2792
2793        assert_eq!(
2794            visible_entries_as_strings(&sidebar, cx),
2795            vec![
2796                "v [expanded-project]",
2797                "  Completed thread",
2798                "  Running thread * (running)  <== selected",
2799                "  Error thread * (error)",
2800                "  Waiting thread (waiting)",
2801                "  Notified thread * (!)",
2802                "  + View More (42)",
2803                "> [collapsed-project]",
2804            ]
2805        );
2806
2807        // Move selection to the collapsed header
2808        sidebar.update_in(cx, |s, _window, _cx| {
2809            s.selection = Some(7);
2810        });
2811
2812        assert_eq!(
2813            visible_entries_as_strings(&sidebar, cx).last().cloned(),
2814            Some("> [collapsed-project]  <== selected".to_string()),
2815        );
2816
2817        // Clear selection
2818        sidebar.update_in(cx, |s, _window, _cx| {
2819            s.selection = None;
2820        });
2821
2822        // No entry should have the selected marker
2823        let entries = visible_entries_as_strings(&sidebar, cx);
2824        for entry in &entries {
2825            assert!(
2826                !entry.contains("<== selected"),
2827                "unexpected selection marker in: {}",
2828                entry
2829            );
2830        }
2831    }
2832
2833    #[gpui::test]
2834    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
2835        let project = init_test_project("/my-project", cx).await;
2836        let (multi_workspace, cx) =
2837            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2838        let sidebar = setup_sidebar(&multi_workspace, cx);
2839
2840        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2841        save_n_test_threads(3, &path_list, cx).await;
2842
2843        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2844        cx.run_until_parked();
2845
2846        // Entries: [header, thread3, thread2, thread1]
2847        // Focusing the sidebar does not set a selection; select_next/select_previous
2848        // handle None gracefully by starting from the first or last entry.
2849        open_and_focus_sidebar(&sidebar, cx);
2850        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2851
2852        // First SelectNext from None starts at index 0
2853        cx.dispatch_action(SelectNext);
2854        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2855
2856        // Move down through remaining entries
2857        cx.dispatch_action(SelectNext);
2858        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2859
2860        cx.dispatch_action(SelectNext);
2861        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2862
2863        cx.dispatch_action(SelectNext);
2864        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2865
2866        // At the end, selection stays on the last entry
2867        cx.dispatch_action(SelectNext);
2868        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2869
2870        // Move back up
2871
2872        cx.dispatch_action(SelectPrevious);
2873        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2874
2875        cx.dispatch_action(SelectPrevious);
2876        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2877
2878        cx.dispatch_action(SelectPrevious);
2879        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2880
2881        // At the top, selection stays on the first entry
2882        cx.dispatch_action(SelectPrevious);
2883        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2884    }
2885
2886    #[gpui::test]
2887    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
2888        let project = init_test_project("/my-project", cx).await;
2889        let (multi_workspace, cx) =
2890            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2891        let sidebar = setup_sidebar(&multi_workspace, cx);
2892
2893        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2894        save_n_test_threads(3, &path_list, cx).await;
2895        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2896        cx.run_until_parked();
2897
2898        open_and_focus_sidebar(&sidebar, cx);
2899
2900        // SelectLast jumps to the end
2901        cx.dispatch_action(SelectLast);
2902        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2903
2904        // SelectFirst jumps to the beginning
2905        cx.dispatch_action(SelectFirst);
2906        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2907    }
2908
2909    #[gpui::test]
2910    async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
2911        let project = init_test_project("/my-project", cx).await;
2912        let (multi_workspace, cx) =
2913            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2914        let sidebar = setup_sidebar(&multi_workspace, cx);
2915
2916        // Initially no selection
2917        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2918
2919        // Open the sidebar so it's rendered, then focus it to trigger focus_in.
2920        // focus_in no longer sets a default selection.
2921        open_and_focus_sidebar(&sidebar, cx);
2922        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2923
2924        // Manually set a selection, blur, then refocus — selection should be preserved
2925        sidebar.update_in(cx, |sidebar, _window, _cx| {
2926            sidebar.selection = Some(0);
2927        });
2928
2929        cx.update(|window, _cx| {
2930            window.blur();
2931        });
2932        cx.run_until_parked();
2933
2934        sidebar.update_in(cx, |_, window, cx| {
2935            cx.focus_self(window);
2936        });
2937        cx.run_until_parked();
2938        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2939    }
2940
2941    #[gpui::test]
2942    async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) {
2943        let project = init_test_project("/my-project", cx).await;
2944        let (multi_workspace, cx) =
2945            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2946        let sidebar = setup_sidebar(&multi_workspace, cx);
2947
2948        multi_workspace.update_in(cx, |mw, window, cx| {
2949            mw.create_workspace(window, cx);
2950        });
2951        cx.run_until_parked();
2952
2953        // Add an agent panel to workspace 1 so the sidebar renders when it's active.
2954        setup_sidebar_with_agent_panel(&multi_workspace, cx);
2955
2956        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2957        save_n_test_threads(1, &path_list, cx).await;
2958        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2959        cx.run_until_parked();
2960
2961        assert_eq!(
2962            visible_entries_as_strings(&sidebar, cx),
2963            vec![
2964                "v [my-project]",
2965                "  Thread 1",
2966                "v [Empty Workspace]",
2967                "  [+ New Thread]",
2968            ]
2969        );
2970
2971        // Switch to workspace 1 so we can verify confirm switches back.
2972        multi_workspace.update_in(cx, |mw, window, cx| {
2973            mw.activate_index(1, window, cx);
2974        });
2975        cx.run_until_parked();
2976        assert_eq!(
2977            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2978            1
2979        );
2980
2981        // Focus the sidebar and manually select the header (index 0)
2982        open_and_focus_sidebar(&sidebar, cx);
2983        sidebar.update_in(cx, |sidebar, _window, _cx| {
2984            sidebar.selection = Some(0);
2985        });
2986
2987        // Press confirm on project header (workspace 0) to activate it.
2988        cx.dispatch_action(Confirm);
2989        cx.run_until_parked();
2990
2991        assert_eq!(
2992            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2993            0
2994        );
2995
2996        // Focus should have moved out of the sidebar to the workspace center.
2997        let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
2998        workspace_0.update_in(cx, |workspace, window, cx| {
2999            let pane_focus = workspace.active_pane().read(cx).focus_handle(cx);
3000            assert!(
3001                pane_focus.contains_focused(window, cx),
3002                "Confirming a project header should focus the workspace center pane"
3003            );
3004        });
3005    }
3006
3007    #[gpui::test]
3008    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
3009        let project = init_test_project("/my-project", cx).await;
3010        let (multi_workspace, cx) =
3011            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3012        let sidebar = setup_sidebar(&multi_workspace, cx);
3013
3014        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3015        save_n_test_threads(8, &path_list, cx).await;
3016        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3017        cx.run_until_parked();
3018
3019        // Should show header + 5 threads + "View More (3)"
3020        let entries = visible_entries_as_strings(&sidebar, cx);
3021        assert_eq!(entries.len(), 7);
3022        assert!(entries.iter().any(|e| e.contains("View More (3)")));
3023
3024        // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
3025        open_and_focus_sidebar(&sidebar, cx);
3026        for _ in 0..7 {
3027            cx.dispatch_action(SelectNext);
3028        }
3029        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
3030
3031        // Confirm on "View More" to expand
3032        cx.dispatch_action(Confirm);
3033        cx.run_until_parked();
3034
3035        // All 8 threads should now be visible with a "Collapse" button
3036        let entries = visible_entries_as_strings(&sidebar, cx);
3037        assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
3038        assert!(!entries.iter().any(|e| e.contains("View More")));
3039        assert!(entries.iter().any(|e| e.contains("Collapse")));
3040    }
3041
3042    #[gpui::test]
3043    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
3044        let project = init_test_project("/my-project", cx).await;
3045        let (multi_workspace, cx) =
3046            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3047        let sidebar = setup_sidebar(&multi_workspace, cx);
3048
3049        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3050        save_n_test_threads(1, &path_list, cx).await;
3051        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3052        cx.run_until_parked();
3053
3054        assert_eq!(
3055            visible_entries_as_strings(&sidebar, cx),
3056            vec!["v [my-project]", "  Thread 1"]
3057        );
3058
3059        // Focus sidebar and manually select the header (index 0). Press left to collapse.
3060        open_and_focus_sidebar(&sidebar, cx);
3061        sidebar.update_in(cx, |sidebar, _window, _cx| {
3062            sidebar.selection = Some(0);
3063        });
3064
3065        cx.dispatch_action(CollapseSelectedEntry);
3066        cx.run_until_parked();
3067
3068        assert_eq!(
3069            visible_entries_as_strings(&sidebar, cx),
3070            vec!["> [my-project]  <== selected"]
3071        );
3072
3073        // Press right to expand
3074        cx.dispatch_action(ExpandSelectedEntry);
3075        cx.run_until_parked();
3076
3077        assert_eq!(
3078            visible_entries_as_strings(&sidebar, cx),
3079            vec!["v [my-project]  <== selected", "  Thread 1",]
3080        );
3081
3082        // Press right again on already-expanded header moves selection down
3083        cx.dispatch_action(ExpandSelectedEntry);
3084        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3085    }
3086
3087    #[gpui::test]
3088    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
3089        let project = init_test_project("/my-project", cx).await;
3090        let (multi_workspace, cx) =
3091            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3092        let sidebar = setup_sidebar(&multi_workspace, cx);
3093
3094        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3095        save_n_test_threads(1, &path_list, cx).await;
3096        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3097        cx.run_until_parked();
3098
3099        // Focus sidebar (selection starts at None), then navigate down to the thread (child)
3100        open_and_focus_sidebar(&sidebar, cx);
3101        cx.dispatch_action(SelectNext);
3102        cx.dispatch_action(SelectNext);
3103        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3104
3105        assert_eq!(
3106            visible_entries_as_strings(&sidebar, cx),
3107            vec!["v [my-project]", "  Thread 1  <== selected",]
3108        );
3109
3110        // Pressing left on a child collapses the parent group and selects it
3111        cx.dispatch_action(CollapseSelectedEntry);
3112        cx.run_until_parked();
3113
3114        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3115        assert_eq!(
3116            visible_entries_as_strings(&sidebar, cx),
3117            vec!["> [my-project]  <== selected"]
3118        );
3119    }
3120
3121    #[gpui::test]
3122    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
3123        let project = init_test_project("/empty-project", cx).await;
3124        let (multi_workspace, cx) =
3125            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3126        let sidebar = setup_sidebar(&multi_workspace, cx);
3127
3128        // Even an empty project has the header and a new thread button
3129        assert_eq!(
3130            visible_entries_as_strings(&sidebar, cx),
3131            vec!["v [empty-project]", "  [+ New Thread]"]
3132        );
3133
3134        // Focus sidebar — focus_in does not set a selection
3135        open_and_focus_sidebar(&sidebar, cx);
3136        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3137
3138        // First SelectNext from None starts at index 0 (header)
3139        cx.dispatch_action(SelectNext);
3140        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3141
3142        // SelectNext moves to the new thread button
3143        cx.dispatch_action(SelectNext);
3144        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3145
3146        // At the end, selection stays on the last entry
3147        cx.dispatch_action(SelectNext);
3148        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3149
3150        // SelectPrevious goes back to the header
3151        cx.dispatch_action(SelectPrevious);
3152        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3153    }
3154
3155    #[gpui::test]
3156    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
3157        let project = init_test_project("/my-project", cx).await;
3158        let (multi_workspace, cx) =
3159            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3160        let sidebar = setup_sidebar(&multi_workspace, cx);
3161
3162        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3163        save_n_test_threads(1, &path_list, cx).await;
3164        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3165        cx.run_until_parked();
3166
3167        // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
3168        open_and_focus_sidebar(&sidebar, cx);
3169        cx.dispatch_action(SelectNext);
3170        cx.dispatch_action(SelectNext);
3171        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3172
3173        // Collapse the group, which removes the thread from the list
3174        cx.dispatch_action(CollapseSelectedEntry);
3175        cx.run_until_parked();
3176
3177        // Selection should be clamped to the last valid index (0 = header)
3178        let selection = sidebar.read_with(cx, |s, _| s.selection);
3179        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
3180        assert!(
3181            selection.unwrap_or(0) < entry_count,
3182            "selection {} should be within bounds (entries: {})",
3183            selection.unwrap_or(0),
3184            entry_count,
3185        );
3186    }
3187
3188    fn add_agent_panel(
3189        workspace: &Entity<Workspace>,
3190        project: &Entity<project::Project>,
3191        cx: &mut gpui::VisualTestContext,
3192    ) -> Entity<AgentPanel> {
3193        workspace.update_in(cx, |workspace, window, cx| {
3194            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
3195            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
3196            workspace.add_panel(panel.clone(), window, cx);
3197            panel
3198        })
3199    }
3200
3201    #[gpui::test]
3202    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
3203        let project = init_test_project("/my-project", cx).await;
3204        let (multi_workspace, cx) =
3205            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3206        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3207
3208        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3209
3210        // Open thread A and keep it generating.
3211        let connection = StubAgentConnection::new();
3212        open_thread_with_connection(&panel, connection.clone(), cx);
3213        send_message(&panel, cx);
3214
3215        let session_id_a = active_session_id(&panel, cx);
3216        save_thread_to_store(&session_id_a, &path_list, cx).await;
3217
3218        cx.update(|_, cx| {
3219            connection.send_update(
3220                session_id_a.clone(),
3221                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3222                cx,
3223            );
3224        });
3225        cx.run_until_parked();
3226
3227        // Open thread B (idle, default response) — thread A goes to background.
3228        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3229            acp::ContentChunk::new("Done".into()),
3230        )]);
3231        open_thread_with_connection(&panel, connection, cx);
3232        send_message(&panel, cx);
3233
3234        let session_id_b = active_session_id(&panel, cx);
3235        save_thread_to_store(&session_id_b, &path_list, cx).await;
3236
3237        cx.run_until_parked();
3238
3239        let mut entries = visible_entries_as_strings(&sidebar, cx);
3240        entries[1..].sort();
3241        assert_eq!(
3242            entries,
3243            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
3244        );
3245    }
3246
3247    #[gpui::test]
3248    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
3249        let project_a = init_test_project("/project-a", cx).await;
3250        let (multi_workspace, cx) = cx
3251            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3252        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3253
3254        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3255
3256        // Open thread on workspace A and keep it generating.
3257        let connection_a = StubAgentConnection::new();
3258        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
3259        send_message(&panel_a, cx);
3260
3261        let session_id_a = active_session_id(&panel_a, cx);
3262        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3263
3264        cx.update(|_, cx| {
3265            connection_a.send_update(
3266                session_id_a.clone(),
3267                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
3268                cx,
3269            );
3270        });
3271        cx.run_until_parked();
3272
3273        // Add a second workspace and activate it (making workspace A the background).
3274        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3275        let project_b = project::Project::test(fs, [], cx).await;
3276        multi_workspace.update_in(cx, |mw, window, cx| {
3277            mw.test_add_workspace(project_b, window, cx);
3278        });
3279        cx.run_until_parked();
3280
3281        // Thread A is still running; no notification yet.
3282        assert_eq!(
3283            visible_entries_as_strings(&sidebar, cx),
3284            vec![
3285                "v [project-a]",
3286                "  Hello * (running)",
3287                "v [Empty Workspace]",
3288                "  [+ New Thread]",
3289            ]
3290        );
3291
3292        // Complete thread A's turn (transition Running → Completed).
3293        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
3294        cx.run_until_parked();
3295
3296        // The completed background thread shows a notification indicator.
3297        assert_eq!(
3298            visible_entries_as_strings(&sidebar, cx),
3299            vec![
3300                "v [project-a]",
3301                "  Hello * (!)",
3302                "v [Empty Workspace]",
3303                "  [+ New Thread]",
3304            ]
3305        );
3306    }
3307
3308    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
3309        sidebar.update_in(cx, |sidebar, window, cx| {
3310            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
3311            sidebar.filter_editor.update(cx, |editor, cx| {
3312                editor.set_text(query, window, cx);
3313            });
3314        });
3315        cx.run_until_parked();
3316    }
3317
3318    #[gpui::test]
3319    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
3320        let project = init_test_project("/my-project", cx).await;
3321        let (multi_workspace, cx) =
3322            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3323        let sidebar = setup_sidebar(&multi_workspace, cx);
3324
3325        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3326        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3327
3328        for (id, title, hour) in [
3329            ("t-1", "Fix crash in project panel", 3),
3330            ("t-2", "Add inline diff view", 2),
3331            ("t-3", "Refactor settings module", 1),
3332        ] {
3333            let save_task = thread_store.update(cx, |store, cx| {
3334                store.save_thread(
3335                    acp::SessionId::new(Arc::from(id)),
3336                    make_test_thread(
3337                        title,
3338                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3339                    ),
3340                    path_list.clone(),
3341                    cx,
3342                )
3343            });
3344            save_task.await.unwrap();
3345        }
3346        cx.run_until_parked();
3347
3348        assert_eq!(
3349            visible_entries_as_strings(&sidebar, cx),
3350            vec![
3351                "v [my-project]",
3352                "  Fix crash in project panel",
3353                "  Add inline diff view",
3354                "  Refactor settings module",
3355            ]
3356        );
3357
3358        // User types "diff" in the search box — only the matching thread remains,
3359        // with its workspace header preserved for context.
3360        type_in_search(&sidebar, "diff", cx);
3361        assert_eq!(
3362            visible_entries_as_strings(&sidebar, cx),
3363            vec!["v [my-project]", "  Add inline diff view  <== selected",]
3364        );
3365
3366        // User changes query to something with no matches — list is empty.
3367        type_in_search(&sidebar, "nonexistent", cx);
3368        assert_eq!(
3369            visible_entries_as_strings(&sidebar, cx),
3370            Vec::<String>::new()
3371        );
3372    }
3373
3374    #[gpui::test]
3375    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
3376        // Scenario: A user remembers a thread title but not the exact casing.
3377        // Search should match case-insensitively so they can still find it.
3378        let project = init_test_project("/my-project", cx).await;
3379        let (multi_workspace, cx) =
3380            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3381        let sidebar = setup_sidebar(&multi_workspace, cx);
3382
3383        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3384        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3385
3386        let save_task = thread_store.update(cx, |store, cx| {
3387            store.save_thread(
3388                acp::SessionId::new(Arc::from("thread-1")),
3389                make_test_thread(
3390                    "Fix Crash In Project Panel",
3391                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3392                ),
3393                path_list.clone(),
3394                cx,
3395            )
3396        });
3397        save_task.await.unwrap();
3398        cx.run_until_parked();
3399
3400        // Lowercase query matches mixed-case title.
3401        type_in_search(&sidebar, "fix crash", cx);
3402        assert_eq!(
3403            visible_entries_as_strings(&sidebar, cx),
3404            vec![
3405                "v [my-project]",
3406                "  Fix Crash In Project Panel  <== selected",
3407            ]
3408        );
3409
3410        // Uppercase query also matches the same title.
3411        type_in_search(&sidebar, "FIX CRASH", cx);
3412        assert_eq!(
3413            visible_entries_as_strings(&sidebar, cx),
3414            vec![
3415                "v [my-project]",
3416                "  Fix Crash In Project Panel  <== selected",
3417            ]
3418        );
3419    }
3420
3421    #[gpui::test]
3422    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
3423        // Scenario: A user searches, finds what they need, then presses Escape
3424        // to dismiss the filter and see the full list again.
3425        let project = init_test_project("/my-project", cx).await;
3426        let (multi_workspace, cx) =
3427            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3428        let sidebar = setup_sidebar(&multi_workspace, cx);
3429
3430        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3431        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3432
3433        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
3434            let save_task = thread_store.update(cx, |store, cx| {
3435                store.save_thread(
3436                    acp::SessionId::new(Arc::from(id)),
3437                    make_test_thread(
3438                        title,
3439                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3440                    ),
3441                    path_list.clone(),
3442                    cx,
3443                )
3444            });
3445            save_task.await.unwrap();
3446        }
3447        cx.run_until_parked();
3448
3449        // Confirm the full list is showing.
3450        assert_eq!(
3451            visible_entries_as_strings(&sidebar, cx),
3452            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
3453        );
3454
3455        // User types a search query to filter down.
3456        open_and_focus_sidebar(&sidebar, cx);
3457        type_in_search(&sidebar, "alpha", cx);
3458        assert_eq!(
3459            visible_entries_as_strings(&sidebar, cx),
3460            vec!["v [my-project]", "  Alpha thread  <== selected",]
3461        );
3462
3463        // User presses Escape — filter clears, full list is restored.
3464        cx.dispatch_action(Cancel);
3465        cx.run_until_parked();
3466        assert_eq!(
3467            visible_entries_as_strings(&sidebar, cx),
3468            vec![
3469                "v [my-project]",
3470                "  Alpha thread  <== selected",
3471                "  Beta thread",
3472            ]
3473        );
3474    }
3475
3476    #[gpui::test]
3477    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
3478        let project_a = init_test_project("/project-a", cx).await;
3479        let (multi_workspace, cx) =
3480            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3481        let sidebar = setup_sidebar(&multi_workspace, cx);
3482
3483        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3484        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3485
3486        for (id, title, hour) in [
3487            ("a1", "Fix bug in sidebar", 2),
3488            ("a2", "Add tests for editor", 1),
3489        ] {
3490            let save_task = thread_store.update(cx, |store, cx| {
3491                store.save_thread(
3492                    acp::SessionId::new(Arc::from(id)),
3493                    make_test_thread(
3494                        title,
3495                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3496                    ),
3497                    path_list_a.clone(),
3498                    cx,
3499                )
3500            });
3501            save_task.await.unwrap();
3502        }
3503
3504        // Add a second workspace.
3505        multi_workspace.update_in(cx, |mw, window, cx| {
3506            mw.create_workspace(window, cx);
3507        });
3508        cx.run_until_parked();
3509
3510        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
3511
3512        for (id, title, hour) in [
3513            ("b1", "Refactor sidebar layout", 3),
3514            ("b2", "Fix typo in README", 1),
3515        ] {
3516            let save_task = thread_store.update(cx, |store, cx| {
3517                store.save_thread(
3518                    acp::SessionId::new(Arc::from(id)),
3519                    make_test_thread(
3520                        title,
3521                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3522                    ),
3523                    path_list_b.clone(),
3524                    cx,
3525                )
3526            });
3527            save_task.await.unwrap();
3528        }
3529        cx.run_until_parked();
3530
3531        assert_eq!(
3532            visible_entries_as_strings(&sidebar, cx),
3533            vec![
3534                "v [project-a]",
3535                "  Fix bug in sidebar",
3536                "  Add tests for editor",
3537                "v [Empty Workspace]",
3538                "  Refactor sidebar layout",
3539                "  Fix typo in README",
3540            ]
3541        );
3542
3543        // "sidebar" matches a thread in each workspace — both headers stay visible.
3544        type_in_search(&sidebar, "sidebar", cx);
3545        assert_eq!(
3546            visible_entries_as_strings(&sidebar, cx),
3547            vec![
3548                "v [project-a]",
3549                "  Fix bug in sidebar  <== selected",
3550                "v [Empty Workspace]",
3551                "  Refactor sidebar layout",
3552            ]
3553        );
3554
3555        // "typo" only matches in the second workspace — the first header disappears.
3556        type_in_search(&sidebar, "typo", cx);
3557        assert_eq!(
3558            visible_entries_as_strings(&sidebar, cx),
3559            vec!["v [Empty Workspace]", "  Fix typo in README  <== selected",]
3560        );
3561
3562        // "project-a" matches the first workspace name — the header appears
3563        // with all child threads included.
3564        type_in_search(&sidebar, "project-a", cx);
3565        assert_eq!(
3566            visible_entries_as_strings(&sidebar, cx),
3567            vec![
3568                "v [project-a]",
3569                "  Fix bug in sidebar  <== selected",
3570                "  Add tests for editor",
3571            ]
3572        );
3573    }
3574
3575    #[gpui::test]
3576    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
3577        let project_a = init_test_project("/alpha-project", cx).await;
3578        let (multi_workspace, cx) =
3579            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3580        let sidebar = setup_sidebar(&multi_workspace, cx);
3581
3582        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
3583        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3584
3585        for (id, title, hour) in [
3586            ("a1", "Fix bug in sidebar", 2),
3587            ("a2", "Add tests for editor", 1),
3588        ] {
3589            let save_task = thread_store.update(cx, |store, cx| {
3590                store.save_thread(
3591                    acp::SessionId::new(Arc::from(id)),
3592                    make_test_thread(
3593                        title,
3594                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3595                    ),
3596                    path_list_a.clone(),
3597                    cx,
3598                )
3599            });
3600            save_task.await.unwrap();
3601        }
3602
3603        // Add a second workspace.
3604        multi_workspace.update_in(cx, |mw, window, cx| {
3605            mw.create_workspace(window, cx);
3606        });
3607        cx.run_until_parked();
3608
3609        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
3610
3611        for (id, title, hour) in [
3612            ("b1", "Refactor sidebar layout", 3),
3613            ("b2", "Fix typo in README", 1),
3614        ] {
3615            let save_task = thread_store.update(cx, |store, cx| {
3616                store.save_thread(
3617                    acp::SessionId::new(Arc::from(id)),
3618                    make_test_thread(
3619                        title,
3620                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3621                    ),
3622                    path_list_b.clone(),
3623                    cx,
3624                )
3625            });
3626            save_task.await.unwrap();
3627        }
3628        cx.run_until_parked();
3629
3630        // "alpha" matches the workspace name "alpha-project" but no thread titles.
3631        // The workspace header should appear with all child threads included.
3632        type_in_search(&sidebar, "alpha", cx);
3633        assert_eq!(
3634            visible_entries_as_strings(&sidebar, cx),
3635            vec![
3636                "v [alpha-project]",
3637                "  Fix bug in sidebar  <== selected",
3638                "  Add tests for editor",
3639            ]
3640        );
3641
3642        // "sidebar" matches thread titles in both workspaces but not workspace names.
3643        // Both headers appear with their matching threads.
3644        type_in_search(&sidebar, "sidebar", cx);
3645        assert_eq!(
3646            visible_entries_as_strings(&sidebar, cx),
3647            vec![
3648                "v [alpha-project]",
3649                "  Fix bug in sidebar  <== selected",
3650                "v [Empty Workspace]",
3651                "  Refactor sidebar layout",
3652            ]
3653        );
3654
3655        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
3656        // doesn't match) — but does not match either workspace name or any thread.
3657        // Actually let's test something simpler: a query that matches both a workspace
3658        // name AND some threads in that workspace. Matching threads should still appear.
3659        type_in_search(&sidebar, "fix", cx);
3660        assert_eq!(
3661            visible_entries_as_strings(&sidebar, cx),
3662            vec![
3663                "v [alpha-project]",
3664                "  Fix bug in sidebar  <== selected",
3665                "v [Empty Workspace]",
3666                "  Fix typo in README",
3667            ]
3668        );
3669
3670        // A query that matches a workspace name AND a thread in that same workspace.
3671        // Both the header (highlighted) and all child threads should appear.
3672        type_in_search(&sidebar, "alpha", cx);
3673        assert_eq!(
3674            visible_entries_as_strings(&sidebar, cx),
3675            vec![
3676                "v [alpha-project]",
3677                "  Fix bug in sidebar  <== selected",
3678                "  Add tests for editor",
3679            ]
3680        );
3681
3682        // Now search for something that matches only a workspace name when there
3683        // are also threads with matching titles — the non-matching workspace's
3684        // threads should still appear if their titles match.
3685        type_in_search(&sidebar, "alp", cx);
3686        assert_eq!(
3687            visible_entries_as_strings(&sidebar, cx),
3688            vec![
3689                "v [alpha-project]",
3690                "  Fix bug in sidebar  <== selected",
3691                "  Add tests for editor",
3692            ]
3693        );
3694    }
3695
3696    #[gpui::test]
3697    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
3698        let project = init_test_project("/my-project", cx).await;
3699        let (multi_workspace, cx) =
3700            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3701        let sidebar = setup_sidebar(&multi_workspace, cx);
3702
3703        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3704        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3705
3706        // Create 8 threads. The oldest one has a unique name and will be
3707        // behind View More (only 5 shown by default).
3708        for i in 0..8u32 {
3709            let title = if i == 0 {
3710                "Hidden gem thread".to_string()
3711            } else {
3712                format!("Thread {}", i + 1)
3713            };
3714            let save_task = thread_store.update(cx, |store, cx| {
3715                store.save_thread(
3716                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3717                    make_test_thread(
3718                        &title,
3719                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3720                    ),
3721                    path_list.clone(),
3722                    cx,
3723                )
3724            });
3725            save_task.await.unwrap();
3726        }
3727        cx.run_until_parked();
3728
3729        // Confirm the thread is not visible and View More is shown.
3730        let entries = visible_entries_as_strings(&sidebar, cx);
3731        assert!(
3732            entries.iter().any(|e| e.contains("View More")),
3733            "should have View More button"
3734        );
3735        assert!(
3736            !entries.iter().any(|e| e.contains("Hidden gem")),
3737            "Hidden gem should be behind View More"
3738        );
3739
3740        // User searches for the hidden thread — it appears, and View More is gone.
3741        type_in_search(&sidebar, "hidden gem", cx);
3742        let filtered = visible_entries_as_strings(&sidebar, cx);
3743        assert_eq!(
3744            filtered,
3745            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
3746        );
3747        assert!(
3748            !filtered.iter().any(|e| e.contains("View More")),
3749            "View More should not appear when filtering"
3750        );
3751    }
3752
3753    #[gpui::test]
3754    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
3755        let project = init_test_project("/my-project", cx).await;
3756        let (multi_workspace, cx) =
3757            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3758        let sidebar = setup_sidebar(&multi_workspace, cx);
3759
3760        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3761        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3762
3763        let save_task = thread_store.update(cx, |store, cx| {
3764            store.save_thread(
3765                acp::SessionId::new(Arc::from("thread-1")),
3766                make_test_thread(
3767                    "Important thread",
3768                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3769                ),
3770                path_list.clone(),
3771                cx,
3772            )
3773        });
3774        save_task.await.unwrap();
3775        cx.run_until_parked();
3776
3777        // User focuses the sidebar and collapses the group using keyboard:
3778        // manually select the header, then press CollapseSelectedEntry to collapse.
3779        open_and_focus_sidebar(&sidebar, cx);
3780        sidebar.update_in(cx, |sidebar, _window, _cx| {
3781            sidebar.selection = Some(0);
3782        });
3783        cx.dispatch_action(CollapseSelectedEntry);
3784        cx.run_until_parked();
3785
3786        assert_eq!(
3787            visible_entries_as_strings(&sidebar, cx),
3788            vec!["> [my-project]  <== selected"]
3789        );
3790
3791        // User types a search — the thread appears even though its group is collapsed.
3792        type_in_search(&sidebar, "important", cx);
3793        assert_eq!(
3794            visible_entries_as_strings(&sidebar, cx),
3795            vec!["> [my-project]", "  Important thread  <== selected",]
3796        );
3797    }
3798
3799    #[gpui::test]
3800    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
3801        let project = init_test_project("/my-project", cx).await;
3802        let (multi_workspace, cx) =
3803            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3804        let sidebar = setup_sidebar(&multi_workspace, cx);
3805
3806        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3807        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3808
3809        for (id, title, hour) in [
3810            ("t-1", "Fix crash in panel", 3),
3811            ("t-2", "Fix lint warnings", 2),
3812            ("t-3", "Add new feature", 1),
3813        ] {
3814            let save_task = thread_store.update(cx, |store, cx| {
3815                store.save_thread(
3816                    acp::SessionId::new(Arc::from(id)),
3817                    make_test_thread(
3818                        title,
3819                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3820                    ),
3821                    path_list.clone(),
3822                    cx,
3823                )
3824            });
3825            save_task.await.unwrap();
3826        }
3827        cx.run_until_parked();
3828
3829        open_and_focus_sidebar(&sidebar, cx);
3830
3831        // User types "fix" — two threads match.
3832        type_in_search(&sidebar, "fix", cx);
3833        assert_eq!(
3834            visible_entries_as_strings(&sidebar, cx),
3835            vec![
3836                "v [my-project]",
3837                "  Fix crash in panel  <== selected",
3838                "  Fix lint warnings",
3839            ]
3840        );
3841
3842        // Selection starts on the first matching thread. User presses
3843        // SelectNext to move to the second match.
3844        cx.dispatch_action(SelectNext);
3845        assert_eq!(
3846            visible_entries_as_strings(&sidebar, cx),
3847            vec![
3848                "v [my-project]",
3849                "  Fix crash in panel",
3850                "  Fix lint warnings  <== selected",
3851            ]
3852        );
3853
3854        // User can also jump back with SelectPrevious.
3855        cx.dispatch_action(SelectPrevious);
3856        assert_eq!(
3857            visible_entries_as_strings(&sidebar, cx),
3858            vec![
3859                "v [my-project]",
3860                "  Fix crash in panel  <== selected",
3861                "  Fix lint warnings",
3862            ]
3863        );
3864    }
3865
3866    #[gpui::test]
3867    async fn test_confirm_on_historical_thread_activates_workspace(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        multi_workspace.update_in(cx, |mw, window, cx| {
3874            mw.create_workspace(window, cx);
3875        });
3876        cx.run_until_parked();
3877
3878        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3879        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3880
3881        let save_task = thread_store.update(cx, |store, cx| {
3882            store.save_thread(
3883                acp::SessionId::new(Arc::from("hist-1")),
3884                make_test_thread(
3885                    "Historical Thread",
3886                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
3887                ),
3888                path_list.clone(),
3889                cx,
3890            )
3891        });
3892        save_task.await.unwrap();
3893        cx.run_until_parked();
3894        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3895        cx.run_until_parked();
3896
3897        assert_eq!(
3898            visible_entries_as_strings(&sidebar, cx),
3899            vec![
3900                "v [my-project]",
3901                "  Historical Thread",
3902                "v [Empty Workspace]",
3903                "  [+ New Thread]",
3904            ]
3905        );
3906
3907        // Switch to workspace 1 so we can verify the confirm switches back.
3908        multi_workspace.update_in(cx, |mw, window, cx| {
3909            mw.activate_index(1, window, cx);
3910        });
3911        cx.run_until_parked();
3912        assert_eq!(
3913            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3914            1
3915        );
3916
3917        // Confirm on the historical (non-live) thread at index 1.
3918        // Before a previous fix, the workspace field was Option<usize> and
3919        // historical threads had None, so activate_thread early-returned
3920        // without switching the workspace.
3921        sidebar.update_in(cx, |sidebar, window, cx| {
3922            sidebar.selection = Some(1);
3923            sidebar.confirm(&Confirm, window, cx);
3924        });
3925        cx.run_until_parked();
3926
3927        assert_eq!(
3928            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3929            0
3930        );
3931    }
3932
3933    #[gpui::test]
3934    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
3935        let project = init_test_project("/my-project", cx).await;
3936        let (multi_workspace, cx) =
3937            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3938        let sidebar = setup_sidebar(&multi_workspace, cx);
3939
3940        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3941        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3942
3943        let save_task = thread_store.update(cx, |store, cx| {
3944            store.save_thread(
3945                acp::SessionId::new(Arc::from("t-1")),
3946                make_test_thread(
3947                    "Thread A",
3948                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3949                ),
3950                path_list.clone(),
3951                cx,
3952            )
3953        });
3954        save_task.await.unwrap();
3955        let save_task = thread_store.update(cx, |store, cx| {
3956            store.save_thread(
3957                acp::SessionId::new(Arc::from("t-2")),
3958                make_test_thread(
3959                    "Thread B",
3960                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3961                ),
3962                path_list.clone(),
3963                cx,
3964            )
3965        });
3966        save_task.await.unwrap();
3967        cx.run_until_parked();
3968        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3969        cx.run_until_parked();
3970
3971        assert_eq!(
3972            visible_entries_as_strings(&sidebar, cx),
3973            vec!["v [my-project]", "  Thread A", "  Thread B",]
3974        );
3975
3976        // Keyboard confirm preserves selection.
3977        sidebar.update_in(cx, |sidebar, window, cx| {
3978            sidebar.selection = Some(1);
3979            sidebar.confirm(&Confirm, window, cx);
3980        });
3981        assert_eq!(
3982            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
3983            Some(1)
3984        );
3985
3986        // Click handlers clear selection to None so no highlight lingers
3987        // after a click regardless of focus state. The hover style provides
3988        // visual feedback during mouse interaction instead.
3989        sidebar.update_in(cx, |sidebar, window, cx| {
3990            sidebar.selection = None;
3991            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3992            sidebar.toggle_collapse(&path_list, window, cx);
3993        });
3994        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3995
3996        // When the user tabs back into the sidebar, focus_in no longer
3997        // restores selection — it stays None.
3998        sidebar.update_in(cx, |sidebar, window, cx| {
3999            sidebar.focus_in(window, cx);
4000        });
4001        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
4002    }
4003
4004    #[gpui::test]
4005    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
4006        let project = init_test_project("/my-project", cx).await;
4007        let (multi_workspace, cx) =
4008            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4009        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4010
4011        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4012
4013        let connection = StubAgentConnection::new();
4014        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4015            acp::ContentChunk::new("Hi there!".into()),
4016        )]);
4017        open_thread_with_connection(&panel, connection, cx);
4018        send_message(&panel, cx);
4019
4020        let session_id = active_session_id(&panel, cx);
4021        save_thread_to_store(&session_id, &path_list, cx).await;
4022        cx.run_until_parked();
4023
4024        assert_eq!(
4025            visible_entries_as_strings(&sidebar, cx),
4026            vec!["v [my-project]", "  Hello *"]
4027        );
4028
4029        // Simulate the agent generating a title. The notification chain is:
4030        // AcpThread::set_title emits TitleUpdated →
4031        // ConnectionView::handle_thread_event calls cx.notify() →
4032        // AgentPanel observer fires and emits AgentPanelEvent →
4033        // Sidebar subscription calls update_entries / rebuild_contents.
4034        //
4035        // Before the fix, handle_thread_event did NOT call cx.notify() for
4036        // TitleUpdated, so the AgentPanel observer never fired and the
4037        // sidebar kept showing the old title.
4038        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
4039        thread.update(cx, |thread, cx| {
4040            thread
4041                .set_title("Friendly Greeting with AI".into(), cx)
4042                .detach();
4043        });
4044        cx.run_until_parked();
4045
4046        assert_eq!(
4047            visible_entries_as_strings(&sidebar, cx),
4048            vec!["v [my-project]", "  Friendly Greeting with AI *"]
4049        );
4050    }
4051
4052    #[gpui::test]
4053    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
4054        let project_a = init_test_project("/project-a", cx).await;
4055        let (multi_workspace, cx) = cx
4056            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4057        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
4058
4059        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4060
4061        // Save a thread so it appears in the list.
4062        let connection_a = StubAgentConnection::new();
4063        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4064            acp::ContentChunk::new("Done".into()),
4065        )]);
4066        open_thread_with_connection(&panel_a, connection_a, cx);
4067        send_message(&panel_a, cx);
4068        let session_id_a = active_session_id(&panel_a, cx);
4069        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
4070
4071        // Add a second workspace with its own agent panel.
4072        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
4073        fs.as_fake()
4074            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
4075            .await;
4076        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
4077        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
4078            mw.test_add_workspace(project_b.clone(), window, cx)
4079        });
4080        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
4081        cx.run_until_parked();
4082
4083        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
4084
4085        // ── 1. Initial state: no focused thread ──────────────────────────────
4086        // Workspace B is active (just added) and has no thread, so its header
4087        // is the active entry.
4088        sidebar.read_with(cx, |sidebar, _cx| {
4089            assert_eq!(
4090                sidebar.focused_thread, None,
4091                "Initially no thread should be focused"
4092            );
4093            let active_entry = sidebar
4094                .active_entry_index
4095                .and_then(|ix| sidebar.contents.entries.get(ix));
4096            assert!(
4097                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
4098                "Active entry should be the active workspace header"
4099            );
4100        });
4101
4102        // ── 2. Click thread in workspace A via sidebar ───────────────────────
4103        sidebar.update_in(cx, |sidebar, window, cx| {
4104            sidebar.activate_thread(
4105                Agent::NativeAgent,
4106                acp_thread::AgentSessionInfo {
4107                    session_id: session_id_a.clone(),
4108                    cwd: None,
4109                    title: Some("Test".into()),
4110                    updated_at: None,
4111                    created_at: None,
4112                    meta: None,
4113                },
4114                &workspace_a,
4115                window,
4116                cx,
4117            );
4118        });
4119        cx.run_until_parked();
4120
4121        sidebar.read_with(cx, |sidebar, _cx| {
4122            assert_eq!(
4123                sidebar.focused_thread.as_ref(),
4124                Some(&session_id_a),
4125                "After clicking a thread, it should be the focused thread"
4126            );
4127            let active_entry = sidebar.active_entry_index
4128                .and_then(|ix| sidebar.contents.entries.get(ix));
4129            assert!(
4130                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
4131                "Active entry should be the clicked thread"
4132            );
4133        });
4134
4135        workspace_a.read_with(cx, |workspace, cx| {
4136            assert!(
4137                workspace.panel::<AgentPanel>(cx).is_some(),
4138                "Agent panel should exist"
4139            );
4140            let dock = workspace.right_dock().read(cx);
4141            assert!(
4142                dock.is_open(),
4143                "Clicking a thread should open the agent panel dock"
4144            );
4145        });
4146
4147        // ── 3. Open thread in workspace B, then click it via sidebar ─────────
4148        let connection_b = StubAgentConnection::new();
4149        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4150            acp::ContentChunk::new("Thread B".into()),
4151        )]);
4152        open_thread_with_connection(&panel_b, connection_b, cx);
4153        send_message(&panel_b, cx);
4154        let session_id_b = active_session_id(&panel_b, cx);
4155        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4156        save_thread_to_store(&session_id_b, &path_list_b, cx).await;
4157        cx.run_until_parked();
4158
4159        // Opening a thread in a non-active workspace should NOT change
4160        // focused_thread — it's derived from the active workspace.
4161        sidebar.read_with(cx, |sidebar, _cx| {
4162            assert_eq!(
4163                sidebar.focused_thread.as_ref(),
4164                Some(&session_id_a),
4165                "Opening a thread in a non-active workspace should not affect focused_thread"
4166            );
4167        });
4168
4169        // Workspace A is currently active. Click a thread in workspace B,
4170        // which also triggers a workspace switch.
4171        sidebar.update_in(cx, |sidebar, window, cx| {
4172            sidebar.activate_thread(
4173                Agent::NativeAgent,
4174                acp_thread::AgentSessionInfo {
4175                    session_id: session_id_b.clone(),
4176                    cwd: None,
4177                    title: Some("Thread B".into()),
4178                    updated_at: None,
4179                    created_at: None,
4180                    meta: None,
4181                },
4182                &workspace_b,
4183                window,
4184                cx,
4185            );
4186        });
4187        cx.run_until_parked();
4188
4189        sidebar.read_with(cx, |sidebar, _cx| {
4190            assert_eq!(
4191                sidebar.focused_thread.as_ref(),
4192                Some(&session_id_b),
4193                "Clicking a thread in another workspace should focus that thread"
4194            );
4195            let active_entry = sidebar
4196                .active_entry_index
4197                .and_then(|ix| sidebar.contents.entries.get(ix));
4198            assert!(
4199                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b),
4200                "Active entry should be the cross-workspace thread"
4201            );
4202        });
4203
4204        // ── 4. Switch workspace → focused_thread reflects new workspace ──────
4205        multi_workspace.update_in(cx, |mw, window, cx| {
4206            mw.activate_next_workspace(window, cx);
4207        });
4208        cx.run_until_parked();
4209
4210        // Workspace A is now active. Its agent panel still has session_id_a
4211        // loaded, so focused_thread should reflect that.
4212        sidebar.read_with(cx, |sidebar, _cx| {
4213            assert_eq!(
4214                sidebar.focused_thread.as_ref(),
4215                Some(&session_id_a),
4216                "Switching workspaces should derive focused_thread from the new active workspace"
4217            );
4218            let active_entry = sidebar
4219                .active_entry_index
4220                .and_then(|ix| sidebar.contents.entries.get(ix));
4221            assert!(
4222                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
4223                "Active entry should be workspace_a's active thread"
4224            );
4225        });
4226
4227        // ── 5. Opening a thread in a non-active workspace is ignored ──────────
4228        let connection_b2 = StubAgentConnection::new();
4229        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4230            acp::ContentChunk::new("New thread".into()),
4231        )]);
4232        open_thread_with_connection(&panel_b, connection_b2, cx);
4233        send_message(&panel_b, cx);
4234        let session_id_b2 = active_session_id(&panel_b, cx);
4235        save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
4236        cx.run_until_parked();
4237
4238        // Workspace A is still active, so focused_thread stays on session_id_a.
4239        sidebar.read_with(cx, |sidebar, _cx| {
4240            assert_eq!(
4241                sidebar.focused_thread.as_ref(),
4242                Some(&session_id_a),
4243                "Opening a thread in a non-active workspace should not affect focused_thread"
4244            );
4245        });
4246
4247        // ── 6. Activating workspace B shows its active thread ────────────────
4248        sidebar.update_in(cx, |sidebar, window, cx| {
4249            sidebar.activate_workspace(&workspace_b, window, cx);
4250        });
4251        cx.run_until_parked();
4252
4253        // Workspace B is now active with session_id_b2 loaded.
4254        sidebar.read_with(cx, |sidebar, _cx| {
4255            assert_eq!(
4256                sidebar.focused_thread.as_ref(),
4257                Some(&session_id_b2),
4258                "Activating workspace_b should show workspace_b's active thread"
4259            );
4260            let active_entry = sidebar
4261                .active_entry_index
4262                .and_then(|ix| sidebar.contents.entries.get(ix));
4263            assert!(
4264                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
4265                "Active entry should be workspace_b's active thread"
4266            );
4267        });
4268
4269        // ── 7. Switching back to workspace A reflects its thread ─────────────
4270        multi_workspace.update_in(cx, |mw, window, cx| {
4271            mw.activate_next_workspace(window, cx);
4272        });
4273        cx.run_until_parked();
4274
4275        sidebar.read_with(cx, |sidebar, _cx| {
4276            assert_eq!(
4277                sidebar.focused_thread.as_ref(),
4278                Some(&session_id_a),
4279                "Switching back to workspace_a should show its active thread"
4280            );
4281        });
4282    }
4283
4284    async fn save_named_thread(
4285        session_id: &str,
4286        title: &str,
4287        path_list: &PathList,
4288        cx: &mut gpui::VisualTestContext,
4289    ) {
4290        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
4291        let save_task = thread_store.update(cx, |store, cx| {
4292            store.save_thread(
4293                acp::SessionId::new(Arc::from(session_id)),
4294                make_test_thread(
4295                    title,
4296                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4297                ),
4298                path_list.clone(),
4299                cx,
4300            )
4301        });
4302        save_task.await.unwrap();
4303        cx.run_until_parked();
4304    }
4305
4306    async fn init_test_project_with_git(
4307        worktree_path: &str,
4308        cx: &mut TestAppContext,
4309    ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
4310        init_test(cx);
4311        let fs = FakeFs::new(cx.executor());
4312        fs.insert_tree(
4313            worktree_path,
4314            serde_json::json!({
4315                ".git": {},
4316                "src": {},
4317            }),
4318        )
4319        .await;
4320        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4321        let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
4322        (project, fs)
4323    }
4324
4325    #[gpui::test]
4326    async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
4327        let (project, fs) = init_test_project_with_git("/project", cx).await;
4328
4329        fs.as_fake()
4330            .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4331                state.worktrees.push(git::repository::Worktree {
4332                    path: std::path::PathBuf::from("/wt/rosewood"),
4333                    ref_name: "refs/heads/rosewood".into(),
4334                    sha: "abc".into(),
4335                });
4336            })
4337            .unwrap();
4338
4339        project
4340            .update(cx, |project, cx| project.git_scans_complete(cx))
4341            .await;
4342
4343        let (multi_workspace, cx) =
4344            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4345        let sidebar = setup_sidebar(&multi_workspace, cx);
4346
4347        let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
4348        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
4349        save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await;
4350        save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await;
4351
4352        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4353        cx.run_until_parked();
4354
4355        // Search for "rosewood" — should match the worktree name, not the title.
4356        type_in_search(&sidebar, "rosewood", cx);
4357
4358        assert_eq!(
4359            visible_entries_as_strings(&sidebar, cx),
4360            vec!["v [project]", "  Fix Bug {rosewood}  <== selected"],
4361        );
4362    }
4363
4364    #[gpui::test]
4365    async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
4366        let (project, fs) = init_test_project_with_git("/project", cx).await;
4367
4368        project
4369            .update(cx, |project, cx| project.git_scans_complete(cx))
4370            .await;
4371
4372        let (multi_workspace, cx) =
4373            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4374        let sidebar = setup_sidebar(&multi_workspace, cx);
4375
4376        // Save a thread against a worktree path that doesn't exist yet.
4377        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
4378        save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await;
4379
4380        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4381        cx.run_until_parked();
4382
4383        // Thread is not visible yet — no worktree knows about this path.
4384        assert_eq!(
4385            visible_entries_as_strings(&sidebar, cx),
4386            vec!["v [project]", "  [+ New Thread]"]
4387        );
4388
4389        // Now add the worktree to the git state and trigger a rescan.
4390        fs.as_fake()
4391            .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
4392                state.worktrees.push(git::repository::Worktree {
4393                    path: std::path::PathBuf::from("/wt/rosewood"),
4394                    ref_name: "refs/heads/rosewood".into(),
4395                    sha: "abc".into(),
4396                });
4397            })
4398            .unwrap();
4399
4400        cx.run_until_parked();
4401
4402        assert_eq!(
4403            visible_entries_as_strings(&sidebar, cx),
4404            vec!["v [project]", "  Worktree Thread {rosewood}",]
4405        );
4406    }
4407
4408    #[gpui::test]
4409    async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
4410        init_test(cx);
4411        let fs = FakeFs::new(cx.executor());
4412
4413        // Create the main repo directory (not opened as a workspace yet).
4414        fs.insert_tree(
4415            "/project",
4416            serde_json::json!({
4417                ".git": {
4418                    "worktrees": {
4419                        "feature-a": {
4420                            "commondir": "../../",
4421                            "HEAD": "ref: refs/heads/feature-a",
4422                        },
4423                        "feature-b": {
4424                            "commondir": "../../",
4425                            "HEAD": "ref: refs/heads/feature-b",
4426                        },
4427                    },
4428                },
4429                "src": {},
4430            }),
4431        )
4432        .await;
4433
4434        // Two worktree checkouts whose .git files point back to the main repo.
4435        fs.insert_tree(
4436            "/wt-feature-a",
4437            serde_json::json!({
4438                ".git": "gitdir: /project/.git/worktrees/feature-a",
4439                "src": {},
4440            }),
4441        )
4442        .await;
4443        fs.insert_tree(
4444            "/wt-feature-b",
4445            serde_json::json!({
4446                ".git": "gitdir: /project/.git/worktrees/feature-b",
4447                "src": {},
4448            }),
4449        )
4450        .await;
4451
4452        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4453
4454        let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4455        let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
4456
4457        project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4458        project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4459
4460        // Open both worktrees as workspaces — no main repo yet.
4461        let (multi_workspace, cx) = cx
4462            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4463        multi_workspace.update_in(cx, |mw, window, cx| {
4464            mw.test_add_workspace(project_b.clone(), window, cx);
4465        });
4466        let sidebar = setup_sidebar(&multi_workspace, cx);
4467
4468        let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4469        let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
4470        save_named_thread("thread-a", "Thread A", &paths_a, cx).await;
4471        save_named_thread("thread-b", "Thread B", &paths_b, cx).await;
4472
4473        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4474        cx.run_until_parked();
4475
4476        // Without the main repo, each worktree has its own header.
4477        assert_eq!(
4478            visible_entries_as_strings(&sidebar, cx),
4479            vec![
4480                "v [wt-feature-a]",
4481                "  Thread A",
4482                "v [wt-feature-b]",
4483                "  Thread B",
4484            ]
4485        );
4486
4487        // Configure the main repo to list both worktrees before opening
4488        // it so the initial git scan picks them up.
4489        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4490            state.worktrees.push(git::repository::Worktree {
4491                path: std::path::PathBuf::from("/wt-feature-a"),
4492                ref_name: "refs/heads/feature-a".into(),
4493                sha: "aaa".into(),
4494            });
4495            state.worktrees.push(git::repository::Worktree {
4496                path: std::path::PathBuf::from("/wt-feature-b"),
4497                ref_name: "refs/heads/feature-b".into(),
4498                sha: "bbb".into(),
4499            });
4500        })
4501        .unwrap();
4502
4503        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4504        main_project
4505            .update(cx, |p, cx| p.git_scans_complete(cx))
4506            .await;
4507
4508        multi_workspace.update_in(cx, |mw, window, cx| {
4509            mw.test_add_workspace(main_project.clone(), window, cx);
4510        });
4511        cx.run_until_parked();
4512
4513        // Both worktree workspaces should now be absorbed under the main
4514        // repo header, with worktree chips.
4515        assert_eq!(
4516            visible_entries_as_strings(&sidebar, cx),
4517            vec![
4518                "v [project]",
4519                "  Thread A {wt-feature-a}",
4520                "  Thread B {wt-feature-b}",
4521            ]
4522        );
4523
4524        // Remove feature-b from the main repo's linked worktrees.
4525        // The feature-b workspace should be pruned automatically.
4526        fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
4527            state
4528                .worktrees
4529                .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
4530        })
4531        .unwrap();
4532
4533        cx.run_until_parked();
4534
4535        // feature-b's workspace is pruned; feature-a remains absorbed
4536        // under the main repo.
4537        assert_eq!(
4538            visible_entries_as_strings(&sidebar, cx),
4539            vec!["v [project]", "  Thread A {wt-feature-a}",]
4540        );
4541    }
4542
4543    #[gpui::test]
4544    async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
4545        cx: &mut TestAppContext,
4546    ) {
4547        init_test(cx);
4548        let fs = FakeFs::new(cx.executor());
4549
4550        fs.insert_tree(
4551            "/project",
4552            serde_json::json!({
4553                ".git": {
4554                    "worktrees": {
4555                        "feature-a": {
4556                            "commondir": "../../",
4557                            "HEAD": "ref: refs/heads/feature-a",
4558                        },
4559                    },
4560                },
4561                "src": {},
4562            }),
4563        )
4564        .await;
4565
4566        fs.insert_tree(
4567            "/wt-feature-a",
4568            serde_json::json!({
4569                ".git": "gitdir: /project/.git/worktrees/feature-a",
4570                "src": {},
4571            }),
4572        )
4573        .await;
4574
4575        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4576            state.worktrees.push(git::repository::Worktree {
4577                path: std::path::PathBuf::from("/wt-feature-a"),
4578                ref_name: "refs/heads/feature-a".into(),
4579                sha: "aaa".into(),
4580            });
4581        })
4582        .unwrap();
4583
4584        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4585
4586        // Only open the main repo — no workspace for the worktree.
4587        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4588        main_project
4589            .update(cx, |p, cx| p.git_scans_complete(cx))
4590            .await;
4591
4592        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4593            MultiWorkspace::test_new(main_project.clone(), window, cx)
4594        });
4595        let sidebar = setup_sidebar(&multi_workspace, cx);
4596
4597        // Save a thread for the worktree path (no workspace for it).
4598        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4599        save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
4600
4601        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4602        cx.run_until_parked();
4603
4604        // Thread should appear under the main repo with a worktree chip.
4605        assert_eq!(
4606            visible_entries_as_strings(&sidebar, cx),
4607            vec!["v [project]", "  WT Thread {wt-feature-a}"],
4608        );
4609
4610        // Only 1 workspace should exist.
4611        assert_eq!(
4612            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4613            1,
4614        );
4615
4616        // Focus the sidebar and select the worktree thread.
4617        open_and_focus_sidebar(&sidebar, cx);
4618        sidebar.update_in(cx, |sidebar, _window, _cx| {
4619            sidebar.selection = Some(1); // index 0 is header, 1 is the thread
4620        });
4621
4622        // Confirm to open the worktree thread.
4623        cx.dispatch_action(Confirm);
4624        cx.run_until_parked();
4625
4626        // A new workspace should have been created for the worktree path.
4627        let new_workspace = multi_workspace.read_with(cx, |mw, _| {
4628            assert_eq!(
4629                mw.workspaces().len(),
4630                2,
4631                "confirming a worktree thread without a workspace should open one",
4632            );
4633            mw.workspaces()[1].clone()
4634        });
4635
4636        let new_path_list =
4637            new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
4638        assert_eq!(
4639            new_path_list,
4640            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
4641            "the new workspace should have been opened for the worktree path",
4642        );
4643    }
4644
4645    #[gpui::test]
4646    async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
4647        cx: &mut TestAppContext,
4648    ) {
4649        init_test(cx);
4650        let fs = FakeFs::new(cx.executor());
4651
4652        fs.insert_tree(
4653            "/project",
4654            serde_json::json!({
4655                ".git": {
4656                    "worktrees": {
4657                        "feature-a": {
4658                            "commondir": "../../",
4659                            "HEAD": "ref: refs/heads/feature-a",
4660                        },
4661                    },
4662                },
4663                "src": {},
4664            }),
4665        )
4666        .await;
4667
4668        fs.insert_tree(
4669            "/wt-feature-a",
4670            serde_json::json!({
4671                ".git": "gitdir: /project/.git/worktrees/feature-a",
4672                "src": {},
4673            }),
4674        )
4675        .await;
4676
4677        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4678            state.worktrees.push(git::repository::Worktree {
4679                path: std::path::PathBuf::from("/wt-feature-a"),
4680                ref_name: "refs/heads/feature-a".into(),
4681                sha: "aaa".into(),
4682            });
4683        })
4684        .unwrap();
4685
4686        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4687
4688        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4689        let worktree_project =
4690            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4691
4692        main_project
4693            .update(cx, |p, cx| p.git_scans_complete(cx))
4694            .await;
4695        worktree_project
4696            .update(cx, |p, cx| p.git_scans_complete(cx))
4697            .await;
4698
4699        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4700            MultiWorkspace::test_new(main_project.clone(), window, cx)
4701        });
4702
4703        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4704            mw.test_add_workspace(worktree_project.clone(), window, cx)
4705        });
4706
4707        // Activate the main workspace before setting up the sidebar.
4708        multi_workspace.update_in(cx, |mw, window, cx| {
4709            mw.activate_index(0, window, cx);
4710        });
4711
4712        let sidebar = setup_sidebar(&multi_workspace, cx);
4713
4714        let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
4715        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4716        save_named_thread("thread-main", "Main Thread", &paths_main, cx).await;
4717        save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
4718
4719        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4720        cx.run_until_parked();
4721
4722        // The worktree workspace should be absorbed under the main repo.
4723        let entries = visible_entries_as_strings(&sidebar, cx);
4724        assert_eq!(entries.len(), 3);
4725        assert_eq!(entries[0], "v [project]");
4726        assert!(entries.contains(&"  Main Thread".to_string()));
4727        assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
4728
4729        let wt_thread_index = entries
4730            .iter()
4731            .position(|e| e.contains("WT Thread"))
4732            .expect("should find the worktree thread entry");
4733
4734        assert_eq!(
4735            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4736            0,
4737            "main workspace should be active initially"
4738        );
4739
4740        // Focus the sidebar and select the absorbed worktree thread.
4741        open_and_focus_sidebar(&sidebar, cx);
4742        sidebar.update_in(cx, |sidebar, _window, _cx| {
4743            sidebar.selection = Some(wt_thread_index);
4744        });
4745
4746        // Confirm to activate the worktree thread.
4747        cx.dispatch_action(Confirm);
4748        cx.run_until_parked();
4749
4750        // The worktree workspace should now be active, not the main one.
4751        let active_workspace = multi_workspace.read_with(cx, |mw, _| {
4752            mw.workspaces()[mw.active_workspace_index()].clone()
4753        });
4754        assert_eq!(
4755            active_workspace, worktree_workspace,
4756            "clicking an absorbed worktree thread should activate the worktree workspace"
4757        );
4758    }
4759
4760    #[gpui::test]
4761    async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
4762        cx: &mut TestAppContext,
4763    ) {
4764        // Thread has saved metadata in ThreadStore. A matching workspace is
4765        // already open. Expected: activates the matching workspace.
4766        init_test(cx);
4767        let fs = FakeFs::new(cx.executor());
4768        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4769            .await;
4770        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4771            .await;
4772        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4773
4774        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4775        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4776
4777        let (multi_workspace, cx) =
4778            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4779
4780        multi_workspace.update_in(cx, |mw, window, cx| {
4781            mw.test_add_workspace(project_b, window, cx);
4782        });
4783
4784        let sidebar = setup_sidebar(&multi_workspace, cx);
4785
4786        // Save a thread with path_list pointing to project-b.
4787        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4788        let session_id = acp::SessionId::new(Arc::from("archived-1"));
4789        save_thread_to_store(&session_id, &path_list_b, cx).await;
4790
4791        // Ensure workspace A is active.
4792        multi_workspace.update_in(cx, |mw, window, cx| {
4793            mw.activate_index(0, window, cx);
4794        });
4795        cx.run_until_parked();
4796        assert_eq!(
4797            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4798            0
4799        );
4800
4801        // Call activate_archived_thread – should resolve saved paths and
4802        // switch to the workspace for project-b.
4803        sidebar.update_in(cx, |sidebar, window, cx| {
4804            sidebar.activate_archived_thread(
4805                Agent::NativeAgent,
4806                acp_thread::AgentSessionInfo {
4807                    session_id: session_id.clone(),
4808                    cwd: Some("/project-b".into()),
4809                    title: Some("Archived Thread".into()),
4810                    updated_at: None,
4811                    created_at: None,
4812                    meta: None,
4813                },
4814                window,
4815                cx,
4816            );
4817        });
4818        cx.run_until_parked();
4819
4820        assert_eq!(
4821            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4822            1,
4823            "should have activated the workspace matching the saved path_list"
4824        );
4825    }
4826
4827    #[gpui::test]
4828    async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
4829        cx: &mut TestAppContext,
4830    ) {
4831        // Thread has no saved metadata but session_info has cwd. A matching
4832        // workspace is open. Expected: uses cwd to find and activate it.
4833        init_test(cx);
4834        let fs = FakeFs::new(cx.executor());
4835        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4836            .await;
4837        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4838            .await;
4839        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4840
4841        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4842        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4843
4844        let (multi_workspace, cx) =
4845            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4846
4847        multi_workspace.update_in(cx, |mw, window, cx| {
4848            mw.test_add_workspace(project_b, window, cx);
4849        });
4850
4851        let sidebar = setup_sidebar(&multi_workspace, cx);
4852
4853        // Start with workspace A active.
4854        multi_workspace.update_in(cx, |mw, window, cx| {
4855            mw.activate_index(0, window, cx);
4856        });
4857        cx.run_until_parked();
4858        assert_eq!(
4859            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4860            0
4861        );
4862
4863        // No thread saved to the store – cwd is the only path hint.
4864        sidebar.update_in(cx, |sidebar, window, cx| {
4865            sidebar.activate_archived_thread(
4866                Agent::NativeAgent,
4867                acp_thread::AgentSessionInfo {
4868                    session_id: acp::SessionId::new(Arc::from("unknown-session")),
4869                    cwd: Some(std::path::PathBuf::from("/project-b")),
4870                    title: Some("CWD Thread".into()),
4871                    updated_at: None,
4872                    created_at: None,
4873                    meta: None,
4874                },
4875                window,
4876                cx,
4877            );
4878        });
4879        cx.run_until_parked();
4880
4881        assert_eq!(
4882            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4883            1,
4884            "should have activated the workspace matching the cwd"
4885        );
4886    }
4887
4888    #[gpui::test]
4889    async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4890        cx: &mut TestAppContext,
4891    ) {
4892        // Thread has no saved metadata and no cwd. Expected: falls back to
4893        // the currently active workspace.
4894        init_test(cx);
4895        let fs = FakeFs::new(cx.executor());
4896        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4897            .await;
4898        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4899            .await;
4900        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4901
4902        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4903        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4904
4905        let (multi_workspace, cx) =
4906            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4907
4908        multi_workspace.update_in(cx, |mw, window, cx| {
4909            mw.test_add_workspace(project_b, window, cx);
4910        });
4911
4912        let sidebar = setup_sidebar(&multi_workspace, cx);
4913
4914        // Activate workspace B (index 1) to make it the active one.
4915        multi_workspace.update_in(cx, |mw, window, cx| {
4916            mw.activate_index(1, window, cx);
4917        });
4918        cx.run_until_parked();
4919        assert_eq!(
4920            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4921            1
4922        );
4923
4924        // No saved thread, no cwd – should fall back to the active workspace.
4925        sidebar.update_in(cx, |sidebar, window, cx| {
4926            sidebar.activate_archived_thread(
4927                Agent::NativeAgent,
4928                acp_thread::AgentSessionInfo {
4929                    session_id: acp::SessionId::new(Arc::from("no-context-session")),
4930                    cwd: None,
4931                    title: Some("Contextless Thread".into()),
4932                    updated_at: None,
4933                    created_at: None,
4934                    meta: None,
4935                },
4936                window,
4937                cx,
4938            );
4939        });
4940        cx.run_until_parked();
4941
4942        assert_eq!(
4943            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4944            1,
4945            "should have stayed on the active workspace when no path info is available"
4946        );
4947    }
4948
4949    #[gpui::test]
4950    async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
4951        cx: &mut TestAppContext,
4952    ) {
4953        // Thread has saved metadata pointing to a path with no open workspace.
4954        // Expected: opens a new workspace for that path.
4955        init_test(cx);
4956        let fs = FakeFs::new(cx.executor());
4957        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4958            .await;
4959        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4960            .await;
4961        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4962
4963        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4964
4965        let (multi_workspace, cx) =
4966            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4967
4968        let sidebar = setup_sidebar(&multi_workspace, cx);
4969
4970        // Save a thread with path_list pointing to project-b – which has no
4971        // open workspace.
4972        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4973        let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4974        save_thread_to_store(&session_id, &path_list_b, cx).await;
4975
4976        assert_eq!(
4977            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4978            1,
4979            "should start with one workspace"
4980        );
4981
4982        sidebar.update_in(cx, |sidebar, window, cx| {
4983            sidebar.activate_archived_thread(
4984                Agent::NativeAgent,
4985                acp_thread::AgentSessionInfo {
4986                    session_id: session_id.clone(),
4987                    cwd: None,
4988                    title: Some("New WS Thread".into()),
4989                    updated_at: None,
4990                    created_at: None,
4991                    meta: None,
4992                },
4993                window,
4994                cx,
4995            );
4996        });
4997        cx.run_until_parked();
4998
4999        assert_eq!(
5000            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
5001            2,
5002            "should have opened a second workspace for the archived thread's saved paths"
5003        );
5004    }
5005}