sidebar.rs

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