sidebar.rs

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