sidebar.rs

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