sidebar.rs

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