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