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