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