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