sidebar.rs

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