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