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