sidebar.rs

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