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