sidebar.rs

   1use acp_thread::ThreadStatus;
   2use agent::ThreadStore;
   3use agent_client_protocol as acp;
   4use agent_ui::{AgentPanel, AgentPanelEvent, NewThread};
   5use chrono::Utc;
   6use editor::{Editor, EditorElement, EditorStyle};
   7use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
   8use gpui::{
   9    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState,
  10    Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px,
  11    relative, rems,
  12};
  13use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
  14use project::Event as ProjectEvent;
  15use recent_projects::RecentProjects;
  16use settings::Settings;
  17use std::collections::{HashMap, HashSet};
  18use std::mem;
  19use theme::{ActiveTheme, ThemeSettings};
  20use ui::utils::TRAFFIC_LIGHT_PADDING;
  21use ui::{
  22    AgentThreadStatus, ButtonStyle, GradientFade, HighlightedLabel, IconButtonShape, KeyBinding,
  23    ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar,
  24    prelude::*,
  25};
  26use util::path_list::PathList;
  27use workspace::{
  28    FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
  29    SidebarEvent, ToggleWorkspaceSidebar, Workspace,
  30};
  31use zed_actions::OpenRecent;
  32use zed_actions::editor::{MoveDown, MoveUp};
  33
  34actions!(
  35    agents_sidebar,
  36    [
  37        /// Collapses the selected entry in the workspace sidebar.
  38        CollapseSelectedEntry,
  39        /// Expands the selected entry in the workspace sidebar.
  40        ExpandSelectedEntry,
  41    ]
  42);
  43
  44const DEFAULT_WIDTH: Pixels = px(320.0);
  45const MIN_WIDTH: Pixels = px(200.0);
  46const MAX_WIDTH: Pixels = px(800.0);
  47const DEFAULT_THREADS_SHOWN: usize = 5;
  48
  49#[derive(Clone, Debug)]
  50struct ActiveThreadInfo {
  51    session_id: acp::SessionId,
  52    title: SharedString,
  53    status: AgentThreadStatus,
  54    icon: IconName,
  55    icon_from_external_svg: Option<SharedString>,
  56    is_background: bool,
  57}
  58
  59impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
  60    fn from(info: &ActiveThreadInfo) -> Self {
  61        Self {
  62            session_id: info.session_id.clone(),
  63            cwd: None,
  64            title: Some(info.title.clone()),
  65            updated_at: Some(Utc::now()),
  66            meta: None,
  67        }
  68    }
  69}
  70
  71#[derive(Clone)]
  72struct ThreadEntry {
  73    session_info: acp_thread::AgentSessionInfo,
  74    icon: IconName,
  75    icon_from_external_svg: Option<SharedString>,
  76    status: AgentThreadStatus,
  77    workspace: Entity<Workspace>,
  78    is_live: bool,
  79    is_background: bool,
  80    highlight_positions: Vec<usize>,
  81}
  82
  83#[derive(Clone)]
  84enum ListEntry {
  85    ProjectHeader {
  86        path_list: PathList,
  87        label: SharedString,
  88        workspace: Entity<Workspace>,
  89        highlight_positions: Vec<usize>,
  90        has_threads: bool,
  91    },
  92    Thread(ThreadEntry),
  93    ViewMore {
  94        path_list: PathList,
  95        remaining_count: usize,
  96        is_fully_expanded: bool,
  97    },
  98    NewThread {
  99        path_list: PathList,
 100        workspace: Entity<Workspace>,
 101    },
 102}
 103
 104impl From<ThreadEntry> for ListEntry {
 105    fn from(thread: ThreadEntry) -> Self {
 106        ListEntry::Thread(thread)
 107    }
 108}
 109
 110#[derive(Default)]
 111struct SidebarContents {
 112    entries: Vec<ListEntry>,
 113    notified_threads: HashSet<acp::SessionId>,
 114}
 115
 116impl SidebarContents {
 117    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
 118        self.notified_threads.contains(session_id)
 119    }
 120}
 121
 122fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 123    let mut positions = Vec::new();
 124    let mut query_chars = query.chars().peekable();
 125
 126    for (byte_idx, candidate_char) in candidate.char_indices() {
 127        if let Some(&query_char) = query_chars.peek() {
 128            if candidate_char.eq_ignore_ascii_case(&query_char) {
 129                positions.push(byte_idx);
 130                query_chars.next();
 131            }
 132        } else {
 133            break;
 134        }
 135    }
 136
 137    if query_chars.peek().is_none() {
 138        Some(positions)
 139    } else {
 140        None
 141    }
 142}
 143
 144fn workspace_path_list_and_label(
 145    workspace: &Entity<Workspace>,
 146    cx: &App,
 147) -> (PathList, SharedString) {
 148    let workspace_ref = workspace.read(cx);
 149    let mut paths = Vec::new();
 150    let mut names = Vec::new();
 151
 152    for worktree in workspace_ref.worktrees(cx) {
 153        let worktree_ref = worktree.read(cx);
 154        if !worktree_ref.is_visible() {
 155            continue;
 156        }
 157        let abs_path = worktree_ref.abs_path();
 158        paths.push(abs_path.to_path_buf());
 159        if let Some(name) = abs_path.file_name() {
 160            names.push(name.to_string_lossy().to_string());
 161        }
 162    }
 163
 164    let label: SharedString = if names.is_empty() {
 165        // TODO: Can we do something better in this case?
 166        "Empty Workspace".into()
 167    } else {
 168        names.join(", ").into()
 169    };
 170
 171    (PathList::new(&paths), label)
 172}
 173
 174pub struct Sidebar {
 175    multi_workspace: WeakEntity<MultiWorkspace>,
 176    width: Pixels,
 177    focus_handle: FocusHandle,
 178    filter_editor: Entity<Editor>,
 179    list_state: ListState,
 180    contents: SidebarContents,
 181    /// The index of the list item that currently has the keyboard focus
 182    ///
 183    /// Note: This is NOT the same as the active item.
 184    selection: Option<usize>,
 185    focused_thread: Option<acp::SessionId>,
 186    active_entry_index: Option<usize>,
 187    collapsed_groups: HashSet<PathList>,
 188    expanded_groups: HashMap<PathList, usize>,
 189    recent_projects_popover_handle: PopoverMenuHandle<RecentProjects>,
 190}
 191
 192impl EventEmitter<SidebarEvent> for Sidebar {}
 193
 194impl Sidebar {
 195    pub fn new(
 196        multi_workspace: Entity<MultiWorkspace>,
 197        window: &mut Window,
 198        cx: &mut Context<Self>,
 199    ) -> Self {
 200        let focus_handle = cx.focus_handle();
 201        cx.on_focus_in(&focus_handle, window, Self::focus_in)
 202            .detach();
 203
 204        let filter_editor = cx.new(|cx| {
 205            let mut editor = Editor::single_line(window, cx);
 206            editor.set_placeholder_text("Search…", window, cx);
 207            editor
 208        });
 209
 210        cx.subscribe_in(
 211            &multi_workspace,
 212            window,
 213            |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
 214                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
 215                    this.focused_thread = None;
 216                    this.update_entries(cx);
 217                }
 218                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
 219                    this.subscribe_to_workspace(workspace, window, cx);
 220                    this.update_entries(cx);
 221                }
 222                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
 223                    this.update_entries(cx);
 224                }
 225            },
 226        )
 227        .detach();
 228
 229        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 230            if let editor::EditorEvent::BufferEdited = event {
 231                let query = this.filter_editor.read(cx).text(cx);
 232                if !query.is_empty() {
 233                    this.selection.take();
 234                }
 235                this.update_entries(cx);
 236                if !query.is_empty() {
 237                    this.selection = this
 238                        .contents
 239                        .entries
 240                        .iter()
 241                        .position(|entry| matches!(entry, ListEntry::Thread(_)))
 242                        .or_else(|| {
 243                            if this.contents.entries.is_empty() {
 244                                None
 245                            } else {
 246                                Some(0)
 247                            }
 248                        });
 249                }
 250            }
 251        })
 252        .detach();
 253
 254        let thread_store = ThreadStore::global(cx);
 255        cx.observe_in(&thread_store, window, |this, _, _window, cx| {
 256            this.update_entries(cx);
 257        })
 258        .detach();
 259
 260        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
 261            this.update_entries(cx);
 262        })
 263        .detach();
 264
 265        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 266        cx.defer_in(window, move |this, window, cx| {
 267            for workspace in &workspaces {
 268                this.subscribe_to_workspace(workspace, window, cx);
 269            }
 270            this.update_entries(cx);
 271        });
 272
 273        Self {
 274            multi_workspace: multi_workspace.downgrade(),
 275            width: DEFAULT_WIDTH,
 276            focus_handle,
 277            filter_editor,
 278            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 279            contents: SidebarContents::default(),
 280            selection: None,
 281            focused_thread: None,
 282            active_entry_index: None,
 283            collapsed_groups: HashSet::new(),
 284            expanded_groups: HashMap::new(),
 285            recent_projects_popover_handle: PopoverMenuHandle::default(),
 286        }
 287    }
 288
 289    fn subscribe_to_workspace(
 290        &self,
 291        workspace: &Entity<Workspace>,
 292        window: &mut Window,
 293        cx: &mut Context<Self>,
 294    ) {
 295        let project = workspace.read(cx).project().clone();
 296        cx.subscribe_in(
 297            &project,
 298            window,
 299            |this, _project, event, _window, cx| match event {
 300                ProjectEvent::WorktreeAdded(_)
 301                | ProjectEvent::WorktreeRemoved(_)
 302                | ProjectEvent::WorktreeOrderChanged => {
 303                    this.update_entries(cx);
 304                }
 305                _ => {}
 306            },
 307        )
 308        .detach();
 309
 310        cx.subscribe_in(
 311            workspace,
 312            window,
 313            |this, _workspace, event: &workspace::Event, window, cx| {
 314                if let workspace::Event::PanelAdded(view) = event {
 315                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
 316                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
 317                    }
 318                }
 319            },
 320        )
 321        .detach();
 322
 323        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 324            self.subscribe_to_agent_panel(&agent_panel, window, cx);
 325        }
 326    }
 327
 328    fn subscribe_to_agent_panel(
 329        &self,
 330        agent_panel: &Entity<AgentPanel>,
 331        window: &mut Window,
 332        cx: &mut Context<Self>,
 333    ) {
 334        cx.subscribe_in(
 335            agent_panel,
 336            window,
 337            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
 338                AgentPanelEvent::ActiveViewChanged => {
 339                    match agent_panel.read(cx).active_connection_view() {
 340                        Some(thread) => {
 341                            if let Some(session_id) = thread.read(cx).parent_id(cx) {
 342                                this.focused_thread = Some(session_id);
 343                            }
 344                        }
 345                        None => {
 346                            this.focused_thread = None;
 347                        }
 348                    }
 349                    this.update_entries(cx);
 350                }
 351                AgentPanelEvent::ThreadFocused => {
 352                    let new_focused = agent_panel
 353                        .read(cx)
 354                        .active_connection_view()
 355                        .and_then(|thread| thread.read(cx).parent_id(cx));
 356                    if new_focused.is_some() && new_focused != this.focused_thread {
 357                        this.focused_thread = new_focused;
 358                        this.update_entries(cx);
 359                    }
 360                }
 361                AgentPanelEvent::BackgroundThreadChanged => {
 362                    this.update_entries(cx);
 363                }
 364            },
 365        )
 366        .detach();
 367    }
 368
 369    fn all_thread_infos_for_workspace(
 370        workspace: &Entity<Workspace>,
 371        cx: &App,
 372    ) -> Vec<ActiveThreadInfo> {
 373        let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
 374            return Vec::new();
 375        };
 376        let agent_panel_ref = agent_panel.read(cx);
 377
 378        agent_panel_ref
 379            .parent_threads(cx)
 380            .into_iter()
 381            .map(|thread_view| {
 382                let thread_view_ref = thread_view.read(cx);
 383                let thread = thread_view_ref.thread.read(cx);
 384
 385                let icon = thread_view_ref.agent_icon;
 386                let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
 387                let title = thread.title();
 388                let session_id = thread.session_id().clone();
 389                let is_background = agent_panel_ref.is_background_thread(&session_id);
 390
 391                let status = if thread.is_waiting_for_confirmation() {
 392                    AgentThreadStatus::WaitingForConfirmation
 393                } else if thread.had_error() {
 394                    AgentThreadStatus::Error
 395                } else {
 396                    match thread.status() {
 397                        ThreadStatus::Generating => AgentThreadStatus::Running,
 398                        ThreadStatus::Idle => AgentThreadStatus::Completed,
 399                    }
 400                };
 401
 402                ActiveThreadInfo {
 403                    session_id,
 404                    title,
 405                    status,
 406                    icon,
 407                    icon_from_external_svg,
 408                    is_background,
 409                }
 410            })
 411            .collect()
 412    }
 413
 414    fn rebuild_contents(&mut self, cx: &App) {
 415        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 416            return;
 417        };
 418        let mw = multi_workspace.read(cx);
 419        let workspaces = mw.workspaces().to_vec();
 420        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 421
 422        let thread_store = ThreadStore::try_global(cx);
 423        let query = self.filter_editor.read(cx).text(cx);
 424
 425        let previous = mem::take(&mut self.contents);
 426
 427        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
 428            .entries
 429            .iter()
 430            .filter_map(|entry| match entry {
 431                ListEntry::Thread(thread) if thread.is_live => {
 432                    Some((thread.session_info.session_id.clone(), thread.status))
 433                }
 434                _ => None,
 435            })
 436            .collect();
 437
 438        let mut entries = Vec::new();
 439        let mut notified_threads = previous.notified_threads;
 440        // Track all session IDs we add to entries so we can prune stale
 441        // notifications without a separate pass at the end.
 442        let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
 443        // Compute active_entry_index inline during the build pass.
 444        let mut active_entry_index: Option<usize> = None;
 445
 446        for workspace in workspaces.iter() {
 447            let (path_list, label) = workspace_path_list_and_label(workspace, cx);
 448
 449            let is_collapsed = self.collapsed_groups.contains(&path_list);
 450            let should_load_threads = !is_collapsed || !query.is_empty();
 451
 452            let mut threads: Vec<ThreadEntry> = Vec::new();
 453
 454            if should_load_threads {
 455                if let Some(ref thread_store) = thread_store {
 456                    for meta in thread_store.read(cx).threads_for_paths(&path_list) {
 457                        threads.push(ThreadEntry {
 458                            session_info: meta.into(),
 459                            icon: IconName::ZedAgent,
 460                            icon_from_external_svg: None,
 461                            status: AgentThreadStatus::default(),
 462                            workspace: workspace.clone(),
 463                            is_live: false,
 464                            is_background: false,
 465                            highlight_positions: Vec::new(),
 466                        });
 467                    }
 468                }
 469
 470                let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
 471
 472                if !live_infos.is_empty() {
 473                    let thread_index_by_session: HashMap<acp::SessionId, usize> = threads
 474                        .iter()
 475                        .enumerate()
 476                        .map(|(i, t)| (t.session_info.session_id.clone(), i))
 477                        .collect();
 478
 479                    for info in &live_infos {
 480                        let Some(&idx) = thread_index_by_session.get(&info.session_id) else {
 481                            continue;
 482                        };
 483
 484                        let thread = &mut threads[idx];
 485                        thread.session_info.title = Some(info.title.clone());
 486                        thread.status = info.status;
 487                        thread.icon = info.icon;
 488                        thread.icon_from_external_svg = info.icon_from_external_svg.clone();
 489                        thread.is_live = true;
 490                        thread.is_background = info.is_background;
 491                    }
 492                }
 493
 494                // Update notification state for live threads in the same pass.
 495                let is_active_workspace = active_workspace
 496                    .as_ref()
 497                    .is_some_and(|active| active == workspace);
 498
 499                for thread in &threads {
 500                    let session_id = &thread.session_info.session_id;
 501                    if thread.is_background && thread.status == AgentThreadStatus::Completed {
 502                        notified_threads.insert(session_id.clone());
 503                    } else if thread.status == AgentThreadStatus::Completed
 504                        && !is_active_workspace
 505                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 506                    {
 507                        notified_threads.insert(session_id.clone());
 508                    }
 509
 510                    if is_active_workspace && !thread.is_background {
 511                        notified_threads.remove(session_id);
 512                    }
 513                }
 514
 515                threads.sort_by(|a, b| b.session_info.updated_at.cmp(&a.session_info.updated_at));
 516            }
 517
 518            if !query.is_empty() {
 519                let has_threads = !threads.is_empty();
 520
 521                let workspace_highlight_positions =
 522                    fuzzy_match_positions(&query, &label).unwrap_or_default();
 523                let workspace_matched = !workspace_highlight_positions.is_empty();
 524
 525                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
 526                for mut thread in threads {
 527                    let title = thread
 528                        .session_info
 529                        .title
 530                        .as_ref()
 531                        .map(|s| s.as_ref())
 532                        .unwrap_or("");
 533                    if let Some(positions) = fuzzy_match_positions(&query, title) {
 534                        thread.highlight_positions = positions;
 535                    }
 536                    if workspace_matched || !thread.highlight_positions.is_empty() {
 537                        matched_threads.push(thread);
 538                    }
 539                }
 540
 541                if matched_threads.is_empty() && !workspace_matched {
 542                    continue;
 543                }
 544
 545                if active_entry_index.is_none()
 546                    && self.focused_thread.is_none()
 547                    && active_workspace
 548                        .as_ref()
 549                        .is_some_and(|active| active == workspace)
 550                {
 551                    active_entry_index = Some(entries.len());
 552                }
 553
 554                entries.push(ListEntry::ProjectHeader {
 555                    path_list: path_list.clone(),
 556                    label,
 557                    workspace: workspace.clone(),
 558                    highlight_positions: workspace_highlight_positions,
 559                    has_threads,
 560                });
 561
 562                // Track session IDs and compute active_entry_index as we add
 563                // thread entries.
 564                for thread in matched_threads {
 565                    current_session_ids.insert(thread.session_info.session_id.clone());
 566                    if active_entry_index.is_none() {
 567                        if let Some(focused) = &self.focused_thread {
 568                            if &thread.session_info.session_id == focused {
 569                                active_entry_index = Some(entries.len());
 570                            }
 571                        }
 572                    }
 573                    entries.push(thread.into());
 574                }
 575            } else {
 576                let has_threads = !threads.is_empty();
 577
 578                // Check if this header is the active entry before pushing it.
 579                if active_entry_index.is_none()
 580                    && self.focused_thread.is_none()
 581                    && active_workspace
 582                        .as_ref()
 583                        .is_some_and(|active| active == workspace)
 584                {
 585                    active_entry_index = Some(entries.len());
 586                }
 587
 588                entries.push(ListEntry::ProjectHeader {
 589                    path_list: path_list.clone(),
 590                    label,
 591                    workspace: workspace.clone(),
 592                    highlight_positions: Vec::new(),
 593                    has_threads,
 594                });
 595
 596                if is_collapsed {
 597                    continue;
 598                }
 599
 600                let total = threads.len();
 601
 602                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
 603                let threads_to_show =
 604                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
 605                let count = threads_to_show.min(total);
 606                let is_fully_expanded = count >= total;
 607
 608                // Track session IDs and compute active_entry_index as we add
 609                // thread entries.
 610                for thread in threads.into_iter().take(count) {
 611                    current_session_ids.insert(thread.session_info.session_id.clone());
 612                    if active_entry_index.is_none() {
 613                        if let Some(focused) = &self.focused_thread {
 614                            if &thread.session_info.session_id == focused {
 615                                active_entry_index = Some(entries.len());
 616                            }
 617                        }
 618                    }
 619                    entries.push(thread.into());
 620                }
 621
 622                if total > DEFAULT_THREADS_SHOWN {
 623                    entries.push(ListEntry::ViewMore {
 624                        path_list: path_list.clone(),
 625                        remaining_count: total.saturating_sub(count),
 626                        is_fully_expanded,
 627                    });
 628                }
 629
 630                if total == 0 {
 631                    entries.push(ListEntry::NewThread {
 632                        path_list: path_list.clone(),
 633                        workspace: workspace.clone(),
 634                    });
 635                }
 636            }
 637        }
 638
 639        // Prune stale notifications using the session IDs we collected during
 640        // the build pass (no extra scan needed).
 641        notified_threads.retain(|id| current_session_ids.contains(id));
 642
 643        self.active_entry_index = active_entry_index;
 644        self.contents = SidebarContents {
 645            entries,
 646            notified_threads,
 647        };
 648    }
 649
 650    fn update_entries(&mut self, cx: &mut Context<Self>) {
 651        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 652            return;
 653        };
 654        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
 655            return;
 656        }
 657
 658        let had_notifications = self.has_notifications(cx);
 659
 660        let scroll_position = self.list_state.logical_scroll_top();
 661
 662        self.rebuild_contents(cx);
 663
 664        self.list_state.reset(self.contents.entries.len());
 665        self.list_state.scroll_to(scroll_position);
 666
 667        if had_notifications != self.has_notifications(cx) {
 668            multi_workspace.update(cx, |_, cx| {
 669                cx.notify();
 670            });
 671        }
 672
 673        cx.notify();
 674    }
 675
 676    fn render_list_entry(
 677        &mut self,
 678        ix: usize,
 679        window: &mut Window,
 680        cx: &mut Context<Self>,
 681    ) -> AnyElement {
 682        let Some(entry) = self.contents.entries.get(ix) else {
 683            return div().into_any_element();
 684        };
 685        let is_focused = self.focus_handle.is_focused(window)
 686            || self.filter_editor.focus_handle(cx).is_focused(window);
 687        // is_selected means the keyboard selector is here.
 688        let is_selected = is_focused && self.selection == Some(ix);
 689
 690        let is_group_header_after_first =
 691            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
 692
 693        let rendered = match entry {
 694            ListEntry::ProjectHeader {
 695                path_list,
 696                label,
 697                workspace,
 698                highlight_positions,
 699                has_threads,
 700            } => self.render_project_header(
 701                ix,
 702                path_list,
 703                label,
 704                workspace,
 705                highlight_positions,
 706                *has_threads,
 707                is_selected,
 708                cx,
 709            ),
 710            ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
 711            ListEntry::ViewMore {
 712                path_list,
 713                remaining_count,
 714                is_fully_expanded,
 715            } => self.render_view_more(
 716                ix,
 717                path_list,
 718                *remaining_count,
 719                *is_fully_expanded,
 720                is_selected,
 721                cx,
 722            ),
 723            ListEntry::NewThread {
 724                path_list,
 725                workspace,
 726            } => self.render_new_thread(ix, path_list, workspace, is_selected, cx),
 727        };
 728
 729        // add the blue border here, not in the sub methods
 730
 731        if is_group_header_after_first {
 732            v_flex()
 733                .w_full()
 734                .pt_2()
 735                .border_t_1()
 736                .border_color(cx.theme().colors().border_variant)
 737                .child(rendered)
 738                .into_any_element()
 739        } else {
 740            rendered
 741        }
 742    }
 743
 744    fn render_project_header(
 745        &self,
 746        ix: usize,
 747        path_list: &PathList,
 748        label: &SharedString,
 749        workspace: &Entity<Workspace>,
 750        highlight_positions: &[usize],
 751        has_threads: bool,
 752        is_selected: bool,
 753        cx: &mut Context<Self>,
 754    ) -> AnyElement {
 755        let id = SharedString::from(format!("project-header-{}", ix));
 756        let group_name = SharedString::from(format!("header-group-{}", ix));
 757        let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix));
 758
 759        let is_collapsed = self.collapsed_groups.contains(path_list);
 760        let disclosure_icon = if is_collapsed {
 761            IconName::ChevronRight
 762        } else {
 763            IconName::ChevronDown
 764        };
 765        let workspace_for_new_thread = workspace.clone();
 766        let workspace_for_remove = workspace.clone();
 767        // let workspace_for_activate = workspace.clone();
 768
 769        let path_list_for_toggle = path_list.clone();
 770        let path_list_for_collapse = path_list.clone();
 771        let view_more_expanded = self.expanded_groups.contains_key(path_list);
 772
 773        let multi_workspace = self.multi_workspace.upgrade();
 774        let workspace_count = multi_workspace
 775            .as_ref()
 776            .map_or(0, |mw| mw.read(cx).workspaces().len());
 777        let is_active_workspace = self.focused_thread.is_none()
 778            && multi_workspace
 779                .as_ref()
 780                .is_some_and(|mw| mw.read(cx).workspace() == workspace);
 781
 782        let label = if highlight_positions.is_empty() {
 783            Label::new(label.clone())
 784                .size(LabelSize::Small)
 785                .color(Color::Muted)
 786                .into_any_element()
 787        } else {
 788            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
 789                .size(LabelSize::Small)
 790                .color(Color::Muted)
 791                .into_any_element()
 792        };
 793
 794        let color = cx.theme().colors();
 795        let gradient_overlay = GradientFade::new(
 796            color.panel_background,
 797            color.element_hover,
 798            color.element_active,
 799        )
 800        .width(px(48.0))
 801        .group_name(group_name.clone());
 802
 803        ListItem::new(id)
 804            .group_name(group_name)
 805            .toggle_state(is_active_workspace)
 806            .focused(is_selected)
 807            .child(
 808                h_flex()
 809                    .relative()
 810                    .min_w_0()
 811                    .w_full()
 812                    .p_1()
 813                    .gap_1p5()
 814                    .child(
 815                        Icon::new(disclosure_icon)
 816                            .size(IconSize::Small)
 817                            .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
 818                    )
 819                    .child(label)
 820                    .child(gradient_overlay),
 821            )
 822            .end_hover_slot(
 823                h_flex()
 824                    .when(workspace_count > 1, |this| {
 825                        this.child(
 826                            IconButton::new(
 827                                SharedString::from(format!("project-header-remove-{}", ix)),
 828                                IconName::Close,
 829                            )
 830                            .icon_size(IconSize::Small)
 831                            .icon_color(Color::Muted)
 832                            .tooltip(Tooltip::text("Remove Project"))
 833                            .on_click(cx.listener(
 834                                move |this, _, window, cx| {
 835                                    this.remove_workspace(&workspace_for_remove, window, cx);
 836                                },
 837                            )),
 838                        )
 839                    })
 840                    .when(view_more_expanded && !is_collapsed, |this| {
 841                        this.child(
 842                            IconButton::new(
 843                                SharedString::from(format!("project-header-collapse-{}", ix)),
 844                                IconName::ListCollapse,
 845                            )
 846                            .icon_size(IconSize::Small)
 847                            .icon_color(Color::Muted)
 848                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
 849                            .on_click(cx.listener({
 850                                let path_list_for_collapse = path_list_for_collapse.clone();
 851                                move |this, _, _window, cx| {
 852                                    this.selection = None;
 853                                    this.expanded_groups.remove(&path_list_for_collapse);
 854                                    this.update_entries(cx);
 855                                }
 856                            })),
 857                        )
 858                    })
 859                    .when(has_threads, |this| {
 860                        this.child(
 861                            IconButton::new(ib_id, IconName::NewThread)
 862                                .icon_size(IconSize::Small)
 863                                .icon_color(Color::Muted)
 864                                .tooltip(Tooltip::text("New Thread"))
 865                                .on_click(cx.listener(move |this, _, window, cx| {
 866                                    this.selection = None;
 867                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
 868                                })),
 869                        )
 870                    }),
 871            )
 872            .on_click(cx.listener(move |this, _, window, cx| {
 873                this.selection = None;
 874                this.toggle_collapse(&path_list_for_toggle, window, cx);
 875            }))
 876            // TODO: Decide if we really want the header to be activating different workspaces
 877            // .on_click(cx.listener(move |this, _, window, cx| {
 878            //     this.selection = None;
 879            //     this.activate_workspace(&workspace_for_activate, window, cx);
 880            // }))
 881            .into_any_element()
 882    }
 883
 884    fn activate_workspace(
 885        &mut self,
 886        workspace: &Entity<Workspace>,
 887        window: &mut Window,
 888        cx: &mut Context<Self>,
 889    ) {
 890        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 891            return;
 892        };
 893
 894        self.focused_thread = None;
 895
 896        multi_workspace.update(cx, |multi_workspace, cx| {
 897            multi_workspace.activate(workspace.clone(), cx);
 898        });
 899
 900        multi_workspace.update(cx, |multi_workspace, cx| {
 901            multi_workspace.focus_active_workspace(window, cx);
 902        });
 903    }
 904
 905    fn remove_workspace(
 906        &mut self,
 907        workspace: &Entity<Workspace>,
 908        window: &mut Window,
 909        cx: &mut Context<Self>,
 910    ) {
 911        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 912            return;
 913        };
 914
 915        multi_workspace.update(cx, |multi_workspace, cx| {
 916            let Some(index) = multi_workspace
 917                .workspaces()
 918                .iter()
 919                .position(|w| w == workspace)
 920            else {
 921                return;
 922            };
 923            multi_workspace.remove_workspace(index, window, cx);
 924        });
 925    }
 926
 927    fn toggle_collapse(
 928        &mut self,
 929        path_list: &PathList,
 930        _window: &mut Window,
 931        cx: &mut Context<Self>,
 932    ) {
 933        if self.collapsed_groups.contains(path_list) {
 934            self.collapsed_groups.remove(path_list);
 935        } else {
 936            self.collapsed_groups.insert(path_list.clone());
 937        }
 938        self.update_entries(cx);
 939    }
 940
 941    fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
 942
 943    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
 944        if self.reset_filter_editor_text(window, cx) {
 945            self.update_entries(cx);
 946        } else {
 947            self.focus_handle.focus(window, cx);
 948        }
 949    }
 950
 951    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
 952        self.filter_editor.update(cx, |editor, cx| {
 953            if editor.buffer().read(cx).len(cx).0 > 0 {
 954                editor.set_text("", window, cx);
 955                true
 956            } else {
 957                false
 958            }
 959        })
 960    }
 961
 962    fn has_filter_query(&self, cx: &App) -> bool {
 963        self.filter_editor.read(cx).buffer().read(cx).is_empty()
 964    }
 965
 966    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
 967        self.select_next(&SelectNext, window, cx);
 968    }
 969
 970    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
 971        self.select_previous(&SelectPrevious, window, cx);
 972    }
 973
 974    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 975        let next = match self.selection {
 976            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
 977            None if !self.contents.entries.is_empty() => 0,
 978            _ => return,
 979        };
 980        self.selection = Some(next);
 981        self.list_state.scroll_to_reveal_item(next);
 982        cx.notify();
 983    }
 984
 985    fn select_previous(
 986        &mut self,
 987        _: &SelectPrevious,
 988        _window: &mut Window,
 989        cx: &mut Context<Self>,
 990    ) {
 991        let prev = match self.selection {
 992            Some(ix) if ix > 0 => ix - 1,
 993            None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1,
 994            _ => return,
 995        };
 996        self.selection = Some(prev);
 997        self.list_state.scroll_to_reveal_item(prev);
 998        cx.notify();
 999    }
