sidebar.rs

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