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