1000
1001    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1002        if !self.contents.entries.is_empty() {
1003            self.selection = Some(0);
1004            self.list_state.scroll_to_reveal_item(0);
1005            cx.notify();
1006        }
1007    }
1008
1009    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1010        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1011            self.selection = Some(last);
1012            self.list_state.scroll_to_reveal_item(last);
1013            cx.notify();
1014        }
1015    }
1016
1017    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1018        let Some(ix) = self.selection else { return };
1019        let Some(entry) = self.contents.entries.get(ix) else {
1020            return;
1021        };
1022
1023        match entry {
1024            ListEntry::ProjectHeader { workspace, .. } => {
1025                let workspace = workspace.clone();
1026                self.activate_workspace(&workspace, window, cx);
1027            }
1028            ListEntry::Thread(thread) => {
1029                let session_info = thread.session_info.clone();
1030                let workspace = thread.workspace.clone();
1031                self.activate_thread(session_info, &workspace, window, cx);
1032            }
1033            ListEntry::ViewMore {
1034                path_list,
1035                is_fully_expanded,
1036                ..
1037            } => {
1038                let path_list = path_list.clone();
1039                if *is_fully_expanded {
1040                    self.expanded_groups.remove(&path_list);
1041                } else {
1042                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1043                    self.expanded_groups.insert(path_list, current + 1);
1044                }
1045                self.update_entries(cx);
1046            }
1047            ListEntry::NewThread { workspace, .. } => {
1048                let workspace = workspace.clone();
1049                self.create_new_thread(&workspace, window, cx);
1050            }
1051        }
1052    }
1053
1054    fn activate_thread(
1055        &mut self,
1056        session_info: acp_thread::AgentSessionInfo,
1057        workspace: &Entity<Workspace>,
1058        window: &mut Window,
1059        cx: &mut Context<Self>,
1060    ) {
1061        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1062            return;
1063        };
1064
1065        multi_workspace.update(cx, |multi_workspace, cx| {
1066            multi_workspace.activate(workspace.clone(), cx);
1067        });
1068
1069        workspace.update(cx, |workspace, cx| {
1070            workspace.open_panel::<AgentPanel>(window, cx);
1071        });
1072
1073        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1074            agent_panel.update(cx, |panel, cx| {
1075                panel.load_agent_thread(
1076                    session_info.session_id,
1077                    session_info.cwd,
1078                    session_info.title,
1079                    window,
1080                    cx,
1081                );
1082            });
1083        }
1084    }
1085
1086    fn expand_selected_entry(
1087        &mut self,
1088        _: &ExpandSelectedEntry,
1089        _window: &mut Window,
1090        cx: &mut Context<Self>,
1091    ) {
1092        let Some(ix) = self.selection else { return };
1093
1094        match self.contents.entries.get(ix) {
1095            Some(ListEntry::ProjectHeader { path_list, .. }) => {
1096                if self.collapsed_groups.contains(path_list) {
1097                    let path_list = path_list.clone();
1098                    self.collapsed_groups.remove(&path_list);
1099                    self.update_entries(cx);
1100                } else if ix + 1 < self.contents.entries.len() {
1101                    self.selection = Some(ix + 1);
1102                    self.list_state.scroll_to_reveal_item(ix + 1);
1103                    cx.notify();
1104                }
1105            }
1106            _ => {}
1107        }
1108    }
1109
1110    fn collapse_selected_entry(
1111        &mut self,
1112        _: &CollapseSelectedEntry,
1113        _window: &mut Window,
1114        cx: &mut Context<Self>,
1115    ) {
1116        let Some(ix) = self.selection else { return };
1117
1118        match self.contents.entries.get(ix) {
1119            Some(ListEntry::ProjectHeader { path_list, .. }) => {
1120                if !self.collapsed_groups.contains(path_list) {
1121                    let path_list = path_list.clone();
1122                    self.collapsed_groups.insert(path_list);
1123                    self.update_entries(cx);
1124                }
1125            }
1126            Some(
1127                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
1128            ) => {
1129                for i in (0..ix).rev() {
1130                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
1131                        self.contents.entries.get(i)
1132                    {
1133                        let path_list = path_list.clone();
1134                        self.selection = Some(i);
1135                        self.collapsed_groups.insert(path_list);
1136                        self.update_entries(cx);
1137                        break;
1138                    }
1139                }
1140            }
1141            None => {}
1142        }
1143    }
1144
1145    fn render_thread(
1146        &self,
1147        ix: usize,
1148        thread: &ThreadEntry,
1149        is_selected: bool,
1150        cx: &mut Context<Self>,
1151    ) -> AnyElement {
1152        let has_notification = self
1153            .contents
1154            .is_thread_notified(&thread.session_info.session_id);
1155
1156        let title: SharedString = thread
1157            .session_info
1158            .title
1159            .clone()
1160            .unwrap_or_else(|| "Untitled".into());
1161        let session_info = thread.session_info.clone();
1162        let workspace = thread.workspace.clone();
1163
1164        let id = SharedString::from(format!("thread-entry-{}", ix));
1165        ThreadItem::new(id, title)
1166            .icon(thread.icon)
1167            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
1168                this.custom_icon_from_external_svg(svg)
1169            })
1170            .highlight_positions(thread.highlight_positions.to_vec())
1171            .status(thread.status)
1172            .notified(has_notification)
1173            .selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
1174            .focused(is_selected)
1175            .on_click(cx.listener(move |this, _, window, cx| {
1176                this.selection = None;
1177                this.activate_thread(session_info.clone(), &workspace, window, cx);
1178            }))
1179            .into_any_element()
1180    }
1181
1182    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
1183        let workspace = self
1184            .multi_workspace
1185            .upgrade()
1186            .map(|mw| mw.read(cx).workspace().downgrade());
1187
1188        let focus_handle = workspace
1189            .as_ref()
1190            .and_then(|ws| ws.upgrade())
1191            .map(|w| w.read(cx).focus_handle(cx))
1192            .unwrap_or_else(|| cx.focus_handle());
1193
1194        let popover_handle = self.recent_projects_popover_handle.clone();
1195
1196        PopoverMenu::new("sidebar-recent-projects-menu")
1197            .with_handle(popover_handle)
1198            .menu(move |window, cx| {
1199                workspace.as_ref().map(|ws| {
1200                    RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx)
1201                })
1202            })
1203            .trigger_with_tooltip(
1204                IconButton::new("open-project", IconName::OpenFolder)
1205                    .icon_size(IconSize::Small)
1206                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
1207                |_window, cx| {
1208                    Tooltip::for_action(
1209                        "Recent Projects",
1210                        &OpenRecent {
1211                            create_new_window: false,
1212                        },
1213                        cx,
1214                    )
1215                },
1216            )
1217            .anchor(gpui::Corner::TopLeft)
1218            .offset(gpui::Point {
1219                x: px(0.0),
1220                y: px(2.0),
1221            })
1222    }
1223
1224    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
1225        let settings = ThemeSettings::get_global(cx);
1226        let text_style = TextStyle {
1227            color: cx.theme().colors().text,
1228            font_family: settings.ui_font.family.clone(),
1229            font_features: settings.ui_font.features.clone(),
1230            font_fallbacks: settings.ui_font.fallbacks.clone(),
1231            font_size: rems(0.875).into(),
1232            font_weight: settings.ui_font.weight,
1233            font_style: FontStyle::Normal,
1234            line_height: relative(1.3),
1235            ..Default::default()
1236        };
1237
1238        EditorElement::new(
1239            &self.filter_editor,
1240            EditorStyle {
1241                local_player: cx.theme().players().local(),
1242                text: text_style,
1243                ..Default::default()
1244            },
1245        )
1246    }
1247
1248    fn render_view_more(
1249        &self,
1250        ix: usize,
1251        path_list: &PathList,
1252        remaining_count: usize,
1253        is_fully_expanded: bool,
1254        is_selected: bool,
1255        cx: &mut Context<Self>,
1256    ) -> AnyElement {
1257        let path_list = path_list.clone();
1258        let id = SharedString::from(format!("view-more-{}", ix));
1259
1260        let (icon, label) = if is_fully_expanded {
1261            (IconName::ListCollapse, "Collapse List")
1262        } else {
1263            (IconName::Plus, "View More")
1264        };
1265
1266        ListItem::new(id)
1267            .focused(is_selected)
1268            .child(
1269                h_flex()
1270                    .p_1()
1271                    .gap_1p5()
1272                    .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
1273                    .child(Label::new(label).color(Color::Muted))
1274                    .when(!is_fully_expanded, |this| {
1275                        this.child(
1276                            Label::new(format!("({})", remaining_count))
1277                                .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
1278                        )
1279                    }),
1280            )
1281            .on_click(cx.listener(move |this, _, _window, cx| {
1282                this.selection = None;
1283                if is_fully_expanded {
1284                    this.expanded_groups.remove(&path_list);
1285                } else {
1286                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
1287                    this.expanded_groups.insert(path_list.clone(), current + 1);
1288                }
1289                this.update_entries(cx);
1290            }))
1291            .into_any_element()
1292    }
1293
1294    fn create_new_thread(
1295        &mut self,
1296        workspace: &Entity<Workspace>,
1297        window: &mut Window,
1298        cx: &mut Context<Self>,
1299    ) {
1300        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1301            return;
1302        };
1303
1304        multi_workspace.update(cx, |multi_workspace, cx| {
1305            multi_workspace.activate(workspace.clone(), cx);
1306        });
1307
1308        workspace.update(cx, |workspace, cx| {
1309            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
1310                agent_panel.update(cx, |panel, cx| {
1311                    panel.new_thread(&NewThread, window, cx);
1312                });
1313            }
1314            workspace.focus_panel::<AgentPanel>(window, cx);
1315        });
1316    }
1317
1318    fn render_new_thread(
1319        &self,
1320        ix: usize,
1321        _path_list: &PathList,
1322        workspace: &Entity<Workspace>,
1323        is_selected: bool,
1324        cx: &mut Context<Self>,
1325    ) -> AnyElement {
1326        let workspace = workspace.clone();
1327
1328        div()
1329            .w_full()
1330            .p_2()
1331            .child(
1332                Button::new(
1333                    SharedString::from(format!("new-thread-btn-{}", ix)),
1334                    "New Thread",
1335                )
1336                .full_width()
1337                .style(ButtonStyle::Outlined)
1338                .icon(IconName::Plus)
1339                .icon_color(Color::Muted)
1340                .icon_size(IconSize::Small)
1341                .icon_position(IconPosition::Start)
1342                .toggle_state(is_selected)
1343                .on_click(cx.listener(move |this, _, window, cx| {
1344                    this.selection = None;
1345                    this.create_new_thread(&workspace, window, cx);
1346                })),
1347            )
1348            .into_any_element()
1349    }
1350}
1351
1352impl WorkspaceSidebar for Sidebar {
1353    fn width(&self, _cx: &App) -> Pixels {
1354        self.width
1355    }
1356
1357    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
1358        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
1359        cx.notify();
1360    }
1361
1362    fn has_notifications(&self, _cx: &App) -> bool {
1363        !self.contents.notified_threads.is_empty()
1364    }
1365
1366    fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
1367        self.recent_projects_popover_handle.toggle(window, cx);
1368    }
1369
1370    fn is_recent_projects_popover_deployed(&self) -> bool {
1371        self.recent_projects_popover_handle.is_deployed()
1372    }
1373}
1374
1375impl Focusable for Sidebar {
1376    fn focus_handle(&self, cx: &App) -> FocusHandle {
1377        self.filter_editor.focus_handle(cx)
1378    }
1379}
1380
1381impl Render for Sidebar {
1382    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1383        let titlebar_height = ui::utils::platform_title_bar_height(window);
1384        let ui_font = theme::setup_ui_font(window, cx);
1385        let is_focused = self.focus_handle.is_focused(window)
1386            || self.filter_editor.focus_handle(cx).is_focused(window);
1387        let has_query = self.has_filter_query(cx);
1388
1389        let focus_tooltip_label = if is_focused {
1390            "Focus Workspace"
1391        } else {
1392            "Focus Sidebar"
1393        };
1394
1395        v_flex()
1396            .id("workspace-sidebar")
1397            .key_context("WorkspaceSidebar")
1398            .track_focus(&self.focus_handle)
1399            .on_action(cx.listener(Self::select_next))
1400            .on_action(cx.listener(Self::select_previous))
1401            .on_action(cx.listener(Self::editor_move_down))
1402            .on_action(cx.listener(Self::editor_move_up))
1403            .on_action(cx.listener(Self::select_first))
1404            .on_action(cx.listener(Self::select_last))
1405            .on_action(cx.listener(Self::confirm))
1406            .on_action(cx.listener(Self::expand_selected_entry))
1407            .on_action(cx.listener(Self::collapse_selected_entry))
1408            .on_action(cx.listener(Self::cancel))
1409            .font(ui_font)
1410            .h_full()
1411            .w(self.width)
1412            .bg(cx.theme().colors().surface_background)
1413            .border_r_1()
1414            .border_color(cx.theme().colors().border)
1415            .child(
1416                h_flex()
1417                    .flex_none()
1418                    .h(titlebar_height)
1419                    .w_full()
1420                    .mt_px()
1421                    .pb_px()
1422                    .pr_1()
1423                    .when_else(
1424                        cfg!(target_os = "macos") && !window.is_fullscreen(),
1425                        |this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
1426                        |this| this.pl_2(),
1427                    )
1428                    .justify_between()
1429                    .border_b_1()
1430                    .border_color(cx.theme().colors().border)
1431                    .child({
1432                        let focus_handle_toggle = self.focus_handle.clone();
1433                        let focus_handle_focus = self.focus_handle.clone();
1434                        IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
1435                            .icon_size(IconSize::Small)
1436                            .tooltip(Tooltip::element(move |_, cx| {
1437                                v_flex()
1438                                    .gap_1()
1439                                    .child(
1440                                        h_flex()
1441                                            .gap_2()
1442                                            .justify_between()
1443                                            .child(Label::new("Close Sidebar"))
1444                                            .child(KeyBinding::for_action_in(
1445                                                &ToggleWorkspaceSidebar,
1446                                                &focus_handle_toggle,
1447                                                cx,
1448                                            )),
1449                                    )
1450                                    .child(
1451                                        h_flex()
1452                                            .pt_1()
1453                                            .gap_2()
1454                                            .border_t_1()
1455                                            .border_color(cx.theme().colors().border_variant)
1456                                            .justify_between()
1457                                            .child(Label::new(focus_tooltip_label))
1458                                            .child(KeyBinding::for_action_in(
1459                                                &FocusWorkspaceSidebar,
1460                                                &focus_handle_focus,
1461                                                cx,
1462                                            )),
1463                                    )
1464                                    .into_any_element()
1465                            }))
1466                            .on_click(cx.listener(|_this, _, _window, cx| {
1467                                cx.emit(SidebarEvent::Close);
1468                            }))
1469                    })
1470                    .child(self.render_recent_projects_button(cx)),
1471            )
1472            .child(
1473                h_flex()
1474                    .flex_none()
1475                    .p_2()
1476                    .h(Tab::container_height(cx))
1477                    .gap_1p5()
1478                    .border_b_1()
1479                    .border_color(cx.theme().colors().border)
1480                    .child(
1481                        Icon::new(IconName::MagnifyingGlass)
1482                            .size(IconSize::Small)
1483                            .color(Color::Muted),
1484                    )
1485                    .child(self.render_filter_input(cx))
1486                    .when(has_query, |this| {
1487                        this.pr_1().child(
1488                            IconButton::new("clear_filter", IconName::Close)
1489                                .shape(IconButtonShape::Square)
1490                                .tooltip(Tooltip::text("Clear Search"))
1491                                .on_click(cx.listener(|this, _, window, cx| {
1492                                    this.reset_filter_editor_text(window, cx);
1493                                    this.update_entries(cx);
1494                                })),
1495                        )
1496                    }),
1497            )
1498            .child(
1499                v_flex()
1500                    .flex_1()
1501                    .overflow_hidden()
1502                    .child(
1503                        list(
1504                            self.list_state.clone(),
1505                            cx.processor(Self::render_list_entry),
1506                        )
1507                        .flex_1()
1508                        .size_full(),
1509                    )
1510                    .vertical_scrollbar_for(&self.list_state, window, cx),
1511            )
1512    }
1513}
1514
1515#[cfg(test)]
1516mod tests {
1517    use super::*;
1518    use acp_thread::StubAgentConnection;
1519    use agent::ThreadStore;
1520    use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
1521    use assistant_text_thread::TextThreadStore;
1522    use chrono::DateTime;
1523    use feature_flags::FeatureFlagAppExt as _;
1524    use fs::FakeFs;
1525    use gpui::TestAppContext;
1526    use settings::SettingsStore;
1527    use std::sync::Arc;
1528    use util::path_list::PathList;
1529
1530    fn init_test(cx: &mut TestAppContext) {
1531        cx.update(|cx| {
1532            let settings_store = SettingsStore::test(cx);
1533            cx.set_global(settings_store);
1534            theme::init(theme::LoadThemes::JustBase, cx);
1535            editor::init(cx);
1536            cx.update_flags(false, vec!["agent-v2".into()]);
1537            ThreadStore::init_global(cx);
1538        });
1539    }
1540
1541    fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
1542        agent::DbThread {
1543            title: title.to_string().into(),
1544            messages: Vec::new(),
1545            updated_at,
1546            detailed_summary: None,
1547            initial_project_snapshot: None,
1548            cumulative_token_usage: Default::default(),
1549            request_token_usage: Default::default(),
1550            model: None,
1551            profile: None,
1552            imported: false,
1553            subagent_context: None,
1554            speed: None,
1555            thinking_enabled: false,
1556            thinking_effort: None,
1557            draft_prompt: None,
1558            ui_scroll_position: None,
1559        }
1560    }
1561
1562    async fn init_test_project(
1563        worktree_path: &str,
1564        cx: &mut TestAppContext,
1565    ) -> Entity<project::Project> {
1566        init_test(cx);
1567        let fs = FakeFs::new(cx.executor());
1568        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1569            .await;
1570        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1571        project::Project::test(fs, [worktree_path.as_ref()], cx).await
1572    }
1573
1574    fn setup_sidebar(
1575        multi_workspace: &Entity<MultiWorkspace>,
1576        cx: &mut gpui::VisualTestContext,
1577    ) -> Entity<Sidebar> {
1578        let multi_workspace = multi_workspace.clone();
1579        let sidebar =
1580            cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
1581        multi_workspace.update_in(cx, |mw, window, cx| {
1582            mw.register_sidebar(sidebar.clone(), window, cx);
1583        });
1584        cx.run_until_parked();
1585        sidebar
1586    }
1587
1588    async fn save_n_test_threads(
1589        count: u32,
1590        path_list: &PathList,
1591        cx: &mut gpui::VisualTestContext,
1592    ) {
1593        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1594        for i in 0..count {
1595            let save_task = thread_store.update(cx, |store, cx| {
1596                store.save_thread(
1597                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1598                    make_test_thread(
1599                        &format!("Thread {}", i + 1),
1600                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1601                    ),
1602                    path_list.clone(),
1603                    cx,
1604                )
1605            });
1606            save_task.await.unwrap();
1607        }
1608        cx.run_until_parked();
1609    }
1610
1611    async fn save_thread_to_store(
1612        session_id: &acp::SessionId,
1613        path_list: &PathList,
1614        cx: &mut gpui::VisualTestContext,
1615    ) {
1616        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1617        let save_task = thread_store.update(cx, |store, cx| {
1618            store.save_thread(
1619                session_id.clone(),
1620                make_test_thread(
1621                    "Test",
1622                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1623                ),
1624                path_list.clone(),
1625                cx,
1626            )
1627        });
1628        save_task.await.unwrap();
1629        cx.run_until_parked();
1630    }
1631
1632    fn open_and_focus_sidebar(
1633        sidebar: &Entity<Sidebar>,
1634        multi_workspace: &Entity<MultiWorkspace>,
1635        cx: &mut gpui::VisualTestContext,
1636    ) {
1637        multi_workspace.update_in(cx, |mw, window, cx| {
1638            mw.toggle_sidebar(window, cx);
1639        });
1640        cx.run_until_parked();
1641        sidebar.update_in(cx, |_, window, cx| {
1642            cx.focus_self(window);
1643        });
1644        cx.run_until_parked();
1645    }
1646
1647    fn visible_entries_as_strings(
1648        sidebar: &Entity<Sidebar>,
1649        cx: &mut gpui::VisualTestContext,
1650    ) -> Vec<String> {
1651        sidebar.read_with(cx, |sidebar, _cx| {
1652            sidebar
1653                .contents
1654                .entries
1655                .iter()
1656                .enumerate()
1657                .map(|(ix, entry)| {
1658                    let selected = if sidebar.selection == Some(ix) {
1659                        "  <== selected"
1660                    } else {
1661                        ""
1662                    };
1663                    match entry {
1664                        ListEntry::ProjectHeader {
1665                            label,
1666                            path_list,
1667                            highlight_positions: _,
1668                            ..
1669                        } => {
1670                            let icon = if sidebar.collapsed_groups.contains(path_list) {
1671                                ">"
1672                            } else {
1673                                "v"
1674                            };
1675                            format!("{} [{}]{}", icon, label, selected)
1676                        }
1677                        ListEntry::Thread(thread) => {
1678                            let title = thread
1679                                .session_info
1680                                .title
1681                                .as_ref()
1682                                .map(|s| s.as_ref())
1683                                .unwrap_or("Untitled");
1684                            let active = if thread.is_live { " *" } else { "" };
1685                            let status_str = match thread.status {
1686                                AgentThreadStatus::Running => " (running)",
1687                                AgentThreadStatus::Error => " (error)",
1688                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
1689                                _ => "",
1690                            };
1691                            let notified = if sidebar
1692                                .contents
1693                                .is_thread_notified(&thread.session_info.session_id)
1694                            {
1695                                " (!)"
1696                            } else {
1697                                ""
1698                            };
1699                            format!(
1700                                "  {}{}{}{}{}",
1701                                title, active, status_str, notified, selected
1702                            )
1703                        }
1704                        ListEntry::ViewMore {
1705                            remaining_count,
1706                            is_fully_expanded,
1707                            ..
1708                        } => {
1709                            if *is_fully_expanded {
1710                                format!("  - Collapse{}", selected)
1711                            } else {
1712                                format!("  + View More ({}){}", remaining_count, selected)
1713                            }
1714                        }
1715                        ListEntry::NewThread { .. } => {
1716                            format!("  [+ New Thread]{}", selected)
1717                        }
1718                    }
1719                })
1720                .collect()
1721        })
1722    }
1723
1724    #[gpui::test]
1725    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
1726        let project = init_test_project("/my-project", cx).await;
1727        let (multi_workspace, cx) =
1728            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1729        let sidebar = setup_sidebar(&multi_workspace, cx);
1730
1731        assert_eq!(
1732            visible_entries_as_strings(&sidebar, cx),
1733            vec!["v [my-project]", "  [+ New Thread]"]
1734        );
1735    }
1736
1737    #[gpui::test]
1738    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
1739        let project = init_test_project("/my-project", cx).await;
1740        let (multi_workspace, cx) =
1741            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1742        let sidebar = setup_sidebar(&multi_workspace, cx);
1743
1744        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1745        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1746
1747        let save_task = thread_store.update(cx, |store, cx| {
1748            store.save_thread(
1749                acp::SessionId::new(Arc::from("thread-1")),
1750                make_test_thread(
1751                    "Fix crash in project panel",
1752                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
1753                ),
1754                path_list.clone(),
1755                cx,
1756            )
1757        });
1758        save_task.await.unwrap();
1759
1760        let save_task = thread_store.update(cx, |store, cx| {
1761            store.save_thread(
1762                acp::SessionId::new(Arc::from("thread-2")),
1763                make_test_thread(
1764                    "Add inline diff view",
1765                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
1766                ),
1767                path_list.clone(),
1768                cx,
1769            )
1770        });
1771        save_task.await.unwrap();
1772        cx.run_until_parked();
1773
1774        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1775        cx.run_until_parked();
1776
1777        assert_eq!(
1778            visible_entries_as_strings(&sidebar, cx),
1779            vec![
1780                "v [my-project]",
1781                "  Fix crash in project panel",
1782                "  Add inline diff view",
1783            ]
1784        );
1785    }
1786
1787    #[gpui::test]
1788    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
1789        let project = init_test_project("/project-a", cx).await;
1790        let (multi_workspace, cx) =
1791            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1792        let sidebar = setup_sidebar(&multi_workspace, cx);
1793
1794        // Single workspace with a thread
1795        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
1796        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1797
1798        let save_task = thread_store.update(cx, |store, cx| {
1799            store.save_thread(
1800                acp::SessionId::new(Arc::from("thread-a1")),
1801                make_test_thread(
1802                    "Thread A1",
1803                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1804                ),
1805                path_list.clone(),
1806                cx,
1807            )
1808        });
1809        save_task.await.unwrap();
1810        cx.run_until_parked();
1811
1812        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1813        cx.run_until_parked();
1814
1815        assert_eq!(
1816            visible_entries_as_strings(&sidebar, cx),
1817            vec!["v [project-a]", "  Thread A1"]
1818        );
1819
1820        // Add a second workspace
1821        multi_workspace.update_in(cx, |mw, window, cx| {
1822            mw.create_workspace(window, cx);
1823        });
1824        cx.run_until_parked();
1825
1826        assert_eq!(
1827            visible_entries_as_strings(&sidebar, cx),
1828            vec![
1829                "v [project-a]",
1830                "  Thread A1",
1831                "v [Empty Workspace]",
1832                "  [+ New Thread]"
1833            ]
1834        );
1835
1836        // Remove the second workspace
1837        multi_workspace.update_in(cx, |mw, window, cx| {
1838            mw.remove_workspace(1, window, cx);
1839        });
1840        cx.run_until_parked();
1841
1842        assert_eq!(
1843            visible_entries_as_strings(&sidebar, cx),
1844            vec!["v [project-a]", "  Thread A1"]
1845        );
1846    }
1847
1848    #[gpui::test]
1849    async fn test_view_more_pagination(cx: &mut TestAppContext) {
1850        let project = init_test_project("/my-project", cx).await;
1851        let (multi_workspace, cx) =
1852            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1853        let sidebar = setup_sidebar(&multi_workspace, cx);
1854
1855        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1856        save_n_test_threads(12, &path_list, cx).await;
1857
1858        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1859        cx.run_until_parked();
1860
1861        assert_eq!(
1862            visible_entries_as_strings(&sidebar, cx),
1863            vec![
1864                "v [my-project]",
1865                "  Thread 12",
1866                "  Thread 11",
1867                "  Thread 10",
1868                "  Thread 9",
1869                "  Thread 8",
1870                "  + View More (7)",
1871            ]
1872        );
1873    }
1874
1875    #[gpui::test]
1876    async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
1877        let project = init_test_project("/my-project", cx).await;
1878        let (multi_workspace, cx) =
1879            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1880        let sidebar = setup_sidebar(&multi_workspace, cx);
1881
1882        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1883        // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
1884        save_n_test_threads(17, &path_list, cx).await;
1885
1886        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1887        cx.run_until_parked();
1888
1889        // Initially shows 5 threads + View More (12 remaining)
1890        let entries = visible_entries_as_strings(&sidebar, cx);
1891        assert_eq!(entries.len(), 7); // header + 5 threads + View More
1892        assert!(entries.iter().any(|e| e.contains("View More (12)")));
1893
1894        // Focus and navigate to View More, then confirm to expand by one batch
1895        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
1896        for _ in 0..7 {
1897            cx.dispatch_action(SelectNext);
1898        }
1899        cx.dispatch_action(Confirm);
1900        cx.run_until_parked();
1901
1902        // Now shows 10 threads + View More (7 remaining)
1903        let entries = visible_entries_as_strings(&sidebar, cx);
1904        assert_eq!(entries.len(), 12); // header + 10 threads + View More
1905        assert!(entries.iter().any(|e| e.contains("View More (7)")));
1906
1907        // Expand again by one batch
1908        sidebar.update_in(cx, |s, _window, cx| {
1909            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
1910            s.expanded_groups.insert(path_list.clone(), current + 1);
1911            s.update_entries(cx);
1912        });
1913        cx.run_until_parked();
1914
1915        // Now shows 15 threads + View More (2 remaining)
1916        let entries = visible_entries_as_strings(&sidebar, cx);
1917        assert_eq!(entries.len(), 17); // header + 15 threads + View More
1918        assert!(entries.iter().any(|e| e.contains("View More (2)")));
1919
1920        // Expand one more time - should show all 17 threads with Collapse button
1921        sidebar.update_in(cx, |s, _window, cx| {
1922            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
1923            s.expanded_groups.insert(path_list.clone(), current + 1);
1924            s.update_entries(cx);
1925        });
1926        cx.run_until_parked();
1927
1928        // All 17 threads shown with Collapse button
1929        let entries = visible_entries_as_strings(&sidebar, cx);
1930        assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
1931        assert!(!entries.iter().any(|e| e.contains("View More")));
1932        assert!(entries.iter().any(|e| e.contains("Collapse")));
1933
1934        // Click collapse - should go back to showing 5 threads
1935        sidebar.update_in(cx, |s, _window, cx| {
1936            s.expanded_groups.remove(&path_list);
1937            s.update_entries(cx);
1938        });
1939        cx.run_until_parked();
1940
1941        // Back to initial state: 5 threads + View More (12 remaining)
1942        let entries = visible_entries_as_strings(&sidebar, cx);
1943        assert_eq!(entries.len(), 7); // header + 5 threads + View More
1944        assert!(entries.iter().any(|e| e.contains("View More (12)")));
1945    }
1946
1947    #[gpui::test]
1948    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
1949        let project = init_test_project("/my-project", cx).await;
1950        let (multi_workspace, cx) =
1951            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1952        let sidebar = setup_sidebar(&multi_workspace, cx);
1953
1954        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1955        save_n_test_threads(1, &path_list, cx).await;
1956
1957        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1958        cx.run_until_parked();
1959
1960        assert_eq!(
1961            visible_entries_as_strings(&sidebar, cx),
1962            vec!["v [my-project]", "  Thread 1"]
1963        );
1964
1965        // Collapse
1966        sidebar.update_in(cx, |s, window, cx| {
1967            s.toggle_collapse(&path_list, window, cx);
1968        });
1969        cx.run_until_parked();
1970
1971        assert_eq!(
1972            visible_entries_as_strings(&sidebar, cx),
1973            vec!["> [my-project]"]
1974        );
1975
1976        // Expand
1977        sidebar.update_in(cx, |s, window, cx| {
1978            s.toggle_collapse(&path_list, window, cx);
1979        });
1980        cx.run_until_parked();
1981
1982        assert_eq!(
1983            visible_entries_as_strings(&sidebar, cx),
1984            vec!["v [my-project]", "  Thread 1"]
1985        );
1986    }
1987
1988    #[gpui::test]
1989    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
1990        let project = init_test_project("/my-project", cx).await;
1991        let (multi_workspace, cx) =
1992            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1993        let sidebar = setup_sidebar(&multi_workspace, cx);
1994
1995        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1996        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
1997        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
1998
1999        sidebar.update_in(cx, |s, _window, _cx| {
2000            s.collapsed_groups.insert(collapsed_path.clone());
2001            s.contents
2002                .notified_threads
2003                .insert(acp::SessionId::new(Arc::from("t-5")));
2004            s.contents.entries = vec![
2005                // Expanded project header
2006                ListEntry::ProjectHeader {
2007                    path_list: expanded_path.clone(),
2008                    label: "expanded-project".into(),
2009                    workspace: workspace.clone(),
2010                    highlight_positions: Vec::new(),
2011                    has_threads: true,
2012                },
2013                // Thread with default (Completed) status, not active
2014                ListEntry::Thread(ThreadEntry {
2015                    session_info: acp_thread::AgentSessionInfo {
2016                        session_id: acp::SessionId::new(Arc::from("t-1")),
2017                        cwd: None,
2018                        title: Some("Completed thread".into()),
2019                        updated_at: Some(Utc::now()),
2020                        meta: None,
2021                    },
2022                    icon: IconName::ZedAgent,
2023                    icon_from_external_svg: None,
2024                    status: AgentThreadStatus::Completed,
2025                    workspace: workspace.clone(),
2026                    is_live: false,
2027                    is_background: false,
2028                    highlight_positions: Vec::new(),
2029                }),
2030                // Active thread with Running status
2031                ListEntry::Thread(ThreadEntry {
2032                    session_info: acp_thread::AgentSessionInfo {
2033                        session_id: acp::SessionId::new(Arc::from("t-2")),
2034                        cwd: None,
2035                        title: Some("Running thread".into()),
2036                        updated_at: Some(Utc::now()),
2037                        meta: None,
2038                    },
2039                    icon: IconName::ZedAgent,
2040                    icon_from_external_svg: None,
2041                    status: AgentThreadStatus::Running,
2042                    workspace: workspace.clone(),
2043                    is_live: true,
2044                    is_background: false,
2045                    highlight_positions: Vec::new(),
2046                }),
2047                // Active thread with Error status
2048                ListEntry::Thread(ThreadEntry {
2049                    session_info: acp_thread::AgentSessionInfo {
2050                        session_id: acp::SessionId::new(Arc::from("t-3")),
2051                        cwd: None,
2052                        title: Some("Error thread".into()),
2053                        updated_at: Some(Utc::now()),
2054                        meta: None,
2055                    },
2056                    icon: IconName::ZedAgent,
2057                    icon_from_external_svg: None,
2058                    status: AgentThreadStatus::Error,
2059                    workspace: workspace.clone(),
2060                    is_live: true,
2061                    is_background: false,
2062                    highlight_positions: Vec::new(),
2063                }),
2064                // Thread with WaitingForConfirmation status, not active
2065                ListEntry::Thread(ThreadEntry {
2066                    session_info: acp_thread::AgentSessionInfo {
2067                        session_id: acp::SessionId::new(Arc::from("t-4")),
2068                        cwd: None,
2069                        title: Some("Waiting thread".into()),
2070                        updated_at: Some(Utc::now()),
2071                        meta: None,
2072                    },
2073                    icon: IconName::ZedAgent,
2074                    icon_from_external_svg: None,
2075                    status: AgentThreadStatus::WaitingForConfirmation,
2076                    workspace: workspace.clone(),
2077                    is_live: false,
2078                    is_background: false,
2079                    highlight_positions: Vec::new(),
2080                }),
2081                // Background thread that completed (should show notification)
2082                ListEntry::Thread(ThreadEntry {
2083                    session_info: acp_thread::AgentSessionInfo {
2084                        session_id: acp::SessionId::new(Arc::from("t-5")),
2085                        cwd: None,
2086                        title: Some("Notified thread".into()),
2087                        updated_at: Some(Utc::now()),
2088                        meta: None,
2089                    },
2090                    icon: IconName::ZedAgent,
2091                    icon_from_external_svg: None,
2092                    status: AgentThreadStatus::Completed,
2093                    workspace: workspace.clone(),
2094                    is_live: true,
2095                    is_background: true,
2096                    highlight_positions: Vec::new(),
2097                }),
2098                // View More entry
2099                ListEntry::ViewMore {
2100                    path_list: expanded_path.clone(),
2101                    remaining_count: 42,
2102                    is_fully_expanded: false,
2103                },
2104                // Collapsed project header
2105                ListEntry::ProjectHeader {
2106                    path_list: collapsed_path.clone(),
2107                    label: "collapsed-project".into(),
2108                    workspace: workspace.clone(),
2109                    highlight_positions: Vec::new(),
2110                    has_threads: true,
2111                },
2112            ];
2113            // Select the Running thread (index 2)
2114            s.selection = Some(2);
2115        });
2116
2117        assert_eq!(
2118            visible_entries_as_strings(&sidebar, cx),
2119            vec![
2120                "v [expanded-project]",
2121                "  Completed thread",
2122                "  Running thread * (running)  <== selected",
2123                "  Error thread * (error)",
2124                "  Waiting thread (waiting)",
2125                "  Notified thread * (!)",
2126                "  + View More (42)",
2127                "> [collapsed-project]",
2128            ]
2129        );
2130
2131        // Move selection to the collapsed header
2132        sidebar.update_in(cx, |s, _window, _cx| {
2133            s.selection = Some(7);
2134        });
2135
2136        assert_eq!(
2137            visible_entries_as_strings(&sidebar, cx).last().cloned(),
2138            Some("> [collapsed-project]  <== selected".to_string()),
2139        );
2140
2141        // Clear selection
2142        sidebar.update_in(cx, |s, _window, _cx| {
2143            s.selection = None;
2144        });
2145
2146        // No entry should have the selected marker
2147        let entries = visible_entries_as_strings(&sidebar, cx);
2148        for entry in &entries {
2149            assert!(
2150                !entry.contains("<== selected"),
2151                "unexpected selection marker in: {}",
2152                entry
2153            );
2154        }
2155    }
2156
2157    #[gpui::test]
2158    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
2159        let project = init_test_project("/my-project", cx).await;
2160        let (multi_workspace, cx) =
2161            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2162        let sidebar = setup_sidebar(&multi_workspace, cx);
2163
2164        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2165        save_n_test_threads(3, &path_list, cx).await;
2166
2167        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2168        cx.run_until_parked();
2169
2170        // Entries: [header, thread3, thread2, thread1]
2171        // Focusing the sidebar does not set a selection; select_next/select_previous
2172        // handle None gracefully by starting from the first or last entry.
2173        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2174        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2175
2176        // First SelectNext from None starts at index 0
2177        cx.dispatch_action(SelectNext);
2178        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2179
2180        // Move down through remaining entries
2181        cx.dispatch_action(SelectNext);
2182        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2183
2184        cx.dispatch_action(SelectNext);
2185        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2186
2187        cx.dispatch_action(SelectNext);
2188        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2189
2190        // At the end, selection stays on the last entry
2191        cx.dispatch_action(SelectNext);
2192        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2193
2194        // Move back up
2195
2196        cx.dispatch_action(SelectPrevious);
2197        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2198
2199        cx.dispatch_action(SelectPrevious);
2200        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2201
2202        cx.dispatch_action(SelectPrevious);
2203        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2204
2205        // At the top, selection stays on the first entry
2206        cx.dispatch_action(SelectPrevious);
2207        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2208    }
2209
2210    #[gpui::test]
2211    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
2212        let project = init_test_project("/my-project", cx).await;
2213        let (multi_workspace, cx) =
2214            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2215        let sidebar = setup_sidebar(&multi_workspace, cx);
2216
2217        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2218        save_n_test_threads(3, &path_list, cx).await;
2219        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2220        cx.run_until_parked();
2221
2222        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2223
2224        // SelectLast jumps to the end
2225        cx.dispatch_action(SelectLast);
2226        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2227
2228        // SelectFirst jumps to the beginning
2229        cx.dispatch_action(SelectFirst);
2230        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2231    }
2232
2233    #[gpui::test]
2234    async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
2235        let project = init_test_project("/my-project", cx).await;
2236        let (multi_workspace, cx) =
2237            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2238        let sidebar = setup_sidebar(&multi_workspace, cx);
2239
2240        // Initially no selection
2241        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2242
2243        // Open the sidebar so it's rendered, then focus it to trigger focus_in.
2244        // focus_in no longer sets a default selection.
2245        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2246        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2247
2248        // Manually set a selection, blur, then refocus — selection should be preserved
2249        sidebar.update_in(cx, |sidebar, _window, _cx| {
2250            sidebar.selection = Some(0);
2251        });
2252
2253        cx.update(|window, _cx| {
2254            window.blur();
2255        });
2256        cx.run_until_parked();
2257
2258        sidebar.update_in(cx, |_, window, cx| {
2259            cx.focus_self(window);
2260        });
2261        cx.run_until_parked();
2262        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2263    }
2264
2265    #[gpui::test]
2266    async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) {
2267        let project = init_test_project("/my-project", cx).await;
2268        let (multi_workspace, cx) =
2269            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2270        let sidebar = setup_sidebar(&multi_workspace, cx);
2271
2272        multi_workspace.update_in(cx, |mw, window, cx| {
2273            mw.create_workspace(window, cx);
2274        });
2275        cx.run_until_parked();
2276
2277        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2278        save_n_test_threads(1, &path_list, cx).await;
2279        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2280        cx.run_until_parked();
2281
2282        assert_eq!(
2283            visible_entries_as_strings(&sidebar, cx),
2284            vec![
2285                "v [my-project]",
2286                "  Thread 1",
2287                "v [Empty Workspace]",
2288                "  [+ New Thread]",
2289            ]
2290        );
2291
2292        // Switch to workspace 1 so we can verify confirm switches back.
2293        multi_workspace.update_in(cx, |mw, window, cx| {
2294            mw.activate_index(1, window, cx);
2295        });
2296        cx.run_until_parked();
2297        assert_eq!(
2298            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2299            1
2300        );
2301
2302        // Focus the sidebar and manually select the header (index 0)
2303        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2304        sidebar.update_in(cx, |sidebar, _window, _cx| {
2305            sidebar.selection = Some(0);
2306        });
2307
2308        // Press confirm on project header (workspace 0) to activate it.
2309        cx.dispatch_action(Confirm);
2310        cx.run_until_parked();
2311
2312        assert_eq!(
2313            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2314            0
2315        );
2316
2317        // Focus should have moved out of the sidebar to the workspace center.
2318        let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
2319        workspace_0.update_in(cx, |workspace, window, cx| {
2320            let pane_focus = workspace.active_pane().read(cx).focus_handle(cx);
2321            assert!(
2322                pane_focus.contains_focused(window, cx),
2323                "Confirming a project header should focus the workspace center pane"
2324            );
2325        });
2326    }
2327
2328    #[gpui::test]
2329    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
2330        let project = init_test_project("/my-project", cx).await;
2331        let (multi_workspace, cx) =
2332            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2333        let sidebar = setup_sidebar(&multi_workspace, cx);
2334
2335        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2336        save_n_test_threads(8, &path_list, cx).await;
2337        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2338        cx.run_until_parked();
2339
2340        // Should show header + 5 threads + "View More (3)"
2341        let entries = visible_entries_as_strings(&sidebar, cx);
2342        assert_eq!(entries.len(), 7);
2343        assert!(entries.iter().any(|e| e.contains("View More (3)")));
2344
2345        // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
2346        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2347        for _ in 0..7 {
2348            cx.dispatch_action(SelectNext);
2349        }
2350        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
2351
2352        // Confirm on "View More" to expand
2353        cx.dispatch_action(Confirm);
2354        cx.run_until_parked();
2355
2356        // All 8 threads should now be visible with a "Collapse" button
2357        let entries = visible_entries_as_strings(&sidebar, cx);
2358        assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
2359        assert!(!entries.iter().any(|e| e.contains("View More")));
2360        assert!(entries.iter().any(|e| e.contains("Collapse")));
2361    }
2362
2363    #[gpui::test]
2364    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
2365        let project = init_test_project("/my-project", cx).await;
2366        let (multi_workspace, cx) =
2367            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2368        let sidebar = setup_sidebar(&multi_workspace, cx);
2369
2370        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2371        save_n_test_threads(1, &path_list, cx).await;
2372        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2373        cx.run_until_parked();
2374
2375        assert_eq!(
2376            visible_entries_as_strings(&sidebar, cx),
2377            vec!["v [my-project]", "  Thread 1"]
2378        );
2379
2380        // Focus sidebar and manually select the header (index 0). Press left to collapse.
2381        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2382        sidebar.update_in(cx, |sidebar, _window, _cx| {
2383            sidebar.selection = Some(0);
2384        });
2385
2386        cx.dispatch_action(CollapseSelectedEntry);
2387        cx.run_until_parked();
2388
2389        assert_eq!(
2390            visible_entries_as_strings(&sidebar, cx),
2391            vec!["> [my-project]  <== selected"]
2392        );
2393
2394        // Press right to expand
2395        cx.dispatch_action(ExpandSelectedEntry);
2396        cx.run_until_parked();
2397
2398        assert_eq!(
2399            visible_entries_as_strings(&sidebar, cx),
2400            vec!["v [my-project]  <== selected", "  Thread 1",]
2401        );
2402
2403        // Press right again on already-expanded header moves selection down
2404        cx.dispatch_action(ExpandSelectedEntry);
2405        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2406    }
2407
2408    #[gpui::test]
2409    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
2410        let project = init_test_project("/my-project", cx).await;
2411        let (multi_workspace, cx) =
2412            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2413        let sidebar = setup_sidebar(&multi_workspace, cx);
2414
2415        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2416        save_n_test_threads(1, &path_list, cx).await;
2417        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2418        cx.run_until_parked();
2419
2420        // Focus sidebar (selection starts at None), then navigate down to the thread (child)
2421        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2422        cx.dispatch_action(SelectNext);
2423        cx.dispatch_action(SelectNext);
2424        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2425
2426        assert_eq!(
2427            visible_entries_as_strings(&sidebar, cx),
2428            vec!["v [my-project]", "  Thread 1  <== selected",]
2429        );
2430
2431        // Pressing left on a child collapses the parent group and selects it
2432        cx.dispatch_action(CollapseSelectedEntry);
2433        cx.run_until_parked();
2434
2435        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2436        assert_eq!(
2437            visible_entries_as_strings(&sidebar, cx),
2438            vec!["> [my-project]  <== selected"]
2439        );
2440    }
2441
2442    #[gpui::test]
2443    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
2444        let project = init_test_project("/empty-project", cx).await;
2445        let (multi_workspace, cx) =
2446            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2447        let sidebar = setup_sidebar(&multi_workspace, cx);
2448
2449        // Even an empty project has the header and a new thread button
2450        assert_eq!(
2451            visible_entries_as_strings(&sidebar, cx),
2452            vec!["v [empty-project]", "  [+ New Thread]"]
2453        );
2454
2455        // Focus sidebar — focus_in does not set a selection
2456        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2457        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2458
2459        // First SelectNext from None starts at index 0 (header)
2460        cx.dispatch_action(SelectNext);
2461        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2462
2463        // SelectNext moves to the new thread button
2464        cx.dispatch_action(SelectNext);
2465        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2466
2467        // At the end, selection stays on the last entry
2468        cx.dispatch_action(SelectNext);
2469        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2470
2471        // SelectPrevious goes back to the header
2472        cx.dispatch_action(SelectPrevious);
2473        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2474    }
2475
2476    #[gpui::test]
2477    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
2478        let project = init_test_project("/my-project", cx).await;
2479        let (multi_workspace, cx) =
2480            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2481        let sidebar = setup_sidebar(&multi_workspace, cx);
2482
2483        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2484        save_n_test_threads(1, &path_list, cx).await;
2485        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2486        cx.run_until_parked();
2487
2488        // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
2489        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2490        cx.dispatch_action(SelectNext);
2491        cx.dispatch_action(SelectNext);
2492        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2493
2494        // Collapse the group, which removes the thread from the list
2495        cx.dispatch_action(CollapseSelectedEntry);
2496        cx.run_until_parked();
2497
2498        // Selection should be clamped to the last valid index (0 = header)
2499        let selection = sidebar.read_with(cx, |s, _| s.selection);
2500        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
2501        assert!(
2502            selection.unwrap_or(0) < entry_count,
2503            "selection {} should be within bounds (entries: {})",
2504            selection.unwrap_or(0),
2505            entry_count,
2506        );
2507    }
2508
2509    async fn init_test_project_with_agent_panel(
2510        worktree_path: &str,
2511        cx: &mut TestAppContext,
2512    ) -> Entity<project::Project> {
2513        agent_ui::test_support::init_test(cx);
2514        cx.update(|cx| {
2515            cx.update_flags(false, vec!["agent-v2".into()]);
2516            ThreadStore::init_global(cx);
2517            language_model::LanguageModelRegistry::test(cx);
2518        });
2519
2520        let fs = FakeFs::new(cx.executor());
2521        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
2522            .await;
2523        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2524        project::Project::test(fs, [worktree_path.as_ref()], cx).await
2525    }
2526
2527    fn add_agent_panel(
2528        workspace: &Entity<Workspace>,
2529        project: &Entity<project::Project>,
2530        cx: &mut gpui::VisualTestContext,
2531    ) -> Entity<AgentPanel> {
2532        workspace.update_in(cx, |workspace, window, cx| {
2533            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2534            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
2535            workspace.add_panel(panel.clone(), window, cx);
2536            panel
2537        })
2538    }
2539
2540    fn setup_sidebar_with_agent_panel(
2541        multi_workspace: &Entity<MultiWorkspace>,
2542        project: &Entity<project::Project>,
2543        cx: &mut gpui::VisualTestContext,
2544    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
2545        let sidebar = setup_sidebar(multi_workspace, cx);
2546        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2547        let panel = add_agent_panel(&workspace, project, cx);
2548        (sidebar, panel)
2549    }
2550
2551    #[gpui::test]
2552    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
2553        let project = init_test_project_with_agent_panel("/my-project", cx).await;
2554        let (multi_workspace, cx) =
2555            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2556        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
2557
2558        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2559
2560        // Open thread A and keep it generating.
2561        let connection_a = StubAgentConnection::new();
2562        open_thread_with_connection(&panel, connection_a.clone(), cx);
2563        send_message(&panel, cx);
2564
2565        let session_id_a = active_session_id(&panel, cx);
2566        save_thread_to_store(&session_id_a, &path_list, cx).await;
2567
2568        cx.update(|_, cx| {
2569            connection_a.send_update(
2570                session_id_a.clone(),
2571                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
2572                cx,
2573            );
2574        });
2575        cx.run_until_parked();
2576
2577        // Open thread B (idle, default response) — thread A goes to background.
2578        let connection_b = StubAgentConnection::new();
2579        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2580            acp::ContentChunk::new("Done".into()),
2581        )]);
2582        open_thread_with_connection(&panel, connection_b, cx);
2583        send_message(&panel, cx);
2584
2585        let session_id_b = active_session_id(&panel, cx);
2586        save_thread_to_store(&session_id_b, &path_list, cx).await;
2587
2588        cx.run_until_parked();
2589
2590        let mut entries = visible_entries_as_strings(&sidebar, cx);
2591        entries[1..].sort();
2592        assert_eq!(
2593            entries,
2594            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
2595        );
2596    }
2597
2598    #[gpui::test]
2599    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
2600        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2601        let (multi_workspace, cx) = cx
2602            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2603        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
2604
2605        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
2606
2607        // Open thread on workspace A and keep it generating.
2608        let connection_a = StubAgentConnection::new();
2609        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
2610        send_message(&panel_a, cx);
2611
2612        let session_id_a = active_session_id(&panel_a, cx);
2613        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
2614
2615        cx.update(|_, cx| {
2616            connection_a.send_update(
2617                session_id_a.clone(),
2618                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
2619                cx,
2620            );
2621        });
2622        cx.run_until_parked();
2623
2624        // Add a second workspace and activate it (making workspace A the background).
2625        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2626        let project_b = project::Project::test(fs, [], cx).await;
2627        multi_workspace.update_in(cx, |mw, window, cx| {
2628            mw.test_add_workspace(project_b, window, cx);
2629        });
2630        cx.run_until_parked();
2631
2632        // Thread A is still running; no notification yet.
2633        assert_eq!(
2634            visible_entries_as_strings(&sidebar, cx),
2635            vec![
2636                "v [project-a]",
2637                "  Hello * (running)",
2638                "v [Empty Workspace]",
2639                "  [+ New Thread]",
2640            ]
2641        );
2642
2643        // Complete thread A's turn (transition Running → Completed).
2644        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
2645        cx.run_until_parked();
2646
2647        // The completed background thread shows a notification indicator.
2648        assert_eq!(
2649            visible_entries_as_strings(&sidebar, cx),
2650            vec![
2651                "v [project-a]",
2652                "  Hello * (!)",
2653                "v [Empty Workspace]",
2654                "  [+ New Thread]",
2655            ]
2656        );
2657    }
2658
2659    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
2660        sidebar.update_in(cx, |sidebar, window, cx| {
2661            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
2662            sidebar.filter_editor.update(cx, |editor, cx| {
2663                editor.set_text(query, window, cx);
2664            });
2665        });
2666        cx.run_until_parked();
2667    }
2668
2669    #[gpui::test]
2670    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
2671        let project = init_test_project("/my-project", cx).await;
2672        let (multi_workspace, cx) =
2673            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2674        let sidebar = setup_sidebar(&multi_workspace, cx);
2675
2676        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2677        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2678
2679        for (id, title, hour) in [
2680            ("t-1", "Fix crash in project panel", 3),
2681            ("t-2", "Add inline diff view", 2),
2682            ("t-3", "Refactor settings module", 1),
2683        ] {
2684            let save_task = thread_store.update(cx, |store, cx| {
2685                store.save_thread(
2686                    acp::SessionId::new(Arc::from(id)),
2687                    make_test_thread(
2688                        title,
2689                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2690                    ),
2691                    path_list.clone(),
2692                    cx,
2693                )
2694            });
2695            save_task.await.unwrap();
2696        }
2697        cx.run_until_parked();
2698
2699        assert_eq!(
2700            visible_entries_as_strings(&sidebar, cx),
2701            vec![
2702                "v [my-project]",
2703                "  Fix crash in project panel",
2704                "  Add inline diff view",
2705                "  Refactor settings module",
2706            ]
2707        );
2708
2709        // User types "diff" in the search box — only the matching thread remains,
2710        // with its workspace header preserved for context.
2711        type_in_search(&sidebar, "diff", cx);
2712        assert_eq!(
2713            visible_entries_as_strings(&sidebar, cx),
2714            vec!["v [my-project]", "  Add inline diff view  <== selected",]
2715        );
2716
2717        // User changes query to something with no matches — list is empty.
2718        type_in_search(&sidebar, "nonexistent", cx);
2719        assert_eq!(
2720            visible_entries_as_strings(&sidebar, cx),
2721            Vec::<String>::new()
2722        );
2723    }
2724
2725    #[gpui::test]
2726    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
2727        // Scenario: A user remembers a thread title but not the exact casing.
2728        // Search should match case-insensitively so they can still find it.
2729        let project = init_test_project("/my-project", cx).await;
2730        let (multi_workspace, cx) =
2731            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2732        let sidebar = setup_sidebar(&multi_workspace, cx);
2733
2734        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2735        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2736
2737        let save_task = thread_store.update(cx, |store, cx| {
2738            store.save_thread(
2739                acp::SessionId::new(Arc::from("thread-1")),
2740                make_test_thread(
2741                    "Fix Crash In Project Panel",
2742                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2743                ),
2744                path_list.clone(),
2745                cx,
2746            )
2747        });
2748        save_task.await.unwrap();
2749        cx.run_until_parked();
2750
2751        // Lowercase query matches mixed-case title.
2752        type_in_search(&sidebar, "fix crash", cx);
2753        assert_eq!(
2754            visible_entries_as_strings(&sidebar, cx),
2755            vec![
2756                "v [my-project]",
2757                "  Fix Crash In Project Panel  <== selected",
2758            ]
2759        );
2760
2761        // Uppercase query also matches the same title.
2762        type_in_search(&sidebar, "FIX CRASH", cx);
2763        assert_eq!(
2764            visible_entries_as_strings(&sidebar, cx),
2765            vec![
2766                "v [my-project]",
2767                "  Fix Crash In Project Panel  <== selected",
2768            ]
2769        );
2770    }
2771
2772    #[gpui::test]
2773    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
2774        // Scenario: A user searches, finds what they need, then presses Escape
2775        // to dismiss the filter and see the full list again.
2776        let project = init_test_project("/my-project", cx).await;
2777        let (multi_workspace, cx) =
2778            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2779        let sidebar = setup_sidebar(&multi_workspace, cx);
2780
2781        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2782        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2783
2784        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
2785            let save_task = thread_store.update(cx, |store, cx| {
2786                store.save_thread(
2787                    acp::SessionId::new(Arc::from(id)),
2788                    make_test_thread(
2789                        title,
2790                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2791                    ),
2792                    path_list.clone(),
2793                    cx,
2794                )
2795            });
2796            save_task.await.unwrap();
2797        }
2798        cx.run_until_parked();
2799
2800        // Confirm the full list is showing.
2801        assert_eq!(
2802            visible_entries_as_strings(&sidebar, cx),
2803            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
2804        );
2805
2806        // User types a search query to filter down.
2807        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2808        type_in_search(&sidebar, "alpha", cx);
2809        assert_eq!(
2810            visible_entries_as_strings(&sidebar, cx),
2811            vec!["v [my-project]", "  Alpha thread  <== selected",]
2812        );
2813
2814        // User presses Escape — filter clears, full list is restored.
2815        cx.dispatch_action(Cancel);
2816        cx.run_until_parked();
2817        assert_eq!(
2818            visible_entries_as_strings(&sidebar, cx),
2819            vec![
2820                "v [my-project]",
2821                "  Alpha thread  <== selected",
2822                "  Beta thread",
2823            ]
2824        );
2825    }
2826
2827    #[gpui::test]
2828    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
2829        let project_a = init_test_project("/project-a", cx).await;
2830        let (multi_workspace, cx) =
2831            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
2832        let sidebar = setup_sidebar(&multi_workspace, cx);
2833
2834        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
2835        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2836
2837        for (id, title, hour) in [
2838            ("a1", "Fix bug in sidebar", 2),
2839            ("a2", "Add tests for editor", 1),
2840        ] {
2841            let save_task = thread_store.update(cx, |store, cx| {
2842                store.save_thread(
2843                    acp::SessionId::new(Arc::from(id)),
2844                    make_test_thread(
2845                        title,
2846                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2847                    ),
2848                    path_list_a.clone(),
2849                    cx,
2850                )
2851            });
2852            save_task.await.unwrap();
2853        }
2854
2855        // Add a second workspace.
2856        multi_workspace.update_in(cx, |mw, window, cx| {
2857            mw.create_workspace(window, cx);
2858        });
2859        cx.run_until_parked();
2860
2861        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
2862
2863        for (id, title, hour) in [
2864            ("b1", "Refactor sidebar layout", 3),
2865            ("b2", "Fix typo in README", 1),
2866        ] {
2867            let save_task = thread_store.update(cx, |store, cx| {
2868                store.save_thread(
2869                    acp::SessionId::new(Arc::from(id)),
2870                    make_test_thread(
2871                        title,
2872                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2873                    ),
2874                    path_list_b.clone(),
2875                    cx,
2876                )
2877            });
2878            save_task.await.unwrap();
2879        }
2880        cx.run_until_parked();
2881
2882        assert_eq!(
2883            visible_entries_as_strings(&sidebar, cx),
2884            vec![
2885                "v [project-a]",
2886                "  Fix bug in sidebar",
2887                "  Add tests for editor",
2888                "v [Empty Workspace]",
2889                "  Refactor sidebar layout",
2890                "  Fix typo in README",
2891            ]
2892        );
2893
2894        // "sidebar" matches a thread in each workspace — both headers stay visible.
2895        type_in_search(&sidebar, "sidebar", cx);
2896        assert_eq!(
2897            visible_entries_as_strings(&sidebar, cx),
2898            vec![
2899                "v [project-a]",
2900                "  Fix bug in sidebar  <== selected",
2901                "v [Empty Workspace]",
2902                "  Refactor sidebar layout",
2903            ]
2904        );
2905
2906        // "typo" only matches in the second workspace — the first header disappears.
2907        type_in_search(&sidebar, "typo", cx);
2908        assert_eq!(
2909            visible_entries_as_strings(&sidebar, cx),
2910            vec!["v [Empty Workspace]", "  Fix typo in README  <== selected",]
2911        );
2912
2913        // "project-a" matches the first workspace name — the header appears
2914        // with all child threads included.
2915        type_in_search(&sidebar, "project-a", cx);
2916        assert_eq!(
2917            visible_entries_as_strings(&sidebar, cx),
2918            vec![
2919                "v [project-a]",
2920                "  Fix bug in sidebar  <== selected",
2921                "  Add tests for editor",
2922            ]
2923        );
2924    }
2925
2926    #[gpui::test]
2927    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
2928        let project_a = init_test_project("/alpha-project", cx).await;
2929        let (multi_workspace, cx) =
2930            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
2931        let sidebar = setup_sidebar(&multi_workspace, cx);
2932
2933        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
2934        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2935
2936        for (id, title, hour) in [
2937            ("a1", "Fix bug in sidebar", 2),
2938            ("a2", "Add tests for editor", 1),
2939        ] {
2940            let save_task = thread_store.update(cx, |store, cx| {
2941                store.save_thread(
2942                    acp::SessionId::new(Arc::from(id)),
2943                    make_test_thread(
2944                        title,
2945                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2946                    ),
2947                    path_list_a.clone(),
2948                    cx,
2949                )
2950            });
2951            save_task.await.unwrap();
2952        }
2953
2954        // Add a second workspace.
2955        multi_workspace.update_in(cx, |mw, window, cx| {
2956            mw.create_workspace(window, cx);
2957        });
2958        cx.run_until_parked();
2959
2960        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
2961
2962        for (id, title, hour) in [
2963            ("b1", "Refactor sidebar layout", 3),
2964            ("b2", "Fix typo in README", 1),
2965        ] {
2966            let save_task = thread_store.update(cx, |store, cx| {
2967                store.save_thread(
2968                    acp::SessionId::new(Arc::from(id)),
2969                    make_test_thread(
2970                        title,
2971                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2972                    ),
2973                    path_list_b.clone(),
2974                    cx,
2975                )
2976            });
2977            save_task.await.unwrap();
2978        }
2979        cx.run_until_parked();
2980
2981        // "alpha" matches the workspace name "alpha-project" but no thread titles.
2982        // The workspace header should appear with all child threads included.
2983        type_in_search(&sidebar, "alpha", cx);
2984        assert_eq!(
2985            visible_entries_as_strings(&sidebar, cx),
2986            vec![
2987                "v [alpha-project]",
2988                "  Fix bug in sidebar  <== selected",
2989                "  Add tests for editor",
2990            ]
2991        );
2992
2993        // "sidebar" matches thread titles in both workspaces but not workspace names.
2994        // Both headers appear with their matching threads.
2995        type_in_search(&sidebar, "sidebar", cx);
2996        assert_eq!(
2997            visible_entries_as_strings(&sidebar, cx),
2998            vec![
2999                "v [alpha-project]",
3000                "  Fix bug in sidebar  <== selected",
3001                "v [Empty Workspace]",
3002                "  Refactor sidebar layout",
3003            ]
3004        );
3005
3006        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
3007        // doesn't match) — but does not match either workspace name or any thread.
3008        // Actually let's test something simpler: a query that matches both a workspace
3009        // name AND some threads in that workspace. Matching threads should still appear.
3010        type_in_search(&sidebar, "fix", cx);
3011        assert_eq!(
3012            visible_entries_as_strings(&sidebar, cx),
3013            vec![
3014                "v [alpha-project]",
3015                "  Fix bug in sidebar  <== selected",
3016                "v [Empty Workspace]",
3017                "  Fix typo in README",
3018            ]
3019        );
3020
3021        // A query that matches a workspace name AND a thread in that same workspace.
3022        // Both the header (highlighted) and all child threads should appear.
3023        type_in_search(&sidebar, "alpha", cx);
3024        assert_eq!(
3025            visible_entries_as_strings(&sidebar, cx),
3026            vec![
3027                "v [alpha-project]",
3028                "  Fix bug in sidebar  <== selected",
3029                "  Add tests for editor",
3030            ]
3031        );
3032
3033        // Now search for something that matches only a workspace name when there
3034        // are also threads with matching titles — the non-matching workspace's
3035        // threads should still appear if their titles match.
3036        type_in_search(&sidebar, "alp", cx);
3037        assert_eq!(
3038            visible_entries_as_strings(&sidebar, cx),
3039            vec![
3040                "v [alpha-project]",
3041                "  Fix bug in sidebar  <== selected",
3042                "  Add tests for editor",
3043            ]
3044        );
3045    }
3046
3047    #[gpui::test]
3048    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
3049        let project = init_test_project("/my-project", cx).await;
3050        let (multi_workspace, cx) =
3051            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3052        let sidebar = setup_sidebar(&multi_workspace, cx);
3053
3054        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3055        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3056
3057        // Create 8 threads. The oldest one has a unique name and will be
3058        // behind View More (only 5 shown by default).
3059        for i in 0..8u32 {
3060            let title = if i == 0 {
3061                "Hidden gem thread".to_string()
3062            } else {
3063                format!("Thread {}", i + 1)
3064            };
3065            let save_task = thread_store.update(cx, |store, cx| {
3066                store.save_thread(
3067                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3068                    make_test_thread(
3069                        &title,
3070                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3071                    ),
3072                    path_list.clone(),
3073                    cx,
3074                )
3075            });
3076            save_task.await.unwrap();
3077        }
3078        cx.run_until_parked();
3079
3080        // Confirm the thread is not visible and View More is shown.
3081        let entries = visible_entries_as_strings(&sidebar, cx);
3082        assert!(
3083            entries.iter().any(|e| e.contains("View More")),
3084            "should have View More button"
3085        );
3086        assert!(
3087            !entries.iter().any(|e| e.contains("Hidden gem")),
3088            "Hidden gem should be behind View More"
3089        );
3090
3091        // User searches for the hidden thread — it appears, and View More is gone.
3092        type_in_search(&sidebar, "hidden gem", cx);
3093        let filtered = visible_entries_as_strings(&sidebar, cx);
3094        assert_eq!(
3095            filtered,
3096            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
3097        );
3098        assert!(
3099            !filtered.iter().any(|e| e.contains("View More")),
3100            "View More should not appear when filtering"
3101        );
3102    }
3103
3104    #[gpui::test]
3105    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
3106        let project = init_test_project("/my-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        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3112        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3113
3114        let save_task = thread_store.update(cx, |store, cx| {
3115            store.save_thread(
3116                acp::SessionId::new(Arc::from("thread-1")),
3117                make_test_thread(
3118                    "Important thread",
3119                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3120                ),
3121                path_list.clone(),
3122                cx,
3123            )
3124        });
3125        save_task.await.unwrap();
3126        cx.run_until_parked();
3127
3128        // User focuses the sidebar and collapses the group using keyboard:
3129        // manually select the header, then press CollapseSelectedEntry to collapse.
3130        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
3131        sidebar.update_in(cx, |sidebar, _window, _cx| {
3132            sidebar.selection = Some(0);
3133        });
3134        cx.dispatch_action(CollapseSelectedEntry);
3135        cx.run_until_parked();
3136
3137        assert_eq!(
3138            visible_entries_as_strings(&sidebar, cx),
3139            vec!["> [my-project]  <== selected"]
3140        );
3141
3142        // User types a search — the thread appears even though its group is collapsed.
3143        type_in_search(&sidebar, "important", cx);
3144        assert_eq!(
3145            visible_entries_as_strings(&sidebar, cx),
3146            vec!["> [my-project]", "  Important thread  <== selected",]
3147        );
3148    }
3149
3150    #[gpui::test]
3151    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
3152        let project = init_test_project("/my-project", cx).await;
3153        let (multi_workspace, cx) =
3154            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3155        let sidebar = setup_sidebar(&multi_workspace, cx);
3156
3157        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3158        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3159
3160        for (id, title, hour) in [
3161            ("t-1", "Fix crash in panel", 3),
3162            ("t-2", "Fix lint warnings", 2),
3163            ("t-3", "Add new feature", 1),
3164        ] {
3165            let save_task = thread_store.update(cx, |store, cx| {
3166                store.save_thread(
3167                    acp::SessionId::new(Arc::from(id)),
3168                    make_test_thread(
3169                        title,
3170                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3171                    ),
3172                    path_list.clone(),
3173                    cx,
3174                )
3175            });
3176            save_task.await.unwrap();
3177        }
3178        cx.run_until_parked();
3179
3180        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
3181
3182        // User types "fix" — two threads match.
3183        type_in_search(&sidebar, "fix", cx);
3184        assert_eq!(
3185            visible_entries_as_strings(&sidebar, cx),
3186            vec![
3187                "v [my-project]",
3188                "  Fix crash in panel  <== selected",
3189                "  Fix lint warnings",
3190            ]
3191        );
3192
3193        // Selection starts on the first matching thread. User presses
3194        // SelectNext to move to the second match.
3195        cx.dispatch_action(SelectNext);
3196        assert_eq!(
3197            visible_entries_as_strings(&sidebar, cx),
3198            vec![
3199                "v [my-project]",
3200                "  Fix crash in panel",
3201                "  Fix lint warnings  <== selected",
3202            ]
3203        );
3204
3205        // User can also jump back with SelectPrevious.
3206        cx.dispatch_action(SelectPrevious);
3207        assert_eq!(
3208            visible_entries_as_strings(&sidebar, cx),
3209            vec![
3210                "v [my-project]",
3211                "  Fix crash in panel  <== selected",
3212                "  Fix lint warnings",
3213            ]
3214        );
3215    }
3216
3217    #[gpui::test]
3218    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
3219        let project = init_test_project("/my-project", cx).await;
3220        let (multi_workspace, cx) =
3221            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3222        let sidebar = setup_sidebar(&multi_workspace, cx);
3223
3224        multi_workspace.update_in(cx, |mw, window, cx| {
3225            mw.create_workspace(window, cx);
3226        });
3227        cx.run_until_parked();
3228
3229        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3230        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3231
3232        let save_task = thread_store.update(cx, |store, cx| {
3233            store.save_thread(
3234                acp::SessionId::new(Arc::from("hist-1")),
3235                make_test_thread(
3236                    "Historical Thread",
3237                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
3238                ),
3239                path_list.clone(),
3240                cx,
3241            )
3242        });
3243        save_task.await.unwrap();
3244        cx.run_until_parked();
3245        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3246        cx.run_until_parked();
3247
3248        assert_eq!(
3249            visible_entries_as_strings(&sidebar, cx),
3250            vec![
3251                "v [my-project]",
3252                "  Historical Thread",
3253                "v [Empty Workspace]",
3254                "  [+ New Thread]",
3255            ]
3256        );
3257
3258        // Switch to workspace 1 so we can verify the confirm switches back.
3259        multi_workspace.update_in(cx, |mw, window, cx| {
3260            mw.activate_index(1, window, cx);
3261        });
3262        cx.run_until_parked();
3263        assert_eq!(
3264            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3265            1
3266        );
3267
3268        // Confirm on the historical (non-live) thread at index 1.
3269        // Before a previous fix, the workspace field was Option<usize> and
3270        // historical threads had None, so activate_thread early-returned
3271        // without switching the workspace.
3272        sidebar.update_in(cx, |sidebar, window, cx| {
3273            sidebar.selection = Some(1);
3274            sidebar.confirm(&Confirm, window, cx);
3275        });
3276        cx.run_until_parked();
3277
3278        assert_eq!(
3279            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3280            0
3281        );
3282    }
3283
3284    #[gpui::test]
3285    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
3286        let project = init_test_project("/my-project", cx).await;
3287        let (multi_workspace, cx) =
3288            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3289        let sidebar = setup_sidebar(&multi_workspace, cx);
3290
3291        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3292        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3293
3294        let save_task = thread_store.update(cx, |store, cx| {
3295            store.save_thread(
3296                acp::SessionId::new(Arc::from("t-1")),
3297                make_test_thread(
3298                    "Thread A",
3299                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3300                ),
3301                path_list.clone(),
3302                cx,
3303            )
3304        });
3305        save_task.await.unwrap();
3306        let save_task = thread_store.update(cx, |store, cx| {
3307            store.save_thread(
3308                acp::SessionId::new(Arc::from("t-2")),
3309                make_test_thread(
3310                    "Thread B",
3311                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3312                ),
3313                path_list.clone(),
3314                cx,
3315            )
3316        });
3317        save_task.await.unwrap();
3318        cx.run_until_parked();
3319        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3320        cx.run_until_parked();
3321
3322        assert_eq!(
3323            visible_entries_as_strings(&sidebar, cx),
3324            vec!["v [my-project]", "  Thread A", "  Thread B",]
3325        );
3326
3327        // Keyboard confirm preserves selection.
3328        sidebar.update_in(cx, |sidebar, window, cx| {
3329            sidebar.selection = Some(1);
3330            sidebar.confirm(&Confirm, window, cx);
3331        });
3332        assert_eq!(
3333            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
3334            Some(1)
3335        );
3336
3337        // Click handlers clear selection to None so no highlight lingers
3338        // after a click regardless of focus state. The hover style provides
3339        // visual feedback during mouse interaction instead.
3340        sidebar.update_in(cx, |sidebar, window, cx| {
3341            sidebar.selection = None;
3342            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3343            sidebar.toggle_collapse(&path_list, window, cx);
3344        });
3345        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3346
3347        // When the user tabs back into the sidebar, focus_in no longer
3348        // restores selection — it stays None.
3349        sidebar.update_in(cx, |sidebar, window, cx| {
3350            sidebar.focus_in(window, cx);
3351        });
3352        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3353    }
3354
3355    #[gpui::test]
3356    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
3357        let project = init_test_project_with_agent_panel("/my-project", cx).await;
3358        let (multi_workspace, cx) =
3359            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3360        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
3361
3362        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3363
3364        let connection = StubAgentConnection::new();
3365        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3366            acp::ContentChunk::new("Hi there!".into()),
3367        )]);
3368        open_thread_with_connection(&panel, connection, cx);
3369        send_message(&panel, cx);
3370
3371        let session_id = active_session_id(&panel, cx);
3372        save_thread_to_store(&session_id, &path_list, cx).await;
3373        cx.run_until_parked();
3374
3375        assert_eq!(
3376            visible_entries_as_strings(&sidebar, cx),
3377            vec!["v [my-project]", "  Hello *"]
3378        );
3379
3380        // Simulate the agent generating a title. The notification chain is:
3381        // AcpThread::set_title emits TitleUpdated →
3382        // ConnectionView::handle_thread_event calls cx.notify() →
3383        // AgentPanel observer fires and emits AgentPanelEvent →
3384        // Sidebar subscription calls update_entries / rebuild_contents.
3385        //
3386        // Before the fix, handle_thread_event did NOT call cx.notify() for
3387        // TitleUpdated, so the AgentPanel observer never fired and the
3388        // sidebar kept showing the old title.
3389        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
3390        thread.update(cx, |thread, cx| {
3391            thread
3392                .set_title("Friendly Greeting with AI".into(), cx)
3393                .detach();
3394        });
3395        cx.run_until_parked();
3396
3397        assert_eq!(
3398            visible_entries_as_strings(&sidebar, cx),
3399            vec!["v [my-project]", "  Friendly Greeting with AI *"]
3400        );
3401    }
3402
3403    #[gpui::test]
3404    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
3405        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
3406        let (multi_workspace, cx) = cx
3407            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3408        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
3409
3410        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3411
3412        // Save a thread so it appears in the list.
3413        let connection_a = StubAgentConnection::new();
3414        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3415            acp::ContentChunk::new("Done".into()),
3416        )]);
3417        open_thread_with_connection(&panel_a, connection_a, cx);
3418        send_message(&panel_a, cx);
3419        let session_id_a = active_session_id(&panel_a, cx);
3420        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3421
3422        // Add a second workspace with its own agent panel.
3423        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3424        fs.as_fake()
3425            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
3426            .await;
3427        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
3428        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3429            mw.test_add_workspace(project_b.clone(), window, cx)
3430        });
3431        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
3432        cx.run_until_parked();
3433
3434        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
3435
3436        // ── 1. Initial state: no focused thread ──────────────────────────────
3437        // Workspace B is active (just added), so its header is the active entry.
3438        sidebar.read_with(cx, |sidebar, _cx| {
3439            assert_eq!(
3440                sidebar.focused_thread, None,
3441                "Initially no thread should be focused"
3442            );
3443            let active_entry = sidebar
3444                .active_entry_index
3445                .and_then(|ix| sidebar.contents.entries.get(ix));
3446            assert!(
3447                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
3448                "Active entry should be the active workspace header"
3449            );
3450        });
3451
3452        sidebar.update_in(cx, |sidebar, window, cx| {
3453            sidebar.activate_thread(
3454                acp_thread::AgentSessionInfo {
3455                    session_id: session_id_a.clone(),
3456                    cwd: None,
3457                    title: Some("Test".into()),
3458                    updated_at: None,
3459                    meta: None,
3460                },
3461                &workspace_a,
3462                window,
3463                cx,
3464            );
3465        });
3466        cx.run_until_parked();
3467
3468        sidebar.read_with(cx, |sidebar, _cx| {
3469            assert_eq!(
3470                sidebar.focused_thread.as_ref(),
3471                Some(&session_id_a),
3472                "After clicking a thread, it should be the focused thread"
3473            );
3474            let active_entry = sidebar.active_entry_index
3475                .and_then(|ix| sidebar.contents.entries.get(ix));
3476            assert!(
3477                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
3478                "Active entry should be the clicked thread"
3479            );
3480        });
3481
3482        workspace_a.read_with(cx, |workspace, cx| {
3483            assert!(
3484                workspace.panel::<AgentPanel>(cx).is_some(),
3485                "Agent panel should exist"
3486            );
3487            let dock = workspace.right_dock().read(cx);
3488            assert!(
3489                dock.is_open(),
3490                "Clicking a thread should open the agent panel dock"
3491            );
3492        });
3493
3494        let connection_b = StubAgentConnection::new();
3495        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3496            acp::ContentChunk::new("Thread B".into()),
3497        )]);
3498        open_thread_with_connection(&panel_b, connection_b, cx);
3499        send_message(&panel_b, cx);
3500        let session_id_b = active_session_id(&panel_b, cx);
3501        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
3502        save_thread_to_store(&session_id_b, &path_list_b, cx).await;
3503        cx.run_until_parked();
3504
3505        // Workspace A is currently active. Click a thread in workspace B,
3506        // which also triggers a workspace switch.
3507        sidebar.update_in(cx, |sidebar, window, cx| {
3508            sidebar.activate_thread(
3509                acp_thread::AgentSessionInfo {
3510                    session_id: session_id_b.clone(),
3511                    cwd: None,
3512                    title: Some("Thread B".into()),
3513                    updated_at: None,
3514                    meta: None,
3515                },
3516                &workspace_b,
3517                window,
3518                cx,
3519            );
3520        });
3521        cx.run_until_parked();
3522
3523        sidebar.read_with(cx, |sidebar, _cx| {
3524            assert_eq!(
3525                sidebar.focused_thread.as_ref(),
3526                Some(&session_id_b),
3527                "Clicking a thread in another workspace should focus that thread"
3528            );
3529            let active_entry = sidebar
3530                .active_entry_index
3531                .and_then(|ix| sidebar.contents.entries.get(ix));
3532            assert!(
3533                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b),
3534                "Active entry should be the cross-workspace thread"
3535            );
3536        });
3537
3538        multi_workspace.update_in(cx, |mw, window, cx| {
3539            mw.activate_next_workspace(window, cx);
3540        });
3541        cx.run_until_parked();
3542
3543        sidebar.read_with(cx, |sidebar, _cx| {
3544            assert_eq!(
3545                sidebar.focused_thread, None,
3546                "External workspace switch should clear focused_thread"
3547            );
3548            let active_entry = sidebar
3549                .active_entry_index
3550                .and_then(|ix| sidebar.contents.entries.get(ix));
3551            assert!(
3552                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
3553                "Active entry should be the workspace header after external switch"
3554            );
3555        });
3556
3557        let connection_b2 = StubAgentConnection::new();
3558        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3559            acp::ContentChunk::new("New thread".into()),
3560        )]);
3561        open_thread_with_connection(&panel_b, connection_b2, cx);
3562        send_message(&panel_b, cx);
3563        let session_id_b2 = active_session_id(&panel_b, cx);
3564        save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
3565        cx.run_until_parked();
3566
3567        sidebar.read_with(cx, |sidebar, _cx| {
3568            assert_eq!(
3569                sidebar.focused_thread.as_ref(),
3570                Some(&session_id_b2),
3571                "Opening a thread externally should set focused_thread"
3572            );
3573        });
3574
3575        workspace_b.update_in(cx, |workspace, window, cx| {
3576            workspace.focus_handle(cx).focus(window, cx);
3577        });
3578        cx.run_until_parked();
3579
3580        sidebar.read_with(cx, |sidebar, _cx| {
3581            assert_eq!(
3582                sidebar.focused_thread.as_ref(),
3583                Some(&session_id_b2),
3584                "Defocusing the sidebar should not clear focused_thread"
3585            );
3586        });
3587
3588        sidebar.update_in(cx, |sidebar, window, cx| {
3589            sidebar.activate_workspace(&workspace_b, window, cx);
3590        });
3591        cx.run_until_parked();
3592
3593        sidebar.read_with(cx, |sidebar, _cx| {
3594            assert_eq!(
3595                sidebar.focused_thread, None,
3596                "Clicking a workspace header should clear focused_thread"
3597            );
3598            let active_entry = sidebar
3599                .active_entry_index
3600                .and_then(|ix| sidebar.contents.entries.get(ix));
3601            assert!(
3602                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
3603                "Active entry should be the workspace header"
3604            );
3605        });
3606
3607        // ── 8. Focusing the agent panel thread restores focused_thread ────
3608        // Workspace B still has session_id_b2 loaded in the agent panel.
3609        // Clicking into the thread (simulated by focusing its view) should
3610        // set focused_thread via the ThreadFocused event.
3611        panel_b.update_in(cx, |panel, window, cx| {
3612            if let Some(thread_view) = panel.active_connection_view() {
3613                thread_view.read(cx).focus_handle(cx).focus(window, cx);
3614            }
3615        });
3616        cx.run_until_parked();
3617
3618        sidebar.read_with(cx, |sidebar, _cx| {
3619            assert_eq!(
3620                sidebar.focused_thread.as_ref(),
3621                Some(&session_id_b2),
3622                "Focusing the agent panel thread should set focused_thread"
3623            );
3624            let active_entry = sidebar
3625                .active_entry_index
3626                .and_then(|ix| sidebar.contents.entries.get(ix));
3627            assert!(
3628                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
3629                "Active entry should be the focused thread"
3630            );
3631        });
3632    }
3633}