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