sidebar.rs

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