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