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