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