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