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