sidebar.rs

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