1use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
2use crate::{AgentPanel, AgentPanelEvent, NewThread};
3use acp_thread::ThreadStatus;
4use action_log::DiffStats;
5use agent::ThreadStore;
6use agent_client_protocol as acp;
7use agent_settings::AgentSettings;
8use chrono::Utc;
9use db::kvp::KEY_VALUE_STORE;
10use editor::Editor;
11use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
12use gpui::{
13 Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels,
14 Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px,
15};
16use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
17use project::Event as ProjectEvent;
18use settings::Settings;
19use std::collections::{HashMap, HashSet};
20use std::mem;
21use std::path::Path;
22use std::sync::Arc;
23use theme::ActiveTheme;
24use ui::{
25 AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
26 Tooltip, WithScrollbar, prelude::*,
27};
28use util::ResultExt as _;
29use util::path_list::PathList;
30use workspace::{
31 MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
32};
33use zed_actions::editor::{MoveDown, MoveUp};
34
35actions!(
36 agents_sidebar,
37 [
38 /// Collapses the selected entry in the workspace sidebar.
39 CollapseSelectedEntry,
40 /// Expands the selected entry in the workspace sidebar.
41 ExpandSelectedEntry,
42 ]
43);
44
45const DEFAULT_WIDTH: Pixels = px(320.0);
46const MIN_WIDTH: Pixels = px(200.0);
47const MAX_WIDTH: Pixels = px(800.0);
48const DEFAULT_THREADS_SHOWN: usize = 5;
49const SIDEBAR_STATE_KEY: &str = "sidebar_state";
50
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52enum SidebarView {
53 #[default]
54 ThreadList,
55 Archive,
56}
57
58fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
59 KEY_VALUE_STORE
60 .scoped(SIDEBAR_STATE_KEY)
61 .read(&multi_workspace_id.to_string())
62 .log_err()
63 .flatten()
64 .and_then(|json| serde_json::from_str::<bool>(&json).ok())
65 .unwrap_or(false)
66}
67
68async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) {
69 if let Ok(json) = serde_json::to_string(&is_open) {
70 KEY_VALUE_STORE
71 .scoped(SIDEBAR_STATE_KEY)
72 .write(multi_workspace_id.to_string(), json)
73 .await
74 .log_err();
75 }
76}
77
78#[derive(Clone, Debug)]
79struct ActiveThreadInfo {
80 session_id: acp::SessionId,
81 title: SharedString,
82 status: AgentThreadStatus,
83 icon: IconName,
84 icon_from_external_svg: Option<SharedString>,
85 is_background: bool,
86 diff_stats: DiffStats,
87}
88
89impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
90 fn from(info: &ActiveThreadInfo) -> Self {
91 Self {
92 session_id: info.session_id.clone(),
93 cwd: None,
94 title: Some(info.title.clone()),
95 updated_at: Some(Utc::now()),
96 created_at: Some(Utc::now()),
97 meta: None,
98 }
99 }
100}
101
102#[derive(Clone)]
103enum ThreadEntryWorkspace {
104 Open(Entity<Workspace>),
105 Closed(PathList),
106}
107
108#[derive(Clone)]
109struct ThreadEntry {
110 session_info: acp_thread::AgentSessionInfo,
111 icon: IconName,
112 icon_from_external_svg: Option<SharedString>,
113 status: AgentThreadStatus,
114 workspace: ThreadEntryWorkspace,
115 is_live: bool,
116 is_background: bool,
117 highlight_positions: Vec<usize>,
118 worktree_name: Option<SharedString>,
119 worktree_highlight_positions: Vec<usize>,
120 diff_stats: DiffStats,
121}
122
123#[derive(Clone)]
124enum ListEntry {
125 ProjectHeader {
126 path_list: PathList,
127 label: SharedString,
128 workspace: Entity<Workspace>,
129 highlight_positions: Vec<usize>,
130 has_threads: bool,
131 },
132 Thread(ThreadEntry),
133 ViewMore {
134 path_list: PathList,
135 remaining_count: usize,
136 is_fully_expanded: bool,
137 },
138 NewThread {
139 path_list: PathList,
140 workspace: Entity<Workspace>,
141 },
142}
143
144impl From<ThreadEntry> for ListEntry {
145 fn from(thread: ThreadEntry) -> Self {
146 ListEntry::Thread(thread)
147 }
148}
149
150#[derive(Default)]
151struct SidebarContents {
152 entries: Vec<ListEntry>,
153 notified_threads: HashSet<acp::SessionId>,
154 project_header_indices: Vec<usize>,
155}
156
157impl SidebarContents {
158 fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
159 self.notified_threads.contains(session_id)
160 }
161}
162
163fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
164 let mut positions = Vec::new();
165 let mut query_chars = query.chars().peekable();
166
167 for (byte_idx, candidate_char) in candidate.char_indices() {
168 if let Some(&query_char) = query_chars.peek() {
169 if candidate_char.eq_ignore_ascii_case(&query_char) {
170 positions.push(byte_idx);
171 query_chars.next();
172 }
173 } else {
174 break;
175 }
176 }
177
178 if query_chars.peek().is_none() {
179 Some(positions)
180 } else {
181 None
182 }
183}
184
185// TODO: The mapping from workspace root paths to git repositories needs a
186// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
187// thread persistence (which PathList is saved to the database), and thread
188// querying (which PathList is used to read threads back). All of these need
189// to agree on how repos are resolved for a given workspace, especially in
190// multi-root and nested-repo configurations.
191fn root_repository_snapshots(
192 workspace: &Entity<Workspace>,
193 cx: &App,
194) -> Vec<project::git_store::RepositorySnapshot> {
195 let (path_list, _) = workspace_path_list_and_label(workspace, cx);
196 let project = workspace.read(cx).project().read(cx);
197 project
198 .repositories(cx)
199 .values()
200 .filter_map(|repo| {
201 let snapshot = repo.read(cx).snapshot();
202 let is_root = path_list
203 .paths()
204 .iter()
205 .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
206 is_root.then_some(snapshot)
207 })
208 .collect()
209}
210
211fn workspace_path_list_and_label(
212 workspace: &Entity<Workspace>,
213 cx: &App,
214) -> (PathList, SharedString) {
215 let workspace_ref = workspace.read(cx);
216 let mut paths = Vec::new();
217 let mut names = Vec::new();
218
219 for worktree in workspace_ref.worktrees(cx) {
220 let worktree_ref = worktree.read(cx);
221 if !worktree_ref.is_visible() {
222 continue;
223 }
224 let abs_path = worktree_ref.abs_path();
225 paths.push(abs_path.to_path_buf());
226 if let Some(name) = abs_path.file_name() {
227 names.push(name.to_string_lossy().to_string());
228 }
229 }
230
231 let label: SharedString = if names.is_empty() {
232 // TODO: Can we do something better in this case?
233 "Empty Workspace".into()
234 } else {
235 names.join(", ").into()
236 };
237
238 (PathList::new(&paths), label)
239}
240
241pub struct Sidebar {
242 multi_workspace: WeakEntity<MultiWorkspace>,
243 persistence_key: Option<u64>,
244 is_open: bool,
245 width: Pixels,
246 focus_handle: FocusHandle,
247 filter_editor: Entity<Editor>,
248 list_state: ListState,
249 contents: SidebarContents,
250 /// The index of the list item that currently has the keyboard focus
251 ///
252 /// Note: This is NOT the same as the active item.
253 selection: Option<usize>,
254 focused_thread: Option<acp::SessionId>,
255 active_entry_index: Option<usize>,
256 collapsed_groups: HashSet<PathList>,
257 expanded_groups: HashMap<PathList, usize>,
258 view: SidebarView,
259 archive_view: Option<Entity<ThreadsArchiveView>>,
260 _subscriptions: Vec<gpui::Subscription>,
261}
262
263impl Sidebar {
264 pub fn new(
265 multi_workspace: Entity<MultiWorkspace>,
266 window: &mut Window,
267 cx: &mut Context<Self>,
268 ) -> Self {
269 let focus_handle = cx.focus_handle();
270 cx.on_focus_in(&focus_handle, window, Self::focus_in)
271 .detach();
272
273 let filter_editor = cx.new(|cx| {
274 let mut editor = Editor::single_line(window, cx);
275 editor.set_placeholder_text("Search…", window, cx);
276 editor
277 });
278
279 cx.subscribe_in(
280 &multi_workspace,
281 window,
282 |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
283 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
284 this.update_entries(cx);
285 }
286 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
287 this.subscribe_to_workspace(workspace, window, cx);
288 this.update_entries(cx);
289 }
290 MultiWorkspaceEvent::WorkspaceRemoved(_) => {
291 this.update_entries(cx);
292 }
293 },
294 )
295 .detach();
296
297 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
298 if let editor::EditorEvent::BufferEdited = event {
299 let query = this.filter_editor.read(cx).text(cx);
300 if !query.is_empty() {
301 this.selection.take();
302 }
303 this.update_entries(cx);
304 if !query.is_empty() {
305 this.selection = this
306 .contents
307 .entries
308 .iter()
309 .position(|entry| matches!(entry, ListEntry::Thread(_)))
310 .or_else(|| {
311 if this.contents.entries.is_empty() {
312 None
313 } else {
314 Some(0)
315 }
316 });
317 }
318 }
319 })
320 .detach();
321
322 let thread_store = ThreadStore::global(cx);
323 cx.observe_in(&thread_store, window, |this, _, _window, cx| {
324 this.update_entries(cx);
325 })
326 .detach();
327
328 cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
329 this.update_entries(cx);
330 })
331 .detach();
332
333 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
334 cx.defer_in(window, move |this, window, cx| {
335 for workspace in &workspaces {
336 this.subscribe_to_workspace(workspace, window, cx);
337 }
338 this.update_entries(cx);
339 });
340
341 let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0);
342 let is_open = persistence_key
343 .map(read_sidebar_open_state)
344 .unwrap_or(false);
345
346 Self {
347 multi_workspace: multi_workspace.downgrade(),
348 persistence_key,
349 is_open,
350 width: DEFAULT_WIDTH,
351 focus_handle,
352 filter_editor,
353 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
354 contents: SidebarContents::default(),
355 selection: None,
356 focused_thread: None,
357 active_entry_index: None,
358 collapsed_groups: HashSet::new(),
359 expanded_groups: HashMap::new(),
360 view: SidebarView::default(),
361 archive_view: None,
362 _subscriptions: Vec::new(),
363 }
364 }
365
366 fn subscribe_to_workspace(
367 &self,
368 workspace: &Entity<Workspace>,
369 window: &mut Window,
370 cx: &mut Context<Self>,
371 ) {
372 let project = workspace.read(cx).project().clone();
373 cx.subscribe_in(
374 &project,
375 window,
376 |this, _project, event, _window, cx| match event {
377 ProjectEvent::WorktreeAdded(_)
378 | ProjectEvent::WorktreeRemoved(_)
379 | ProjectEvent::WorktreeOrderChanged => {
380 this.update_entries(cx);
381 }
382 _ => {}
383 },
384 )
385 .detach();
386
387 let git_store = workspace.read(cx).project().read(cx).git_store().clone();
388 cx.subscribe_in(
389 &git_store,
390 window,
391 |this, _, event: &project::git_store::GitStoreEvent, window, cx| {
392 if matches!(
393 event,
394 project::git_store::GitStoreEvent::RepositoryUpdated(
395 _,
396 project::git_store::RepositoryEvent::GitWorktreeListChanged,
397 _,
398 )
399 ) {
400 this.prune_stale_worktree_workspaces(window, cx);
401 this.update_entries(cx);
402 }
403 },
404 )
405 .detach();
406
407 cx.subscribe_in(
408 workspace,
409 window,
410 |this, _workspace, event: &workspace::Event, window, cx| {
411 if let workspace::Event::PanelAdded(view) = event {
412 if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
413 this.subscribe_to_agent_panel(&agent_panel, window, cx);
414 }
415 }
416 },
417 )
418 .detach();
419
420 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
421 self.subscribe_to_agent_panel(&agent_panel, window, cx);
422 }
423 }
424
425 fn subscribe_to_agent_panel(
426 &self,
427 agent_panel: &Entity<AgentPanel>,
428 window: &mut Window,
429 cx: &mut Context<Self>,
430 ) {
431 cx.subscribe_in(
432 agent_panel,
433 window,
434 |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
435 AgentPanelEvent::ActiveViewChanged
436 | AgentPanelEvent::ThreadFocused
437 | AgentPanelEvent::BackgroundThreadChanged => {
438 this.update_entries(cx);
439 }
440 },
441 )
442 .detach();
443 }
444
445 fn all_thread_infos_for_workspace(
446 workspace: &Entity<Workspace>,
447 cx: &App,
448 ) -> Vec<ActiveThreadInfo> {
449 let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
450 return Vec::new();
451 };
452 let agent_panel_ref = agent_panel.read(cx);
453
454 agent_panel_ref
455 .parent_threads(cx)
456 .into_iter()
457 .map(|thread_view| {
458 let thread_view_ref = thread_view.read(cx);
459 let thread = thread_view_ref.thread.read(cx);
460
461 let icon = thread_view_ref.agent_icon;
462 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
463 let title = thread.title();
464 let session_id = thread.session_id().clone();
465 let is_background = agent_panel_ref.is_background_thread(&session_id);
466
467 let status = if thread.is_waiting_for_confirmation() {
468 AgentThreadStatus::WaitingForConfirmation
469 } else if thread.had_error() {
470 AgentThreadStatus::Error
471 } else {
472 match thread.status() {
473 ThreadStatus::Generating => AgentThreadStatus::Running,
474 ThreadStatus::Idle => AgentThreadStatus::Completed,
475 }
476 };
477
478 let diff_stats = thread.action_log().read(cx).diff_stats(cx);
479
480 ActiveThreadInfo {
481 session_id,
482 title,
483 status,
484 icon,
485 icon_from_external_svg,
486 is_background,
487 diff_stats,
488 }
489 })
490 .collect()
491 }
492
493 fn rebuild_contents(&mut self, cx: &App) {
494 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
495 return;
496 };
497 let mw = multi_workspace.read(cx);
498 let workspaces = mw.workspaces().to_vec();
499 let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
500
501 self.focused_thread = active_workspace
502 .as_ref()
503 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
504 .and_then(|panel| panel.read(cx).active_connection_view().cloned())
505 .and_then(|cv| cv.read(cx).parent_id(cx));
506
507 let thread_store = ThreadStore::try_global(cx);
508 let query = self.filter_editor.read(cx).text(cx);
509
510 let previous = mem::take(&mut self.contents);
511
512 let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
513 .entries
514 .iter()
515 .filter_map(|entry| match entry {
516 ListEntry::Thread(thread) if thread.is_live => {
517 Some((thread.session_info.session_id.clone(), thread.status))
518 }
519 _ => None,
520 })
521 .collect();
522
523 let mut entries = Vec::new();
524 let mut notified_threads = previous.notified_threads;
525 // Track all session IDs we add to entries so we can prune stale
526 // notifications without a separate pass at the end.
527 let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
528 // Compute active_entry_index inline during the build pass.
529 let mut active_entry_index: Option<usize> = None;
530
531 // Identify absorbed workspaces in a single pass. A workspace is
532 // "absorbed" when it points at a git worktree checkout whose main
533 // repo is open as another workspace — its threads appear under the
534 // main repo's header instead of getting their own.
535 let mut main_repo_workspace: HashMap<Arc<Path>, usize> = HashMap::new();
536 let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
537 let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString, Arc<Path>)>> = HashMap::new();
538 let mut absorbed_workspace_by_path: HashMap<Arc<Path>, usize> = HashMap::new();
539
540 for (i, workspace) in workspaces.iter().enumerate() {
541 for snapshot in root_repository_snapshots(workspace, cx) {
542 if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path {
543 main_repo_workspace
544 .entry(snapshot.work_directory_abs_path.clone())
545 .or_insert(i);
546 if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) {
547 for (ws_idx, name, ws_path) in waiting {
548 absorbed.insert(ws_idx, (i, name));
549 absorbed_workspace_by_path.insert(ws_path, ws_idx);
550 }
551 }
552 } else {
553 let name: SharedString = snapshot
554 .work_directory_abs_path
555 .file_name()
556 .unwrap_or_default()
557 .to_string_lossy()
558 .to_string()
559 .into();
560 if let Some(&main_idx) =
561 main_repo_workspace.get(&snapshot.original_repo_abs_path)
562 {
563 absorbed.insert(i, (main_idx, name));
564 absorbed_workspace_by_path
565 .insert(snapshot.work_directory_abs_path.clone(), i);
566 } else {
567 pending
568 .entry(snapshot.original_repo_abs_path.clone())
569 .or_default()
570 .push((i, name, snapshot.work_directory_abs_path.clone()));
571 }
572 }
573 }
574 }
575
576 for (ws_index, workspace) in workspaces.iter().enumerate() {
577 if absorbed.contains_key(&ws_index) {
578 continue;
579 }
580
581 let (path_list, label) = workspace_path_list_and_label(workspace, cx);
582
583 let is_collapsed = self.collapsed_groups.contains(&path_list);
584 let should_load_threads = !is_collapsed || !query.is_empty();
585
586 let mut threads: Vec<ThreadEntry> = Vec::new();
587
588 if should_load_threads {
589 let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
590
591 if let Some(ref thread_store) = thread_store {
592 for meta in thread_store.read(cx).threads_for_paths(&path_list) {
593 seen_session_ids.insert(meta.id.clone());
594 threads.push(ThreadEntry {
595 session_info: meta.into(),
596 icon: IconName::ZedAgent,
597 icon_from_external_svg: None,
598 status: AgentThreadStatus::default(),
599 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
600 is_live: false,
601 is_background: false,
602 highlight_positions: Vec::new(),
603 worktree_name: None,
604 worktree_highlight_positions: Vec::new(),
605 diff_stats: DiffStats::default(),
606 });
607 }
608 }
609
610 // Load threads from linked git worktrees of this workspace's repos.
611 if let Some(ref thread_store) = thread_store {
612 let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc<Path>)> =
613 Vec::new();
614 for snapshot in root_repository_snapshots(workspace, cx) {
615 if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
616 continue;
617 }
618 for git_worktree in snapshot.linked_worktrees() {
619 let name = git_worktree
620 .path
621 .file_name()
622 .unwrap_or_default()
623 .to_string_lossy()
624 .to_string();
625 linked_worktree_queries.push((
626 PathList::new(std::slice::from_ref(&git_worktree.path)),
627 name.into(),
628 Arc::from(git_worktree.path.as_path()),
629 ));
630 }
631 }
632
633 for (worktree_path_list, worktree_name, worktree_path) in
634 &linked_worktree_queries
635 {
636 let target_workspace =
637 match absorbed_workspace_by_path.get(worktree_path.as_ref()) {
638 Some(&idx) => ThreadEntryWorkspace::Open(workspaces[idx].clone()),
639 None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
640 };
641
642 for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) {
643 if !seen_session_ids.insert(meta.id.clone()) {
644 continue;
645 }
646 threads.push(ThreadEntry {
647 session_info: meta.into(),
648 icon: IconName::ZedAgent,
649 icon_from_external_svg: None,
650 status: AgentThreadStatus::default(),
651 workspace: target_workspace.clone(),
652 is_live: false,
653 is_background: false,
654 highlight_positions: Vec::new(),
655 worktree_name: Some(worktree_name.clone()),
656 worktree_highlight_positions: Vec::new(),
657 diff_stats: DiffStats::default(),
658 });
659 }
660 }
661 }
662
663 let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
664
665 if !live_infos.is_empty() {
666 let thread_index_by_session: HashMap<acp::SessionId, usize> = threads
667 .iter()
668 .enumerate()
669 .map(|(i, t)| (t.session_info.session_id.clone(), i))
670 .collect();
671
672 for info in &live_infos {
673 let Some(&idx) = thread_index_by_session.get(&info.session_id) else {
674 continue;
675 };
676
677 let thread = &mut threads[idx];
678 thread.session_info.title = Some(info.title.clone());
679 thread.status = info.status;
680 thread.icon = info.icon;
681 thread.icon_from_external_svg = info.icon_from_external_svg.clone();
682 thread.is_live = true;
683 thread.is_background = info.is_background;
684 thread.diff_stats = info.diff_stats;
685 }
686 }
687
688 // Update notification state for live threads in the same pass.
689 let is_active_workspace = active_workspace
690 .as_ref()
691 .is_some_and(|active| active == workspace);
692
693 for thread in &threads {
694 let session_id = &thread.session_info.session_id;
695 if thread.is_background && thread.status == AgentThreadStatus::Completed {
696 notified_threads.insert(session_id.clone());
697 } else if thread.status == AgentThreadStatus::Completed
698 && !is_active_workspace
699 && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
700 {
701 notified_threads.insert(session_id.clone());
702 }
703
704 if is_active_workspace && !thread.is_background {
705 notified_threads.remove(session_id);
706 }
707 }
708
709 // Sort by created_at (newest first), falling back to updated_at
710 // for threads without a created_at (e.g., ACP sessions).
711 threads.sort_by(|a, b| {
712 let a_time = a.session_info.created_at.or(a.session_info.updated_at);
713 let b_time = b.session_info.created_at.or(b.session_info.updated_at);
714 b_time.cmp(&a_time)
715 });
716 }
717
718 if !query.is_empty() {
719 let has_threads = !threads.is_empty();
720
721 let workspace_highlight_positions =
722 fuzzy_match_positions(&query, &label).unwrap_or_default();
723 let workspace_matched = !workspace_highlight_positions.is_empty();
724
725 let mut matched_threads: Vec<ThreadEntry> = Vec::new();
726 for mut thread in threads {
727 let title = thread
728 .session_info
729 .title
730 .as_ref()
731 .map(|s| s.as_ref())
732 .unwrap_or("");
733 if let Some(positions) = fuzzy_match_positions(&query, title) {
734 thread.highlight_positions = positions;
735 }
736 if let Some(worktree_name) = &thread.worktree_name {
737 if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
738 thread.worktree_highlight_positions = positions;
739 }
740 }
741 let worktree_matched = !thread.worktree_highlight_positions.is_empty();
742 if workspace_matched
743 || !thread.highlight_positions.is_empty()
744 || worktree_matched
745 {
746 matched_threads.push(thread);
747 }
748 }
749
750 if matched_threads.is_empty() && !workspace_matched {
751 continue;
752 }
753
754 if active_entry_index.is_none()
755 && self.focused_thread.is_none()
756 && active_workspace
757 .as_ref()
758 .is_some_and(|active| active == workspace)
759 {
760 active_entry_index = Some(entries.len());
761 }
762
763 entries.push(ListEntry::ProjectHeader {
764 path_list: path_list.clone(),
765 label,
766 workspace: workspace.clone(),
767 highlight_positions: workspace_highlight_positions,
768 has_threads,
769 });
770
771 // Track session IDs and compute active_entry_index as we add
772 // thread entries.
773 for thread in matched_threads {
774 current_session_ids.insert(thread.session_info.session_id.clone());
775 if active_entry_index.is_none() {
776 if let Some(focused) = &self.focused_thread {
777 if &thread.session_info.session_id == focused {
778 active_entry_index = Some(entries.len());
779 }
780 }
781 }
782 entries.push(thread.into());
783 }
784 } else {
785 let has_threads = !threads.is_empty();
786
787 // Check if this header is the active entry before pushing it.
788 if active_entry_index.is_none()
789 && self.focused_thread.is_none()
790 && active_workspace
791 .as_ref()
792 .is_some_and(|active| active == workspace)
793 {
794 active_entry_index = Some(entries.len());
795 }
796
797 entries.push(ListEntry::ProjectHeader {
798 path_list: path_list.clone(),
799 label,
800 workspace: workspace.clone(),
801 highlight_positions: Vec::new(),
802 has_threads,
803 });
804
805 if is_collapsed {
806 continue;
807 }
808
809 let total = threads.len();
810
811 let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
812 let threads_to_show =
813 DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
814 let count = threads_to_show.min(total);
815 let is_fully_expanded = count >= total;
816
817 // Track session IDs and compute active_entry_index as we add
818 // thread entries.
819 for thread in threads.into_iter().take(count) {
820 current_session_ids.insert(thread.session_info.session_id.clone());
821 if active_entry_index.is_none() {
822 if let Some(focused) = &self.focused_thread {
823 if &thread.session_info.session_id == focused {
824 active_entry_index = Some(entries.len());
825 }
826 }
827 }
828 entries.push(thread.into());
829 }
830
831 if total > DEFAULT_THREADS_SHOWN {
832 entries.push(ListEntry::ViewMore {
833 path_list: path_list.clone(),
834 remaining_count: total.saturating_sub(count),
835 is_fully_expanded,
836 });
837 }
838
839 if total == 0 {
840 entries.push(ListEntry::NewThread {
841 path_list: path_list.clone(),
842 workspace: workspace.clone(),
843 });
844 }
845 }
846 }
847
848 // Prune stale notifications using the session IDs we collected during
849 // the build pass (no extra scan needed).
850 notified_threads.retain(|id| current_session_ids.contains(id));
851
852 let project_header_indices = entries
853 .iter()
854 .enumerate()
855 .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i))
856 .collect();
857
858 self.active_entry_index = active_entry_index;
859 self.contents = SidebarContents {
860 entries,
861 notified_threads,
862 project_header_indices,
863 };
864 }
865
866 fn update_entries(&mut self, cx: &mut Context<Self>) {
867 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
868 return;
869 };
870 if !multi_workspace_enabled(cx) {
871 return;
872 }
873
874 let had_notifications = self.has_notifications(cx);
875
876 let scroll_position = self.list_state.logical_scroll_top();
877
878 self.rebuild_contents(cx);
879
880 self.list_state.reset(self.contents.entries.len());
881 self.list_state.scroll_to(scroll_position);
882
883 if had_notifications != self.has_notifications(cx) {
884 multi_workspace.update(cx, |_, cx| {
885 cx.notify();
886 });
887 }
888
889 cx.notify();
890 }
891
892 fn render_list_entry(
893 &mut self,
894 ix: usize,
895 window: &mut Window,
896 cx: &mut Context<Self>,
897 ) -> AnyElement {
898 let Some(entry) = self.contents.entries.get(ix) else {
899 return div().into_any_element();
900 };
901 let is_focused = self.focus_handle.is_focused(window)
902 || self.filter_editor.focus_handle(cx).is_focused(window);
903 // is_selected means the keyboard selector is here.
904 let is_selected = is_focused && self.selection == Some(ix);
905
906 let is_group_header_after_first =
907 ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
908
909 let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
910
911 let rendered = match entry {
912 ListEntry::ProjectHeader {
913 path_list,
914 label,
915 workspace,
916 highlight_positions,
917 has_threads,
918 } => self.render_project_header(
919 ix,
920 false,
921 path_list,
922 label,
923 workspace,
924 highlight_positions,
925 *has_threads,
926 is_selected,
927 docked_right,
928 cx,
929 ),
930 ListEntry::Thread(thread) => {
931 self.render_thread(ix, thread, is_selected, docked_right, cx)
932 }
933 ListEntry::ViewMore {
934 path_list,
935 remaining_count,
936 is_fully_expanded,
937 } => self.render_view_more(
938 ix,
939 path_list,
940 *remaining_count,
941 *is_fully_expanded,
942 is_selected,
943 cx,
944 ),
945 ListEntry::NewThread {
946 path_list,
947 workspace,
948 } => self.render_new_thread(ix, path_list, workspace, is_selected, cx),
949 };
950
951 if is_group_header_after_first {
952 v_flex()
953 .w_full()
954 .border_t_1()
955 .border_color(cx.theme().colors().border_variant)
956 .child(rendered)
957 .into_any_element()
958 } else {
959 rendered
960 }
961 }
962
963 fn render_project_header(
964 &self,
965 ix: usize,
966 is_sticky: bool,
967 path_list: &PathList,
968 label: &SharedString,
969 workspace: &Entity<Workspace>,
970 highlight_positions: &[usize],
971 has_threads: bool,
972 is_selected: bool,
973 docked_right: bool,
974 cx: &mut Context<Self>,
975 ) -> AnyElement {
976 let id_prefix = if is_sticky { "sticky-" } else { "" };
977 let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
978 let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
979 let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}"));
980
981 let is_collapsed = self.collapsed_groups.contains(path_list);
982 let disclosure_icon = if is_collapsed {
983 IconName::ChevronRight
984 } else {
985 IconName::ChevronDown
986 };
987 let workspace_for_new_thread = workspace.clone();
988 let workspace_for_remove = workspace.clone();
989 // let workspace_for_activate = workspace.clone();
990
991 let path_list_for_toggle = path_list.clone();
992 let path_list_for_collapse = path_list.clone();
993 let view_more_expanded = self.expanded_groups.contains_key(path_list);
994
995 let multi_workspace = self.multi_workspace.upgrade();
996 let workspace_count = multi_workspace
997 .as_ref()
998 .map_or(0, |mw| mw.read(cx).workspaces().len());
999 let is_active_workspace = self.focused_thread.is_none()
1000 && multi_workspace
1001 .as_ref()
1002 .is_some_and(|mw| mw.read(cx).workspace() == workspace);
1003
1004 let label = if highlight_positions.is_empty() {
1005 Label::new(label.clone())
1006 .size(LabelSize::Small)
1007 .color(Color::Muted)
1008 .into_any_element()
1009 } else {
1010 HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1011 .size(LabelSize::Small)
1012 .color(Color::Muted)
1013 .into_any_element()
1014 };
1015
1016 ListItem::new(id)
1017 .group_name(group_name)
1018 .toggle_state(is_active_workspace)
1019 .focused(is_selected)
1020 .docked_right(docked_right)
1021 .child(
1022 h_flex()
1023 .relative()
1024 .min_w_0()
1025 .w_full()
1026 .py_1()
1027 .gap_1p5()
1028 .child(
1029 Icon::new(disclosure_icon)
1030 .size(IconSize::Small)
1031 .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
1032 )
1033 .child(label),
1034 )
1035 .end_hover_gradient_overlay(true)
1036 .end_hover_slot(
1037 h_flex()
1038 .when(workspace_count > 1, |this| {
1039 this.child(
1040 IconButton::new(
1041 SharedString::from(format!(
1042 "{id_prefix}project-header-remove-{ix}",
1043 )),
1044 IconName::Close,
1045 )
1046 .icon_size(IconSize::Small)
1047 .icon_color(Color::Muted)
1048 .tooltip(Tooltip::text("Remove Project"))
1049 .on_click(cx.listener(
1050 move |this, _, window, cx| {
1051 this.remove_workspace(&workspace_for_remove, window, cx);
1052 },
1053 )),
1054 )
1055 })
1056 .when(view_more_expanded && !is_collapsed, |this| {
1057 this.child(
1058 IconButton::new(
1059 SharedString::from(format!(
1060 "{id_prefix}project-header-collapse-{ix}",
1061 )),
1062 IconName::ListCollapse,
1063 )
1064 .icon_size(IconSize::Small)
1065 .icon_color(Color::Muted)
1066 .tooltip(Tooltip::text("Collapse Displayed Threads"))
1067 .on_click(cx.listener({
1068 let path_list_for_collapse = path_list_for_collapse.clone();
1069 move |this, _, _window, cx| {
1070 this.selection = None;
1071 this.expanded_groups.remove(&path_list_for_collapse);
1072 this.update_entries(cx);
1073 }
1074 })),
1075 )
1076 })
1077 .when(has_threads, |this| {
1078 this.child(
1079 IconButton::new(ib_id, IconName::NewThread)
1080 .icon_size(IconSize::Small)
1081 .icon_color(Color::Muted)
1082 .tooltip(Tooltip::text("New Thread"))
1083 .on_click(cx.listener(move |this, _, window, cx| {
1084 this.selection = None;
1085 this.create_new_thread(&workspace_for_new_thread, window, cx);
1086 })),
1087 )
1088 }),
1089 )
1090 .on_click(cx.listener(move |this, _, window, cx| {
1091 this.selection = None;
1092 this.toggle_collapse(&path_list_for_toggle, window, cx);
1093 }))
1094 // TODO: Decide if we really want the header to be activating different workspaces
1095 // .on_click(cx.listener(move |this, _, window, cx| {
1096 // this.selection = None;
1097 // this.activate_workspace(&workspace_for_activate, window, cx);
1098 // }))
1099 .into_any_element()
1100 }
1101
1102 fn render_sticky_header(
1103 &self,
1104 docked_right: bool,
1105 window: &mut Window,
1106 cx: &mut Context<Self>,
1107 ) -> Option<AnyElement> {
1108 let scroll_top = self.list_state.logical_scroll_top();
1109
1110 let &header_idx = self
1111 .contents
1112 .project_header_indices
1113 .iter()
1114 .rev()
1115 .find(|&&idx| idx <= scroll_top.item_ix)?;
1116
1117 let needs_sticky = header_idx < scroll_top.item_ix
1118 || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1119
1120 if !needs_sticky {
1121 return None;
1122 }
1123
1124 let ListEntry::ProjectHeader {
1125 path_list,
1126 label,
1127 workspace,
1128 highlight_positions,
1129 has_threads,
1130 } = self.contents.entries.get(header_idx)?
1131 else {
1132 return None;
1133 };
1134
1135 let is_focused = self.focus_handle.is_focused(window)
1136 || self.filter_editor.focus_handle(cx).is_focused(window);
1137 let is_selected = is_focused && self.selection == Some(header_idx);
1138
1139 let header_element = self.render_project_header(
1140 header_idx,
1141 true,
1142 &path_list,
1143 &label,
1144 &workspace,
1145 &highlight_positions,
1146 *has_threads,
1147 is_selected,
1148 docked_right,
1149 cx,
1150 );
1151
1152 let top_offset = self
1153 .contents
1154 .project_header_indices
1155 .iter()
1156 .find(|&&idx| idx > header_idx)
1157 .and_then(|&next_idx| {
1158 let bounds = self.list_state.bounds_for_item(next_idx)?;
1159 let viewport = self.list_state.viewport_bounds();
1160 let y_in_viewport = bounds.origin.y - viewport.origin.y;
1161 let header_height = bounds.size.height;
1162 (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1163 })
1164 .unwrap_or(px(0.));
1165
1166 let element = v_flex()
1167 .absolute()
1168 .top(top_offset)
1169 .left_0()
1170 .w_full()
1171 .bg(cx.theme().colors().surface_background)
1172 .border_b_1()
1173 .border_color(cx.theme().colors().border_variant)
1174 .child(header_element)
1175 .into_any_element();
1176
1177 Some(element)
1178 }
1179
1180 fn activate_workspace(
1181 &mut self,
1182 workspace: &Entity<Workspace>,
1183 window: &mut Window,
1184 cx: &mut Context<Self>,
1185 ) {
1186 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1187 return;
1188 };
1189
1190 multi_workspace.update(cx, |multi_workspace, cx| {
1191 multi_workspace.activate(workspace.clone(), cx);
1192 });
1193
1194 multi_workspace.update(cx, |multi_workspace, cx| {
1195 multi_workspace.focus_active_workspace(window, cx);
1196 });
1197 }
1198
1199 fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1200 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1201 return;
1202 };
1203 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
1204
1205 // Collect all worktree paths that are currently listed by any main
1206 // repo open in any workspace.
1207 let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
1208 for workspace in &workspaces {
1209 for snapshot in root_repository_snapshots(workspace, cx) {
1210 if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
1211 continue;
1212 }
1213 for git_worktree in snapshot.linked_worktrees() {
1214 known_worktree_paths.insert(git_worktree.path.to_path_buf());
1215 }
1216 }
1217 }
1218
1219 // Find workspaces that consist of exactly one root folder which is a
1220 // stale worktree checkout. Multi-root workspaces are never pruned —
1221 // losing one worktree shouldn't destroy a workspace that also
1222 // contains other folders.
1223 let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
1224 for workspace in &workspaces {
1225 let (path_list, _) = workspace_path_list_and_label(workspace, cx);
1226 if path_list.paths().len() != 1 {
1227 continue;
1228 }
1229 let should_prune = root_repository_snapshots(workspace, cx)
1230 .iter()
1231 .any(|snapshot| {
1232 snapshot.work_directory_abs_path != snapshot.original_repo_abs_path
1233 && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
1234 });
1235 if should_prune {
1236 to_remove.push(workspace.clone());
1237 }
1238 }
1239
1240 for workspace in &to_remove {
1241 self.remove_workspace(workspace, window, cx);
1242 }
1243 }
1244
1245 fn remove_workspace(
1246 &mut self,
1247 workspace: &Entity<Workspace>,
1248 window: &mut Window,
1249 cx: &mut Context<Self>,
1250 ) {
1251 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1252 return;
1253 };
1254
1255 multi_workspace.update(cx, |multi_workspace, cx| {
1256 let Some(index) = multi_workspace
1257 .workspaces()
1258 .iter()
1259 .position(|w| w == workspace)
1260 else {
1261 return;
1262 };
1263 multi_workspace.remove_workspace(index, window, cx);
1264 });
1265 }
1266
1267 fn toggle_collapse(
1268 &mut self,
1269 path_list: &PathList,
1270 _window: &mut Window,
1271 cx: &mut Context<Self>,
1272 ) {
1273 if self.collapsed_groups.contains(path_list) {
1274 self.collapsed_groups.remove(path_list);
1275 } else {
1276 self.collapsed_groups.insert(path_list.clone());
1277 }
1278 self.update_entries(cx);
1279 }
1280
1281 fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
1282
1283 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1284 if self.reset_filter_editor_text(window, cx) {
1285 self.update_entries(cx);
1286 } else {
1287 self.focus_handle.focus(window, cx);
1288 }
1289 }
1290
1291 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1292 self.filter_editor.update(cx, |editor, cx| {
1293 if editor.buffer().read(cx).len(cx).0 > 0 {
1294 editor.set_text("", window, cx);
1295 true
1296 } else {
1297 false
1298 }
1299 })
1300 }
1301
1302 fn has_filter_query(&self, cx: &App) -> bool {
1303 !self.filter_editor.read(cx).text(cx).is_empty()
1304 }
1305
1306 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1307 self.select_next(&SelectNext, window, cx);
1308 }
1309
1310 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1311 self.select_previous(&SelectPrevious, window, cx);
1312 }
1313
1314 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1315 let next = match self.selection {
1316 Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1317 None if !self.contents.entries.is_empty() => 0,
1318 _ => return,
1319 };
1320 self.selection = Some(next);
1321 self.list_state.scroll_to_reveal_item(next);
1322 cx.notify();
1323 }
1324
1325 fn select_previous(
1326 &mut self,
1327 _: &SelectPrevious,
1328 _window: &mut Window,
1329 cx: &mut Context<Self>,
1330 ) {
1331 let prev = match self.selection {
1332 Some(ix) if ix > 0 => ix - 1,
1333 None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1,
1334 _ => return,
1335 };
1336 self.selection = Some(prev);
1337 self.list_state.scroll_to_reveal_item(prev);
1338 cx.notify();
1339 }
1340
1341 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1342 if !self.contents.entries.is_empty() {
1343 self.selection = Some(0);
1344 self.list_state.scroll_to_reveal_item(0);
1345 cx.notify();
1346 }
1347 }
1348
1349 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1350 if let Some(last) = self.contents.entries.len().checked_sub(1) {
1351 self.selection = Some(last);
1352 self.list_state.scroll_to_reveal_item(last);
1353 cx.notify();
1354 }
1355 }
1356
1357 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1358 let Some(ix) = self.selection else { return };
1359 let Some(entry) = self.contents.entries.get(ix) else {
1360 return;
1361 };
1362
1363 match entry {
1364 ListEntry::ProjectHeader { workspace, .. } => {
1365 let workspace = workspace.clone();
1366 self.activate_workspace(&workspace, window, cx);
1367 }
1368 ListEntry::Thread(thread) => {
1369 let session_info = thread.session_info.clone();
1370 match &thread.workspace {
1371 ThreadEntryWorkspace::Open(workspace) => {
1372 let workspace = workspace.clone();
1373 self.activate_thread(session_info, &workspace, window, cx);
1374 }
1375 ThreadEntryWorkspace::Closed(path_list) => {
1376 self.open_workspace_and_activate_thread(
1377 session_info,
1378 path_list.clone(),
1379 window,
1380 cx,
1381 );
1382 }
1383 }
1384 }
1385 ListEntry::ViewMore {
1386 path_list,
1387 is_fully_expanded,
1388 ..
1389 } => {
1390 let path_list = path_list.clone();
1391 if *is_fully_expanded {
1392 self.expanded_groups.remove(&path_list);
1393 } else {
1394 let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1395 self.expanded_groups.insert(path_list, current + 1);
1396 }
1397 self.update_entries(cx);
1398 }
1399 ListEntry::NewThread { workspace, .. } => {
1400 let workspace = workspace.clone();
1401 self.create_new_thread(&workspace, window, cx);
1402 }
1403 }
1404 }
1405
1406 fn activate_thread(
1407 &mut self,
1408 session_info: acp_thread::AgentSessionInfo,
1409 workspace: &Entity<Workspace>,
1410 window: &mut Window,
1411 cx: &mut Context<Self>,
1412 ) {
1413 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1414 return;
1415 };
1416
1417 multi_workspace.update(cx, |multi_workspace, cx| {
1418 multi_workspace.activate(workspace.clone(), cx);
1419 });
1420
1421 workspace.update(cx, |workspace, cx| {
1422 workspace.open_panel::<AgentPanel>(window, cx);
1423 });
1424
1425 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1426 agent_panel.update(cx, |panel, cx| {
1427 panel.load_agent_thread(
1428 session_info.session_id,
1429 session_info.cwd,
1430 session_info.title,
1431 window,
1432 cx,
1433 );
1434 });
1435 }
1436 }
1437
1438 fn open_workspace_and_activate_thread(
1439 &mut self,
1440 session_info: acp_thread::AgentSessionInfo,
1441 path_list: PathList,
1442 window: &mut Window,
1443 cx: &mut Context<Self>,
1444 ) {
1445 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1446 return;
1447 };
1448
1449 let paths: Vec<std::path::PathBuf> =
1450 path_list.paths().iter().map(|p| p.to_path_buf()).collect();
1451
1452 let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
1453
1454 cx.spawn_in(window, async move |this, cx| {
1455 let workspace = open_task.await?;
1456 this.update_in(cx, |this, window, cx| {
1457 this.activate_thread(session_info, &workspace, window, cx);
1458 })?;
1459 anyhow::Ok(())
1460 })
1461 .detach_and_log_err(cx);
1462 }
1463
1464 fn expand_selected_entry(
1465 &mut self,
1466 _: &ExpandSelectedEntry,
1467 _window: &mut Window,
1468 cx: &mut Context<Self>,
1469 ) {
1470 let Some(ix) = self.selection else { return };
1471
1472 match self.contents.entries.get(ix) {
1473 Some(ListEntry::ProjectHeader { path_list, .. }) => {
1474 if self.collapsed_groups.contains(path_list) {
1475 let path_list = path_list.clone();
1476 self.collapsed_groups.remove(&path_list);
1477 self.update_entries(cx);
1478 } else if ix + 1 < self.contents.entries.len() {
1479 self.selection = Some(ix + 1);
1480 self.list_state.scroll_to_reveal_item(ix + 1);
1481 cx.notify();
1482 }
1483 }
1484 _ => {}
1485 }
1486 }
1487
1488 fn collapse_selected_entry(
1489 &mut self,
1490 _: &CollapseSelectedEntry,
1491 _window: &mut Window,
1492 cx: &mut Context<Self>,
1493 ) {
1494 let Some(ix) = self.selection else { return };
1495
1496 match self.contents.entries.get(ix) {
1497 Some(ListEntry::ProjectHeader { path_list, .. }) => {
1498 if !self.collapsed_groups.contains(path_list) {
1499 let path_list = path_list.clone();
1500 self.collapsed_groups.insert(path_list);
1501 self.update_entries(cx);
1502 }
1503 }
1504 Some(
1505 ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
1506 ) => {
1507 for i in (0..ix).rev() {
1508 if let Some(ListEntry::ProjectHeader { path_list, .. }) =
1509 self.contents.entries.get(i)
1510 {
1511 let path_list = path_list.clone();
1512 self.selection = Some(i);
1513 self.collapsed_groups.insert(path_list);
1514 self.update_entries(cx);
1515 break;
1516 }
1517 }
1518 }
1519 None => {}
1520 }
1521 }
1522
1523 fn render_thread(
1524 &self,
1525 ix: usize,
1526 thread: &ThreadEntry,
1527 is_selected: bool,
1528 docked_right: bool,
1529 cx: &mut Context<Self>,
1530 ) -> AnyElement {
1531 let has_notification = self
1532 .contents
1533 .is_thread_notified(&thread.session_info.session_id);
1534
1535 let title: SharedString = thread
1536 .session_info
1537 .title
1538 .clone()
1539 .unwrap_or_else(|| "Untitled".into());
1540 let session_info = thread.session_info.clone();
1541 let thread_workspace = thread.workspace.clone();
1542
1543 let id = SharedString::from(format!("thread-entry-{}", ix));
1544
1545 let timestamp = thread
1546 .session_info
1547 .created_at
1548 .or(thread.session_info.updated_at)
1549 .map(|entry_time| {
1550 let now = Utc::now();
1551 let duration = now.signed_duration_since(entry_time);
1552
1553 let minutes = duration.num_minutes();
1554 let hours = duration.num_hours();
1555 let days = duration.num_days();
1556 let weeks = days / 7;
1557 let months = days / 30;
1558
1559 if minutes < 60 {
1560 format!("{}m", minutes.max(1))
1561 } else if hours < 24 {
1562 format!("{}h", hours)
1563 } else if weeks < 4 {
1564 format!("{}w", weeks.max(1))
1565 } else {
1566 format!("{}mo", months.max(1))
1567 }
1568 });
1569
1570 ThreadItem::new(id, title)
1571 .icon(thread.icon)
1572 .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
1573 this.custom_icon_from_external_svg(svg)
1574 })
1575 .when_some(thread.worktree_name.clone(), |this, name| {
1576 this.worktree(name)
1577 })
1578 .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
1579 .when_some(timestamp, |this, ts| this.timestamp(ts))
1580 .highlight_positions(thread.highlight_positions.to_vec())
1581 .status(thread.status)
1582 .notified(has_notification)
1583 .when(thread.diff_stats.lines_added > 0, |this| {
1584 this.added(thread.diff_stats.lines_added as usize)
1585 })
1586 .when(thread.diff_stats.lines_removed > 0, |this| {
1587 this.removed(thread.diff_stats.lines_removed as usize)
1588 })
1589 .selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
1590 .focused(is_selected)
1591 .docked_right(docked_right)
1592 .on_click(cx.listener(move |this, _, window, cx| {
1593 this.selection = None;
1594 match &thread_workspace {
1595 ThreadEntryWorkspace::Open(workspace) => {
1596 this.activate_thread(session_info.clone(), workspace, window, cx);
1597 }
1598 ThreadEntryWorkspace::Closed(path_list) => {
1599 this.open_workspace_and_activate_thread(
1600 session_info.clone(),
1601 path_list.clone(),
1602 window,
1603 cx,
1604 );
1605 }
1606 }
1607 }))
1608 .into_any_element()
1609 }
1610
1611 fn render_filter_input(&self) -> impl IntoElement {
1612 self.filter_editor.clone()
1613 }
1614
1615 fn render_view_more(
1616 &self,
1617 ix: usize,
1618 path_list: &PathList,
1619 remaining_count: usize,
1620 is_fully_expanded: bool,
1621 is_selected: bool,
1622 cx: &mut Context<Self>,
1623 ) -> AnyElement {
1624 let path_list = path_list.clone();
1625 let id = SharedString::from(format!("view-more-{}", ix));
1626
1627 let (icon, label) = if is_fully_expanded {
1628 (IconName::ListCollapse, "Collapse")
1629 } else {
1630 (IconName::Plus, "View More")
1631 };
1632
1633 ListItem::new(id)
1634 .focused(is_selected)
1635 .child(
1636 h_flex()
1637 .py_1()
1638 .gap_1p5()
1639 .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
1640 .child(Label::new(label).color(Color::Muted))
1641 .when(!is_fully_expanded, |this| {
1642 this.child(
1643 Label::new(format!("({})", remaining_count))
1644 .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
1645 )
1646 }),
1647 )
1648 .on_click(cx.listener(move |this, _, _window, cx| {
1649 this.selection = None;
1650 if is_fully_expanded {
1651 this.expanded_groups.remove(&path_list);
1652 } else {
1653 let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
1654 this.expanded_groups.insert(path_list.clone(), current + 1);
1655 }
1656 this.update_entries(cx);
1657 }))
1658 .into_any_element()
1659 }
1660
1661 fn create_new_thread(
1662 &mut self,
1663 workspace: &Entity<Workspace>,
1664 window: &mut Window,
1665 cx: &mut Context<Self>,
1666 ) {
1667 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1668 return;
1669 };
1670
1671 multi_workspace.update(cx, |multi_workspace, cx| {
1672 multi_workspace.activate(workspace.clone(), cx);
1673 });
1674
1675 workspace.update(cx, |workspace, cx| {
1676 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
1677 agent_panel.update(cx, |panel, cx| {
1678 panel.new_thread(&NewThread, window, cx);
1679 });
1680 }
1681 workspace.focus_panel::<AgentPanel>(window, cx);
1682 });
1683 }
1684
1685 fn render_new_thread(
1686 &self,
1687 ix: usize,
1688 _path_list: &PathList,
1689 workspace: &Entity<Workspace>,
1690 is_selected: bool,
1691 cx: &mut Context<Self>,
1692 ) -> AnyElement {
1693 let workspace = workspace.clone();
1694
1695 div()
1696 .w_full()
1697 .p_2()
1698 .pt_1p5()
1699 .child(
1700 Button::new(
1701 SharedString::from(format!("new-thread-btn-{}", ix)),
1702 "New Thread",
1703 )
1704 .full_width()
1705 .style(ButtonStyle::Outlined)
1706 .icon(IconName::Plus)
1707 .icon_color(Color::Muted)
1708 .icon_size(IconSize::Small)
1709 .icon_position(IconPosition::Start)
1710 .toggle_state(is_selected)
1711 .on_click(cx.listener(move |this, _, window, cx| {
1712 this.selection = None;
1713 this.create_new_thread(&workspace, window, cx);
1714 })),
1715 )
1716 .into_any_element()
1717 }
1718
1719 fn render_thread_list_header(
1720 &self,
1721 docked_right: bool,
1722 cx: &mut Context<Self>,
1723 ) -> impl IntoElement {
1724 let has_query = self.has_filter_query(cx);
1725
1726 h_flex()
1727 .h(Tab::container_height(cx))
1728 .flex_none()
1729 .gap_1p5()
1730 .border_b_1()
1731 .border_color(cx.theme().colors().border)
1732 .when(!docked_right, |this| {
1733 this.child(self.render_sidebar_toggle_button(false, cx))
1734 })
1735 .child(self.render_filter_input())
1736 .when(has_query, |this| {
1737 this.when(!docked_right, |this| this.pr_1p5()).child(
1738 IconButton::new("clear_filter", IconName::Close)
1739 .shape(IconButtonShape::Square)
1740 .tooltip(Tooltip::text("Clear Search"))
1741 .on_click(cx.listener(|this, _, window, cx| {
1742 this.reset_filter_editor_text(window, cx);
1743 this.update_entries(cx);
1744 })),
1745 )
1746 })
1747 .when(docked_right, |this| {
1748 this.pl_2()
1749 .pr_0p5()
1750 .child(self.render_sidebar_toggle_button(true, cx))
1751 })
1752 }
1753
1754 fn render_thread_list_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
1755 h_flex()
1756 .p_1p5()
1757 .border_t_1()
1758 .border_color(cx.theme().colors().border_variant)
1759 .child(
1760 Button::new("view-archive", "Archive")
1761 .full_width()
1762 .label_size(LabelSize::Small)
1763 .style(ButtonStyle::Outlined)
1764 .icon(IconName::Archive)
1765 .icon_color(Color::Muted)
1766 .icon_size(IconSize::XSmall)
1767 .icon_position(IconPosition::Start)
1768 .on_click(cx.listener(|this, _, window, cx| {
1769 this.show_archive(window, cx);
1770 })),
1771 )
1772 }
1773
1774 fn render_sidebar_toggle_button(
1775 &self,
1776 docked_right: bool,
1777 cx: &mut Context<Self>,
1778 ) -> impl IntoElement {
1779 let icon = if docked_right {
1780 IconName::ThreadsSidebarRightOpen
1781 } else {
1782 IconName::ThreadsSidebarLeftOpen
1783 };
1784
1785 h_flex()
1786 .h_full()
1787 .px_1()
1788 .map(|this| {
1789 if docked_right {
1790 this.pr_1p5().border_l_1()
1791 } else {
1792 this.border_r_1()
1793 }
1794 })
1795 .border_color(cx.theme().colors().border_variant)
1796 .child(
1797 IconButton::new("sidebar-close-toggle", icon)
1798 .icon_size(IconSize::Small)
1799 .tooltip(move |_, cx| {
1800 Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
1801 })
1802 .on_click(|_, window, cx| {
1803 window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
1804 }),
1805 )
1806 }
1807}
1808
1809impl Sidebar {
1810 pub fn is_open(&self) -> bool {
1811 self.is_open
1812 }
1813
1814 fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1815 let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
1816 w.read(cx)
1817 .workspaces()
1818 .get(w.read(cx).active_workspace_index())
1819 .cloned()
1820 }) else {
1821 return;
1822 };
1823
1824 let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
1825 return;
1826 };
1827
1828 let thread_store = agent_panel.read(cx).thread_store().clone();
1829 let fs = active_workspace.read(cx).project().read(cx).fs().clone();
1830 let agent_connection_store = agent_panel.read(cx).connection_store().clone();
1831 let agent_server_store = active_workspace
1832 .read(cx)
1833 .project()
1834 .read(cx)
1835 .agent_server_store()
1836 .clone();
1837
1838 let archive_view = cx.new(|cx| {
1839 ThreadsArchiveView::new(
1840 agent_connection_store,
1841 agent_server_store,
1842 thread_store,
1843 fs,
1844 window,
1845 cx,
1846 )
1847 });
1848 let subscription = cx.subscribe_in(
1849 &archive_view,
1850 window,
1851 |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
1852 ThreadsArchiveViewEvent::Close => {
1853 this.show_thread_list(window, cx);
1854 }
1855 ThreadsArchiveViewEvent::OpenThread(_session_info) => {
1856 //TODO: Actually open thread once we support it
1857 }
1858 },
1859 );
1860
1861 self._subscriptions.push(subscription);
1862 self.archive_view = Some(archive_view);
1863 self.view = SidebarView::Archive;
1864 cx.notify();
1865 }
1866
1867 fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1868 self.view = SidebarView::ThreadList;
1869 self.archive_view = None;
1870 self._subscriptions.clear();
1871 window.focus(&self.focus_handle, cx);
1872 cx.notify();
1873 }
1874
1875 pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
1876 if self.is_open == open {
1877 return;
1878 }
1879 self.is_open = open;
1880 cx.notify();
1881 if let Some(key) = self.persistence_key {
1882 let is_open = self.is_open;
1883 cx.background_spawn(async move {
1884 save_sidebar_open_state(key, is_open).await;
1885 })
1886 .detach();
1887 }
1888 }
1889
1890 pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1891 let new_state = !self.is_open;
1892 self.set_open(new_state, cx);
1893 if new_state {
1894 cx.focus_self(window);
1895 }
1896 }
1897
1898 pub fn focus_or_unfocus(
1899 &mut self,
1900 workspace: &mut Workspace,
1901 window: &mut Window,
1902 cx: &mut Context<Self>,
1903 ) {
1904 if self.is_open {
1905 let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
1906 if sidebar_is_focused {
1907 let active_pane = workspace.active_pane().clone();
1908 let pane_focus = active_pane.read(cx).focus_handle(cx);
1909 window.focus(&pane_focus, cx);
1910 } else {
1911 cx.focus_self(window);
1912 }
1913 } else {
1914 self.set_open(true, cx);
1915 cx.focus_self(window);
1916 }
1917 }
1918
1919 pub fn width(&self, _cx: &App) -> Pixels {
1920 self.width
1921 }
1922
1923 pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
1924 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
1925 cx.notify();
1926 }
1927
1928 pub fn has_notifications(&self, _cx: &App) -> bool {
1929 !self.contents.notified_threads.is_empty()
1930 }
1931}
1932
1933impl Focusable for Sidebar {
1934 fn focus_handle(&self, cx: &App) -> FocusHandle {
1935 self.filter_editor.focus_handle(cx)
1936 }
1937}
1938
1939impl Render for Sidebar {
1940 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1941 let ui_font = theme::setup_ui_font(window, cx);
1942 let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
1943 let sticky_header = self.render_sticky_header(docked_right, window, cx);
1944
1945 v_flex()
1946 .id("workspace-sidebar")
1947 .key_context("WorkspaceSidebar")
1948 .track_focus(&self.focus_handle)
1949 .on_action(cx.listener(Self::select_next))
1950 .on_action(cx.listener(Self::select_previous))
1951 .on_action(cx.listener(Self::editor_move_down))
1952 .on_action(cx.listener(Self::editor_move_up))
1953 .on_action(cx.listener(Self::select_first))
1954 .on_action(cx.listener(Self::select_last))
1955 .on_action(cx.listener(Self::confirm))
1956 .on_action(cx.listener(Self::expand_selected_entry))
1957 .on_action(cx.listener(Self::collapse_selected_entry))
1958 .on_action(cx.listener(Self::cancel))
1959 .font(ui_font)
1960 .size_full()
1961 .bg(cx.theme().colors().surface_background)
1962 .map(|this| match self.view {
1963 SidebarView::ThreadList => this
1964 .child(self.render_thread_list_header(docked_right, cx))
1965 .child(
1966 v_flex()
1967 .relative()
1968 .flex_1()
1969 .overflow_hidden()
1970 .child(
1971 list(
1972 self.list_state.clone(),
1973 cx.processor(Self::render_list_entry),
1974 )
1975 .flex_1()
1976 .size_full(),
1977 )
1978 .when_some(sticky_header, |this, header| this.child(header))
1979 .vertical_scrollbar_for(&self.list_state, window, cx),
1980 )
1981 .child(self.render_thread_list_footer(cx)),
1982 SidebarView::Archive => {
1983 if let Some(archive_view) = &self.archive_view {
1984 this.child(archive_view.clone())
1985 } else {
1986 this
1987 }
1988 }
1989 })
1990 }
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995 use super::*;
1996 use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
1997 use acp_thread::StubAgentConnection;
1998 use agent::ThreadStore;
1999 use assistant_text_thread::TextThreadStore;
2000 use chrono::DateTime;
2001 use feature_flags::FeatureFlagAppExt as _;
2002 use fs::FakeFs;
2003 use gpui::TestAppContext;
2004 use std::sync::Arc;
2005 use util::path_list::PathList;
2006
2007 fn init_test(cx: &mut TestAppContext) {
2008 crate::test_support::init_test(cx);
2009 cx.update(|cx| {
2010 cx.update_flags(false, vec!["agent-v2".into()]);
2011 ThreadStore::init_global(cx);
2012 language_model::LanguageModelRegistry::test(cx);
2013 prompt_store::init(cx);
2014 });
2015 }
2016
2017 fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
2018 agent::DbThread {
2019 title: title.to_string().into(),
2020 messages: Vec::new(),
2021 updated_at,
2022 detailed_summary: None,
2023 initial_project_snapshot: None,
2024 cumulative_token_usage: Default::default(),
2025 request_token_usage: Default::default(),
2026 model: None,
2027 profile: None,
2028 imported: false,
2029 subagent_context: None,
2030 speed: None,
2031 thinking_enabled: false,
2032 thinking_effort: None,
2033 draft_prompt: None,
2034 ui_scroll_position: None,
2035 }
2036 }
2037
2038 async fn init_test_project(
2039 worktree_path: &str,
2040 cx: &mut TestAppContext,
2041 ) -> Entity<project::Project> {
2042 init_test(cx);
2043 let fs = FakeFs::new(cx.executor());
2044 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
2045 .await;
2046 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2047 project::Project::test(fs, [worktree_path.as_ref()], cx).await
2048 }
2049
2050 fn setup_sidebar(
2051 multi_workspace: &Entity<MultiWorkspace>,
2052 cx: &mut gpui::VisualTestContext,
2053 ) -> Entity<Sidebar> {
2054 let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
2055 sidebar
2056 }
2057
2058 fn setup_sidebar_with_agent_panel(
2059 multi_workspace: &Entity<MultiWorkspace>,
2060 cx: &mut gpui::VisualTestContext,
2061 ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
2062 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2063 let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
2064 let panel = add_agent_panel(&workspace, &project, cx);
2065 workspace.update_in(cx, |workspace, window, cx| {
2066 workspace.right_dock().update(cx, |dock, cx| {
2067 if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
2068 dock.activate_panel(panel_ix, window, cx);
2069 }
2070 dock.set_open(true, window, cx);
2071 });
2072 });
2073 cx.run_until_parked();
2074 let sidebar = panel.read_with(cx, |panel, _cx| {
2075 panel
2076 .sidebar
2077 .clone()
2078 .expect("AgentPanel should have created a sidebar")
2079 });
2080 (sidebar, panel)
2081 }
2082
2083 async fn save_n_test_threads(
2084 count: u32,
2085 path_list: &PathList,
2086 cx: &mut gpui::VisualTestContext,
2087 ) {
2088 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2089 for i in 0..count {
2090 let save_task = thread_store.update(cx, |store, cx| {
2091 store.save_thread(
2092 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
2093 make_test_thread(
2094 &format!("Thread {}", i + 1),
2095 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
2096 ),
2097 path_list.clone(),
2098 cx,
2099 )
2100 });
2101 save_task.await.unwrap();
2102 }
2103 cx.run_until_parked();
2104 }
2105
2106 async fn save_thread_to_store(
2107 session_id: &acp::SessionId,
2108 path_list: &PathList,
2109 cx: &mut gpui::VisualTestContext,
2110 ) {
2111 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2112 let save_task = thread_store.update(cx, |store, cx| {
2113 store.save_thread(
2114 session_id.clone(),
2115 make_test_thread(
2116 "Test",
2117 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2118 ),
2119 path_list.clone(),
2120 cx,
2121 )
2122 });
2123 save_task.await.unwrap();
2124 cx.run_until_parked();
2125 }
2126
2127 fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
2128 cx.run_until_parked();
2129 sidebar.update_in(cx, |sidebar, window, cx| {
2130 sidebar.set_open(true, cx);
2131 cx.focus_self(window);
2132 });
2133 cx.run_until_parked();
2134 }
2135
2136 fn visible_entries_as_strings(
2137 sidebar: &Entity<Sidebar>,
2138 cx: &mut gpui::VisualTestContext,
2139 ) -> Vec<String> {
2140 sidebar.read_with(cx, |sidebar, _cx| {
2141 sidebar
2142 .contents
2143 .entries
2144 .iter()
2145 .enumerate()
2146 .map(|(ix, entry)| {
2147 let selected = if sidebar.selection == Some(ix) {
2148 " <== selected"
2149 } else {
2150 ""
2151 };
2152 match entry {
2153 ListEntry::ProjectHeader {
2154 label,
2155 path_list,
2156 highlight_positions: _,
2157 ..
2158 } => {
2159 let icon = if sidebar.collapsed_groups.contains(path_list) {
2160 ">"
2161 } else {
2162 "v"
2163 };
2164 format!("{} [{}]{}", icon, label, selected)
2165 }
2166 ListEntry::Thread(thread) => {
2167 let title = thread
2168 .session_info
2169 .title
2170 .as_ref()
2171 .map(|s| s.as_ref())
2172 .unwrap_or("Untitled");
2173 let active = if thread.is_live { " *" } else { "" };
2174 let status_str = match thread.status {
2175 AgentThreadStatus::Running => " (running)",
2176 AgentThreadStatus::Error => " (error)",
2177 AgentThreadStatus::WaitingForConfirmation => " (waiting)",
2178 _ => "",
2179 };
2180 let notified = if sidebar
2181 .contents
2182 .is_thread_notified(&thread.session_info.session_id)
2183 {
2184 " (!)"
2185 } else {
2186 ""
2187 };
2188 let worktree = thread
2189 .worktree_name
2190 .as_ref()
2191 .map(|name| format!(" {{{}}}", name))
2192 .unwrap_or_default();
2193 format!(
2194 " {}{}{}{}{}{}",
2195 title, worktree, active, status_str, notified, selected
2196 )
2197 }
2198 ListEntry::ViewMore {
2199 remaining_count,
2200 is_fully_expanded,
2201 ..
2202 } => {
2203 if *is_fully_expanded {
2204 format!(" - Collapse{}", selected)
2205 } else {
2206 format!(" + View More ({}){}", remaining_count, selected)
2207 }
2208 }
2209 ListEntry::NewThread { .. } => {
2210 format!(" [+ New Thread]{}", selected)
2211 }
2212 }
2213 })
2214 .collect()
2215 })
2216 }
2217
2218 #[gpui::test]
2219 async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
2220 let project = init_test_project("/my-project", cx).await;
2221 let (multi_workspace, cx) =
2222 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2223 let sidebar = setup_sidebar(&multi_workspace, cx);
2224
2225 assert_eq!(
2226 visible_entries_as_strings(&sidebar, cx),
2227 vec!["v [my-project]", " [+ New Thread]"]
2228 );
2229 }
2230
2231 #[gpui::test]
2232 async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
2233 let project = init_test_project("/my-project", cx).await;
2234 let (multi_workspace, cx) =
2235 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2236 let sidebar = setup_sidebar(&multi_workspace, cx);
2237
2238 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2239 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2240
2241 let save_task = thread_store.update(cx, |store, cx| {
2242 store.save_thread(
2243 acp::SessionId::new(Arc::from("thread-1")),
2244 make_test_thread(
2245 "Fix crash in project panel",
2246 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
2247 ),
2248 path_list.clone(),
2249 cx,
2250 )
2251 });
2252 save_task.await.unwrap();
2253
2254 let save_task = thread_store.update(cx, |store, cx| {
2255 store.save_thread(
2256 acp::SessionId::new(Arc::from("thread-2")),
2257 make_test_thread(
2258 "Add inline diff view",
2259 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2260 ),
2261 path_list.clone(),
2262 cx,
2263 )
2264 });
2265 save_task.await.unwrap();
2266 cx.run_until_parked();
2267
2268 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2269 cx.run_until_parked();
2270
2271 assert_eq!(
2272 visible_entries_as_strings(&sidebar, cx),
2273 vec![
2274 "v [my-project]",
2275 " Fix crash in project panel",
2276 " Add inline diff view",
2277 ]
2278 );
2279 }
2280
2281 #[gpui::test]
2282 async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
2283 let project = init_test_project("/project-a", cx).await;
2284 let (multi_workspace, cx) =
2285 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2286 let sidebar = setup_sidebar(&multi_workspace, cx);
2287
2288 // Single workspace with a thread
2289 let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
2290 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2291
2292 let save_task = thread_store.update(cx, |store, cx| {
2293 store.save_thread(
2294 acp::SessionId::new(Arc::from("thread-a1")),
2295 make_test_thread(
2296 "Thread A1",
2297 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2298 ),
2299 path_list.clone(),
2300 cx,
2301 )
2302 });
2303 save_task.await.unwrap();
2304 cx.run_until_parked();
2305
2306 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2307 cx.run_until_parked();
2308
2309 assert_eq!(
2310 visible_entries_as_strings(&sidebar, cx),
2311 vec!["v [project-a]", " Thread A1"]
2312 );
2313
2314 // Add a second workspace
2315 multi_workspace.update_in(cx, |mw, window, cx| {
2316 mw.create_workspace(window, cx);
2317 });
2318 cx.run_until_parked();
2319
2320 assert_eq!(
2321 visible_entries_as_strings(&sidebar, cx),
2322 vec![
2323 "v [project-a]",
2324 " Thread A1",
2325 "v [Empty Workspace]",
2326 " [+ New Thread]"
2327 ]
2328 );
2329
2330 // Remove the second workspace
2331 multi_workspace.update_in(cx, |mw, window, cx| {
2332 mw.remove_workspace(1, window, cx);
2333 });
2334 cx.run_until_parked();
2335
2336 assert_eq!(
2337 visible_entries_as_strings(&sidebar, cx),
2338 vec!["v [project-a]", " Thread A1"]
2339 );
2340 }
2341
2342 #[gpui::test]
2343 async fn test_view_more_pagination(cx: &mut TestAppContext) {
2344 let project = init_test_project("/my-project", cx).await;
2345 let (multi_workspace, cx) =
2346 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2347 let sidebar = setup_sidebar(&multi_workspace, cx);
2348
2349 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2350 save_n_test_threads(12, &path_list, cx).await;
2351
2352 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2353 cx.run_until_parked();
2354
2355 assert_eq!(
2356 visible_entries_as_strings(&sidebar, cx),
2357 vec![
2358 "v [my-project]",
2359 " Thread 12",
2360 " Thread 11",
2361 " Thread 10",
2362 " Thread 9",
2363 " Thread 8",
2364 " + View More (7)",
2365 ]
2366 );
2367 }
2368
2369 #[gpui::test]
2370 async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
2371 let project = init_test_project("/my-project", cx).await;
2372 let (multi_workspace, cx) =
2373 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2374 let sidebar = setup_sidebar(&multi_workspace, cx);
2375
2376 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2377 // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
2378 save_n_test_threads(17, &path_list, cx).await;
2379
2380 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2381 cx.run_until_parked();
2382
2383 // Initially shows 5 threads + View More (12 remaining)
2384 let entries = visible_entries_as_strings(&sidebar, cx);
2385 assert_eq!(entries.len(), 7); // header + 5 threads + View More
2386 assert!(entries.iter().any(|e| e.contains("View More (12)")));
2387
2388 // Focus and navigate to View More, then confirm to expand by one batch
2389 open_and_focus_sidebar(&sidebar, cx);
2390 for _ in 0..7 {
2391 cx.dispatch_action(SelectNext);
2392 }
2393 cx.dispatch_action(Confirm);
2394 cx.run_until_parked();
2395
2396 // Now shows 10 threads + View More (7 remaining)
2397 let entries = visible_entries_as_strings(&sidebar, cx);
2398 assert_eq!(entries.len(), 12); // header + 10 threads + View More
2399 assert!(entries.iter().any(|e| e.contains("View More (7)")));
2400
2401 // Expand again by one batch
2402 sidebar.update_in(cx, |s, _window, cx| {
2403 let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
2404 s.expanded_groups.insert(path_list.clone(), current + 1);
2405 s.update_entries(cx);
2406 });
2407 cx.run_until_parked();
2408
2409 // Now shows 15 threads + View More (2 remaining)
2410 let entries = visible_entries_as_strings(&sidebar, cx);
2411 assert_eq!(entries.len(), 17); // header + 15 threads + View More
2412 assert!(entries.iter().any(|e| e.contains("View More (2)")));
2413
2414 // Expand one more time - should show all 17 threads with Collapse button
2415 sidebar.update_in(cx, |s, _window, cx| {
2416 let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
2417 s.expanded_groups.insert(path_list.clone(), current + 1);
2418 s.update_entries(cx);
2419 });
2420 cx.run_until_parked();
2421
2422 // All 17 threads shown with Collapse button
2423 let entries = visible_entries_as_strings(&sidebar, cx);
2424 assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
2425 assert!(!entries.iter().any(|e| e.contains("View More")));
2426 assert!(entries.iter().any(|e| e.contains("Collapse")));
2427
2428 // Click collapse - should go back to showing 5 threads
2429 sidebar.update_in(cx, |s, _window, cx| {
2430 s.expanded_groups.remove(&path_list);
2431 s.update_entries(cx);
2432 });
2433 cx.run_until_parked();
2434
2435 // Back to initial state: 5 threads + View More (12 remaining)
2436 let entries = visible_entries_as_strings(&sidebar, cx);
2437 assert_eq!(entries.len(), 7); // header + 5 threads + View More
2438 assert!(entries.iter().any(|e| e.contains("View More (12)")));
2439 }
2440
2441 #[gpui::test]
2442 async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
2443 let project = init_test_project("/my-project", cx).await;
2444 let (multi_workspace, cx) =
2445 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2446 let sidebar = setup_sidebar(&multi_workspace, cx);
2447
2448 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2449 save_n_test_threads(1, &path_list, cx).await;
2450
2451 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2452 cx.run_until_parked();
2453
2454 assert_eq!(
2455 visible_entries_as_strings(&sidebar, cx),
2456 vec!["v [my-project]", " Thread 1"]
2457 );
2458
2459 // Collapse
2460 sidebar.update_in(cx, |s, window, cx| {
2461 s.toggle_collapse(&path_list, window, cx);
2462 });
2463 cx.run_until_parked();
2464
2465 assert_eq!(
2466 visible_entries_as_strings(&sidebar, cx),
2467 vec!["> [my-project]"]
2468 );
2469
2470 // Expand
2471 sidebar.update_in(cx, |s, window, cx| {
2472 s.toggle_collapse(&path_list, window, cx);
2473 });
2474 cx.run_until_parked();
2475
2476 assert_eq!(
2477 visible_entries_as_strings(&sidebar, cx),
2478 vec!["v [my-project]", " Thread 1"]
2479 );
2480 }
2481
2482 #[gpui::test]
2483 async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
2484 let project = init_test_project("/my-project", cx).await;
2485 let (multi_workspace, cx) =
2486 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2487 let sidebar = setup_sidebar(&multi_workspace, cx);
2488
2489 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2490 let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
2491 let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
2492
2493 sidebar.update_in(cx, |s, _window, _cx| {
2494 s.collapsed_groups.insert(collapsed_path.clone());
2495 s.contents
2496 .notified_threads
2497 .insert(acp::SessionId::new(Arc::from("t-5")));
2498 s.contents.entries = vec![
2499 // Expanded project header
2500 ListEntry::ProjectHeader {
2501 path_list: expanded_path.clone(),
2502 label: "expanded-project".into(),
2503 workspace: workspace.clone(),
2504 highlight_positions: Vec::new(),
2505 has_threads: true,
2506 },
2507 // Thread with default (Completed) status, not active
2508 ListEntry::Thread(ThreadEntry {
2509 session_info: acp_thread::AgentSessionInfo {
2510 session_id: acp::SessionId::new(Arc::from("t-1")),
2511 cwd: None,
2512 title: Some("Completed thread".into()),
2513 updated_at: Some(Utc::now()),
2514 created_at: Some(Utc::now()),
2515 meta: None,
2516 },
2517 icon: IconName::ZedAgent,
2518 icon_from_external_svg: None,
2519 status: AgentThreadStatus::Completed,
2520 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2521 is_live: false,
2522 is_background: false,
2523 highlight_positions: Vec::new(),
2524 worktree_name: None,
2525 worktree_highlight_positions: Vec::new(),
2526 diff_stats: DiffStats::default(),
2527 }),
2528 // Active thread with Running status
2529 ListEntry::Thread(ThreadEntry {
2530 session_info: acp_thread::AgentSessionInfo {
2531 session_id: acp::SessionId::new(Arc::from("t-2")),
2532 cwd: None,
2533 title: Some("Running thread".into()),
2534 updated_at: Some(Utc::now()),
2535 created_at: Some(Utc::now()),
2536 meta: None,
2537 },
2538 icon: IconName::ZedAgent,
2539 icon_from_external_svg: None,
2540 status: AgentThreadStatus::Running,
2541 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2542 is_live: true,
2543 is_background: false,
2544 highlight_positions: Vec::new(),
2545 worktree_name: None,
2546 worktree_highlight_positions: Vec::new(),
2547 diff_stats: DiffStats::default(),
2548 }),
2549 // Active thread with Error status
2550 ListEntry::Thread(ThreadEntry {
2551 session_info: acp_thread::AgentSessionInfo {
2552 session_id: acp::SessionId::new(Arc::from("t-3")),
2553 cwd: None,
2554 title: Some("Error thread".into()),
2555 updated_at: Some(Utc::now()),
2556 created_at: Some(Utc::now()),
2557 meta: None,
2558 },
2559 icon: IconName::ZedAgent,
2560 icon_from_external_svg: None,
2561 status: AgentThreadStatus::Error,
2562 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2563 is_live: true,
2564 is_background: false,
2565 highlight_positions: Vec::new(),
2566 worktree_name: None,
2567 worktree_highlight_positions: Vec::new(),
2568 diff_stats: DiffStats::default(),
2569 }),
2570 // Thread with WaitingForConfirmation status, not active
2571 ListEntry::Thread(ThreadEntry {
2572 session_info: acp_thread::AgentSessionInfo {
2573 session_id: acp::SessionId::new(Arc::from("t-4")),
2574 cwd: None,
2575 title: Some("Waiting thread".into()),
2576 updated_at: Some(Utc::now()),
2577 created_at: Some(Utc::now()),
2578 meta: None,
2579 },
2580 icon: IconName::ZedAgent,
2581 icon_from_external_svg: None,
2582 status: AgentThreadStatus::WaitingForConfirmation,
2583 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2584 is_live: false,
2585 is_background: false,
2586 highlight_positions: Vec::new(),
2587 worktree_name: None,
2588 worktree_highlight_positions: Vec::new(),
2589 diff_stats: DiffStats::default(),
2590 }),
2591 // Background thread that completed (should show notification)
2592 ListEntry::Thread(ThreadEntry {
2593 session_info: acp_thread::AgentSessionInfo {
2594 session_id: acp::SessionId::new(Arc::from("t-5")),
2595 cwd: None,
2596 title: Some("Notified thread".into()),
2597 updated_at: Some(Utc::now()),
2598 created_at: Some(Utc::now()),
2599 meta: None,
2600 },
2601 icon: IconName::ZedAgent,
2602 icon_from_external_svg: None,
2603 status: AgentThreadStatus::Completed,
2604 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2605 is_live: true,
2606 is_background: true,
2607 highlight_positions: Vec::new(),
2608 worktree_name: None,
2609 worktree_highlight_positions: Vec::new(),
2610 diff_stats: DiffStats::default(),
2611 }),
2612 // View More entry
2613 ListEntry::ViewMore {
2614 path_list: expanded_path.clone(),
2615 remaining_count: 42,
2616 is_fully_expanded: false,
2617 },
2618 // Collapsed project header
2619 ListEntry::ProjectHeader {
2620 path_list: collapsed_path.clone(),
2621 label: "collapsed-project".into(),
2622 workspace: workspace.clone(),
2623 highlight_positions: Vec::new(),
2624 has_threads: true,
2625 },
2626 ];
2627 // Select the Running thread (index 2)
2628 s.selection = Some(2);
2629 });
2630
2631 assert_eq!(
2632 visible_entries_as_strings(&sidebar, cx),
2633 vec![
2634 "v [expanded-project]",
2635 " Completed thread",
2636 " Running thread * (running) <== selected",
2637 " Error thread * (error)",
2638 " Waiting thread (waiting)",
2639 " Notified thread * (!)",
2640 " + View More (42)",
2641 "> [collapsed-project]",
2642 ]
2643 );
2644
2645 // Move selection to the collapsed header
2646 sidebar.update_in(cx, |s, _window, _cx| {
2647 s.selection = Some(7);
2648 });
2649
2650 assert_eq!(
2651 visible_entries_as_strings(&sidebar, cx).last().cloned(),
2652 Some("> [collapsed-project] <== selected".to_string()),
2653 );
2654
2655 // Clear selection
2656 sidebar.update_in(cx, |s, _window, _cx| {
2657 s.selection = None;
2658 });
2659
2660 // No entry should have the selected marker
2661 let entries = visible_entries_as_strings(&sidebar, cx);
2662 for entry in &entries {
2663 assert!(
2664 !entry.contains("<== selected"),
2665 "unexpected selection marker in: {}",
2666 entry
2667 );
2668 }
2669 }
2670
2671 #[gpui::test]
2672 async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
2673 let project = init_test_project("/my-project", cx).await;
2674 let (multi_workspace, cx) =
2675 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2676 let sidebar = setup_sidebar(&multi_workspace, cx);
2677
2678 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2679 save_n_test_threads(3, &path_list, cx).await;
2680
2681 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2682 cx.run_until_parked();
2683
2684 // Entries: [header, thread3, thread2, thread1]
2685 // Focusing the sidebar does not set a selection; select_next/select_previous
2686 // handle None gracefully by starting from the first or last entry.
2687 open_and_focus_sidebar(&sidebar, cx);
2688 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2689
2690 // First SelectNext from None starts at index 0
2691 cx.dispatch_action(SelectNext);
2692 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2693
2694 // Move down through remaining entries
2695 cx.dispatch_action(SelectNext);
2696 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2697
2698 cx.dispatch_action(SelectNext);
2699 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2700
2701 cx.dispatch_action(SelectNext);
2702 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2703
2704 // At the end, selection stays on the last entry
2705 cx.dispatch_action(SelectNext);
2706 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2707
2708 // Move back up
2709
2710 cx.dispatch_action(SelectPrevious);
2711 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2712
2713 cx.dispatch_action(SelectPrevious);
2714 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2715
2716 cx.dispatch_action(SelectPrevious);
2717 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2718
2719 // At the top, selection stays on the first entry
2720 cx.dispatch_action(SelectPrevious);
2721 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2722 }
2723
2724 #[gpui::test]
2725 async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
2726 let project = init_test_project("/my-project", cx).await;
2727 let (multi_workspace, cx) =
2728 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2729 let sidebar = setup_sidebar(&multi_workspace, cx);
2730
2731 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2732 save_n_test_threads(3, &path_list, cx).await;
2733 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2734 cx.run_until_parked();
2735
2736 open_and_focus_sidebar(&sidebar, cx);
2737
2738 // SelectLast jumps to the end
2739 cx.dispatch_action(SelectLast);
2740 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2741
2742 // SelectFirst jumps to the beginning
2743 cx.dispatch_action(SelectFirst);
2744 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2745 }
2746
2747 #[gpui::test]
2748 async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
2749 let project = init_test_project("/my-project", cx).await;
2750 let (multi_workspace, cx) =
2751 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2752 let sidebar = setup_sidebar(&multi_workspace, cx);
2753
2754 // Initially no selection
2755 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2756
2757 // Open the sidebar so it's rendered, then focus it to trigger focus_in.
2758 // focus_in no longer sets a default selection.
2759 open_and_focus_sidebar(&sidebar, cx);
2760 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2761
2762 // Manually set a selection, blur, then refocus — selection should be preserved
2763 sidebar.update_in(cx, |sidebar, _window, _cx| {
2764 sidebar.selection = Some(0);
2765 });
2766
2767 cx.update(|window, _cx| {
2768 window.blur();
2769 });
2770 cx.run_until_parked();
2771
2772 sidebar.update_in(cx, |_, window, cx| {
2773 cx.focus_self(window);
2774 });
2775 cx.run_until_parked();
2776 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2777 }
2778
2779 #[gpui::test]
2780 async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) {
2781 let project = init_test_project("/my-project", cx).await;
2782 let (multi_workspace, cx) =
2783 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2784 let sidebar = setup_sidebar(&multi_workspace, cx);
2785
2786 multi_workspace.update_in(cx, |mw, window, cx| {
2787 mw.create_workspace(window, cx);
2788 });
2789 cx.run_until_parked();
2790
2791 // Add an agent panel to workspace 1 so the sidebar renders when it's active.
2792 setup_sidebar_with_agent_panel(&multi_workspace, cx);
2793
2794 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2795 save_n_test_threads(1, &path_list, cx).await;
2796 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2797 cx.run_until_parked();
2798
2799 assert_eq!(
2800 visible_entries_as_strings(&sidebar, cx),
2801 vec![
2802 "v [my-project]",
2803 " Thread 1",
2804 "v [Empty Workspace]",
2805 " [+ New Thread]",
2806 ]
2807 );
2808
2809 // Switch to workspace 1 so we can verify confirm switches back.
2810 multi_workspace.update_in(cx, |mw, window, cx| {
2811 mw.activate_index(1, window, cx);
2812 });
2813 cx.run_until_parked();
2814 assert_eq!(
2815 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2816 1
2817 );
2818
2819 // Focus the sidebar and manually select the header (index 0)
2820 open_and_focus_sidebar(&sidebar, cx);
2821 sidebar.update_in(cx, |sidebar, _window, _cx| {
2822 sidebar.selection = Some(0);
2823 });
2824
2825 // Press confirm on project header (workspace 0) to activate it.
2826 cx.dispatch_action(Confirm);
2827 cx.run_until_parked();
2828
2829 assert_eq!(
2830 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2831 0
2832 );
2833
2834 // Focus should have moved out of the sidebar to the workspace center.
2835 let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
2836 workspace_0.update_in(cx, |workspace, window, cx| {
2837 let pane_focus = workspace.active_pane().read(cx).focus_handle(cx);
2838 assert!(
2839 pane_focus.contains_focused(window, cx),
2840 "Confirming a project header should focus the workspace center pane"
2841 );
2842 });
2843 }
2844
2845 #[gpui::test]
2846 async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
2847 let project = init_test_project("/my-project", cx).await;
2848 let (multi_workspace, cx) =
2849 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2850 let sidebar = setup_sidebar(&multi_workspace, cx);
2851
2852 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2853 save_n_test_threads(8, &path_list, cx).await;
2854 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2855 cx.run_until_parked();
2856
2857 // Should show header + 5 threads + "View More (3)"
2858 let entries = visible_entries_as_strings(&sidebar, cx);
2859 assert_eq!(entries.len(), 7);
2860 assert!(entries.iter().any(|e| e.contains("View More (3)")));
2861
2862 // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
2863 open_and_focus_sidebar(&sidebar, cx);
2864 for _ in 0..7 {
2865 cx.dispatch_action(SelectNext);
2866 }
2867 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
2868
2869 // Confirm on "View More" to expand
2870 cx.dispatch_action(Confirm);
2871 cx.run_until_parked();
2872
2873 // All 8 threads should now be visible with a "Collapse" button
2874 let entries = visible_entries_as_strings(&sidebar, cx);
2875 assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
2876 assert!(!entries.iter().any(|e| e.contains("View More")));
2877 assert!(entries.iter().any(|e| e.contains("Collapse")));
2878 }
2879
2880 #[gpui::test]
2881 async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
2882 let project = init_test_project("/my-project", cx).await;
2883 let (multi_workspace, cx) =
2884 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2885 let sidebar = setup_sidebar(&multi_workspace, cx);
2886
2887 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2888 save_n_test_threads(1, &path_list, cx).await;
2889 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2890 cx.run_until_parked();
2891
2892 assert_eq!(
2893 visible_entries_as_strings(&sidebar, cx),
2894 vec!["v [my-project]", " Thread 1"]
2895 );
2896
2897 // Focus sidebar and manually select the header (index 0). Press left to collapse.
2898 open_and_focus_sidebar(&sidebar, cx);
2899 sidebar.update_in(cx, |sidebar, _window, _cx| {
2900 sidebar.selection = Some(0);
2901 });
2902
2903 cx.dispatch_action(CollapseSelectedEntry);
2904 cx.run_until_parked();
2905
2906 assert_eq!(
2907 visible_entries_as_strings(&sidebar, cx),
2908 vec!["> [my-project] <== selected"]
2909 );
2910
2911 // Press right to expand
2912 cx.dispatch_action(ExpandSelectedEntry);
2913 cx.run_until_parked();
2914
2915 assert_eq!(
2916 visible_entries_as_strings(&sidebar, cx),
2917 vec!["v [my-project] <== selected", " Thread 1",]
2918 );
2919
2920 // Press right again on already-expanded header moves selection down
2921 cx.dispatch_action(ExpandSelectedEntry);
2922 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2923 }
2924
2925 #[gpui::test]
2926 async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
2927 let project = init_test_project("/my-project", cx).await;
2928 let (multi_workspace, cx) =
2929 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2930 let sidebar = setup_sidebar(&multi_workspace, cx);
2931
2932 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2933 save_n_test_threads(1, &path_list, cx).await;
2934 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2935 cx.run_until_parked();
2936
2937 // Focus sidebar (selection starts at None), then navigate down to the thread (child)
2938 open_and_focus_sidebar(&sidebar, cx);
2939 cx.dispatch_action(SelectNext);
2940 cx.dispatch_action(SelectNext);
2941 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2942
2943 assert_eq!(
2944 visible_entries_as_strings(&sidebar, cx),
2945 vec!["v [my-project]", " Thread 1 <== selected",]
2946 );
2947
2948 // Pressing left on a child collapses the parent group and selects it
2949 cx.dispatch_action(CollapseSelectedEntry);
2950 cx.run_until_parked();
2951
2952 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2953 assert_eq!(
2954 visible_entries_as_strings(&sidebar, cx),
2955 vec!["> [my-project] <== selected"]
2956 );
2957 }
2958
2959 #[gpui::test]
2960 async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
2961 let project = init_test_project("/empty-project", cx).await;
2962 let (multi_workspace, cx) =
2963 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2964 let sidebar = setup_sidebar(&multi_workspace, cx);
2965
2966 // Even an empty project has the header and a new thread button
2967 assert_eq!(
2968 visible_entries_as_strings(&sidebar, cx),
2969 vec!["v [empty-project]", " [+ New Thread]"]
2970 );
2971
2972 // Focus sidebar — focus_in does not set a selection
2973 open_and_focus_sidebar(&sidebar, cx);
2974 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2975
2976 // First SelectNext from None starts at index 0 (header)
2977 cx.dispatch_action(SelectNext);
2978 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2979
2980 // SelectNext moves to the new thread button
2981 cx.dispatch_action(SelectNext);
2982 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2983
2984 // At the end, selection stays on the last entry
2985 cx.dispatch_action(SelectNext);
2986 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2987
2988 // SelectPrevious goes back to the header
2989 cx.dispatch_action(SelectPrevious);
2990 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2991 }
2992
2993 #[gpui::test]
2994 async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
2995 let project = init_test_project("/my-project", cx).await;
2996 let (multi_workspace, cx) =
2997 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2998 let sidebar = setup_sidebar(&multi_workspace, cx);
2999
3000 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3001 save_n_test_threads(1, &path_list, cx).await;
3002 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3003 cx.run_until_parked();
3004
3005 // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
3006 open_and_focus_sidebar(&sidebar, cx);
3007 cx.dispatch_action(SelectNext);
3008 cx.dispatch_action(SelectNext);
3009 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3010
3011 // Collapse the group, which removes the thread from the list
3012 cx.dispatch_action(CollapseSelectedEntry);
3013 cx.run_until_parked();
3014
3015 // Selection should be clamped to the last valid index (0 = header)
3016 let selection = sidebar.read_with(cx, |s, _| s.selection);
3017 let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
3018 assert!(
3019 selection.unwrap_or(0) < entry_count,
3020 "selection {} should be within bounds (entries: {})",
3021 selection.unwrap_or(0),
3022 entry_count,
3023 );
3024 }
3025
3026 fn add_agent_panel(
3027 workspace: &Entity<Workspace>,
3028 project: &Entity<project::Project>,
3029 cx: &mut gpui::VisualTestContext,
3030 ) -> Entity<AgentPanel> {
3031 workspace.update_in(cx, |workspace, window, cx| {
3032 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
3033 let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
3034 workspace.add_panel(panel.clone(), window, cx);
3035 panel
3036 })
3037 }
3038
3039 #[gpui::test]
3040 async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
3041 let project = init_test_project("/my-project", cx).await;
3042 let (multi_workspace, cx) =
3043 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3044 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3045
3046 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3047
3048 // Open thread A and keep it generating.
3049 let connection = StubAgentConnection::new();
3050 open_thread_with_connection(&panel, connection.clone(), cx);
3051 send_message(&panel, cx);
3052
3053 let session_id_a = active_session_id(&panel, cx);
3054 save_thread_to_store(&session_id_a, &path_list, cx).await;
3055
3056 cx.update(|_, cx| {
3057 connection.send_update(
3058 session_id_a.clone(),
3059 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3060 cx,
3061 );
3062 });
3063 cx.run_until_parked();
3064
3065 // Open thread B (idle, default response) — thread A goes to background.
3066 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3067 acp::ContentChunk::new("Done".into()),
3068 )]);
3069 open_thread_with_connection(&panel, connection, cx);
3070 send_message(&panel, cx);
3071
3072 let session_id_b = active_session_id(&panel, cx);
3073 save_thread_to_store(&session_id_b, &path_list, cx).await;
3074
3075 cx.run_until_parked();
3076
3077 let mut entries = visible_entries_as_strings(&sidebar, cx);
3078 entries[1..].sort();
3079 assert_eq!(
3080 entries,
3081 vec!["v [my-project]", " Hello *", " Hello * (running)",]
3082 );
3083 }
3084
3085 #[gpui::test]
3086 async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
3087 let project_a = init_test_project("/project-a", cx).await;
3088 let (multi_workspace, cx) = cx
3089 .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3090 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3091
3092 let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3093
3094 // Open thread on workspace A and keep it generating.
3095 let connection_a = StubAgentConnection::new();
3096 open_thread_with_connection(&panel_a, connection_a.clone(), cx);
3097 send_message(&panel_a, cx);
3098
3099 let session_id_a = active_session_id(&panel_a, cx);
3100 save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3101
3102 cx.update(|_, cx| {
3103 connection_a.send_update(
3104 session_id_a.clone(),
3105 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
3106 cx,
3107 );
3108 });
3109 cx.run_until_parked();
3110
3111 // Add a second workspace and activate it (making workspace A the background).
3112 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3113 let project_b = project::Project::test(fs, [], cx).await;
3114 multi_workspace.update_in(cx, |mw, window, cx| {
3115 mw.test_add_workspace(project_b, window, cx);
3116 });
3117 cx.run_until_parked();
3118
3119 // Thread A is still running; no notification yet.
3120 assert_eq!(
3121 visible_entries_as_strings(&sidebar, cx),
3122 vec![
3123 "v [project-a]",
3124 " Hello * (running)",
3125 "v [Empty Workspace]",
3126 " [+ New Thread]",
3127 ]
3128 );
3129
3130 // Complete thread A's turn (transition Running → Completed).
3131 connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
3132 cx.run_until_parked();
3133
3134 // The completed background thread shows a notification indicator.
3135 assert_eq!(
3136 visible_entries_as_strings(&sidebar, cx),
3137 vec![
3138 "v [project-a]",
3139 " Hello * (!)",
3140 "v [Empty Workspace]",
3141 " [+ New Thread]",
3142 ]
3143 );
3144 }
3145
3146 fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
3147 sidebar.update_in(cx, |sidebar, window, cx| {
3148 window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
3149 sidebar.filter_editor.update(cx, |editor, cx| {
3150 editor.set_text(query, window, cx);
3151 });
3152 });
3153 cx.run_until_parked();
3154 }
3155
3156 #[gpui::test]
3157 async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
3158 let project = init_test_project("/my-project", cx).await;
3159 let (multi_workspace, cx) =
3160 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3161 let sidebar = setup_sidebar(&multi_workspace, cx);
3162
3163 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3164 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3165
3166 for (id, title, hour) in [
3167 ("t-1", "Fix crash in project panel", 3),
3168 ("t-2", "Add inline diff view", 2),
3169 ("t-3", "Refactor settings module", 1),
3170 ] {
3171 let save_task = thread_store.update(cx, |store, cx| {
3172 store.save_thread(
3173 acp::SessionId::new(Arc::from(id)),
3174 make_test_thread(
3175 title,
3176 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3177 ),
3178 path_list.clone(),
3179 cx,
3180 )
3181 });
3182 save_task.await.unwrap();
3183 }
3184 cx.run_until_parked();
3185
3186 assert_eq!(
3187 visible_entries_as_strings(&sidebar, cx),
3188 vec![
3189 "v [my-project]",
3190 " Fix crash in project panel",
3191 " Add inline diff view",
3192 " Refactor settings module",
3193 ]
3194 );
3195
3196 // User types "diff" in the search box — only the matching thread remains,
3197 // with its workspace header preserved for context.
3198 type_in_search(&sidebar, "diff", cx);
3199 assert_eq!(
3200 visible_entries_as_strings(&sidebar, cx),
3201 vec!["v [my-project]", " Add inline diff view <== selected",]
3202 );
3203
3204 // User changes query to something with no matches — list is empty.
3205 type_in_search(&sidebar, "nonexistent", cx);
3206 assert_eq!(
3207 visible_entries_as_strings(&sidebar, cx),
3208 Vec::<String>::new()
3209 );
3210 }
3211
3212 #[gpui::test]
3213 async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
3214 // Scenario: A user remembers a thread title but not the exact casing.
3215 // Search should match case-insensitively so they can still find it.
3216 let project = init_test_project("/my-project", cx).await;
3217 let (multi_workspace, cx) =
3218 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3219 let sidebar = setup_sidebar(&multi_workspace, cx);
3220
3221 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3222 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3223
3224 let save_task = thread_store.update(cx, |store, cx| {
3225 store.save_thread(
3226 acp::SessionId::new(Arc::from("thread-1")),
3227 make_test_thread(
3228 "Fix Crash In Project Panel",
3229 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3230 ),
3231 path_list.clone(),
3232 cx,
3233 )
3234 });
3235 save_task.await.unwrap();
3236 cx.run_until_parked();
3237
3238 // Lowercase query matches mixed-case title.
3239 type_in_search(&sidebar, "fix crash", cx);
3240 assert_eq!(
3241 visible_entries_as_strings(&sidebar, cx),
3242 vec![
3243 "v [my-project]",
3244 " Fix Crash In Project Panel <== selected",
3245 ]
3246 );
3247
3248 // Uppercase query also matches the same title.
3249 type_in_search(&sidebar, "FIX CRASH", cx);
3250 assert_eq!(
3251 visible_entries_as_strings(&sidebar, cx),
3252 vec![
3253 "v [my-project]",
3254 " Fix Crash In Project Panel <== selected",
3255 ]
3256 );
3257 }
3258
3259 #[gpui::test]
3260 async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
3261 // Scenario: A user searches, finds what they need, then presses Escape
3262 // to dismiss the filter and see the full list again.
3263 let project = init_test_project("/my-project", cx).await;
3264 let (multi_workspace, cx) =
3265 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3266 let sidebar = setup_sidebar(&multi_workspace, cx);
3267
3268 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3269 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3270
3271 for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
3272 let save_task = thread_store.update(cx, |store, cx| {
3273 store.save_thread(
3274 acp::SessionId::new(Arc::from(id)),
3275 make_test_thread(
3276 title,
3277 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3278 ),
3279 path_list.clone(),
3280 cx,
3281 )
3282 });
3283 save_task.await.unwrap();
3284 }
3285 cx.run_until_parked();
3286
3287 // Confirm the full list is showing.
3288 assert_eq!(
3289 visible_entries_as_strings(&sidebar, cx),
3290 vec!["v [my-project]", " Alpha thread", " Beta thread",]
3291 );
3292
3293 // User types a search query to filter down.
3294 open_and_focus_sidebar(&sidebar, cx);
3295 type_in_search(&sidebar, "alpha", cx);
3296 assert_eq!(
3297 visible_entries_as_strings(&sidebar, cx),
3298 vec!["v [my-project]", " Alpha thread <== selected",]
3299 );
3300
3301 // User presses Escape — filter clears, full list is restored.
3302 cx.dispatch_action(Cancel);
3303 cx.run_until_parked();
3304 assert_eq!(
3305 visible_entries_as_strings(&sidebar, cx),
3306 vec![
3307 "v [my-project]",
3308 " Alpha thread <== selected",
3309 " Beta thread",
3310 ]
3311 );
3312 }
3313
3314 #[gpui::test]
3315 async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
3316 let project_a = init_test_project("/project-a", cx).await;
3317 let (multi_workspace, cx) =
3318 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3319 let sidebar = setup_sidebar(&multi_workspace, cx);
3320
3321 let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3322 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3323
3324 for (id, title, hour) in [
3325 ("a1", "Fix bug in sidebar", 2),
3326 ("a2", "Add tests for editor", 1),
3327 ] {
3328 let save_task = thread_store.update(cx, |store, cx| {
3329 store.save_thread(
3330 acp::SessionId::new(Arc::from(id)),
3331 make_test_thread(
3332 title,
3333 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3334 ),
3335 path_list_a.clone(),
3336 cx,
3337 )
3338 });
3339 save_task.await.unwrap();
3340 }
3341
3342 // Add a second workspace.
3343 multi_workspace.update_in(cx, |mw, window, cx| {
3344 mw.create_workspace(window, cx);
3345 });
3346 cx.run_until_parked();
3347
3348 let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
3349
3350 for (id, title, hour) in [
3351 ("b1", "Refactor sidebar layout", 3),
3352 ("b2", "Fix typo in README", 1),
3353 ] {
3354 let save_task = thread_store.update(cx, |store, cx| {
3355 store.save_thread(
3356 acp::SessionId::new(Arc::from(id)),
3357 make_test_thread(
3358 title,
3359 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3360 ),
3361 path_list_b.clone(),
3362 cx,
3363 )
3364 });
3365 save_task.await.unwrap();
3366 }
3367 cx.run_until_parked();
3368
3369 assert_eq!(
3370 visible_entries_as_strings(&sidebar, cx),
3371 vec![
3372 "v [project-a]",
3373 " Fix bug in sidebar",
3374 " Add tests for editor",
3375 "v [Empty Workspace]",
3376 " Refactor sidebar layout",
3377 " Fix typo in README",
3378 ]
3379 );
3380
3381 // "sidebar" matches a thread in each workspace — both headers stay visible.
3382 type_in_search(&sidebar, "sidebar", cx);
3383 assert_eq!(
3384 visible_entries_as_strings(&sidebar, cx),
3385 vec![
3386 "v [project-a]",
3387 " Fix bug in sidebar <== selected",
3388 "v [Empty Workspace]",
3389 " Refactor sidebar layout",
3390 ]
3391 );
3392
3393 // "typo" only matches in the second workspace — the first header disappears.
3394 type_in_search(&sidebar, "typo", cx);
3395 assert_eq!(
3396 visible_entries_as_strings(&sidebar, cx),
3397 vec!["v [Empty Workspace]", " Fix typo in README <== selected",]
3398 );
3399
3400 // "project-a" matches the first workspace name — the header appears
3401 // with all child threads included.
3402 type_in_search(&sidebar, "project-a", cx);
3403 assert_eq!(
3404 visible_entries_as_strings(&sidebar, cx),
3405 vec![
3406 "v [project-a]",
3407 " Fix bug in sidebar <== selected",
3408 " Add tests for editor",
3409 ]
3410 );
3411 }
3412
3413 #[gpui::test]
3414 async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
3415 let project_a = init_test_project("/alpha-project", cx).await;
3416 let (multi_workspace, cx) =
3417 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3418 let sidebar = setup_sidebar(&multi_workspace, cx);
3419
3420 let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
3421 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3422
3423 for (id, title, hour) in [
3424 ("a1", "Fix bug in sidebar", 2),
3425 ("a2", "Add tests for editor", 1),
3426 ] {
3427 let save_task = thread_store.update(cx, |store, cx| {
3428 store.save_thread(
3429 acp::SessionId::new(Arc::from(id)),
3430 make_test_thread(
3431 title,
3432 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3433 ),
3434 path_list_a.clone(),
3435 cx,
3436 )
3437 });
3438 save_task.await.unwrap();
3439 }
3440
3441 // Add a second workspace.
3442 multi_workspace.update_in(cx, |mw, window, cx| {
3443 mw.create_workspace(window, cx);
3444 });
3445 cx.run_until_parked();
3446
3447 let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
3448
3449 for (id, title, hour) in [
3450 ("b1", "Refactor sidebar layout", 3),
3451 ("b2", "Fix typo in README", 1),
3452 ] {
3453 let save_task = thread_store.update(cx, |store, cx| {
3454 store.save_thread(
3455 acp::SessionId::new(Arc::from(id)),
3456 make_test_thread(
3457 title,
3458 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3459 ),
3460 path_list_b.clone(),
3461 cx,
3462 )
3463 });
3464 save_task.await.unwrap();
3465 }
3466 cx.run_until_parked();
3467
3468 // "alpha" matches the workspace name "alpha-project" but no thread titles.
3469 // The workspace header should appear with all child threads included.
3470 type_in_search(&sidebar, "alpha", cx);
3471 assert_eq!(
3472 visible_entries_as_strings(&sidebar, cx),
3473 vec![
3474 "v [alpha-project]",
3475 " Fix bug in sidebar <== selected",
3476 " Add tests for editor",
3477 ]
3478 );
3479
3480 // "sidebar" matches thread titles in both workspaces but not workspace names.
3481 // Both headers appear with their matching threads.
3482 type_in_search(&sidebar, "sidebar", cx);
3483 assert_eq!(
3484 visible_entries_as_strings(&sidebar, cx),
3485 vec![
3486 "v [alpha-project]",
3487 " Fix bug in sidebar <== selected",
3488 "v [Empty Workspace]",
3489 " Refactor sidebar layout",
3490 ]
3491 );
3492
3493 // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
3494 // doesn't match) — but does not match either workspace name or any thread.
3495 // Actually let's test something simpler: a query that matches both a workspace
3496 // name AND some threads in that workspace. Matching threads should still appear.
3497 type_in_search(&sidebar, "fix", cx);
3498 assert_eq!(
3499 visible_entries_as_strings(&sidebar, cx),
3500 vec![
3501 "v [alpha-project]",
3502 " Fix bug in sidebar <== selected",
3503 "v [Empty Workspace]",
3504 " Fix typo in README",
3505 ]
3506 );
3507
3508 // A query that matches a workspace name AND a thread in that same workspace.
3509 // Both the header (highlighted) and all child threads should appear.
3510 type_in_search(&sidebar, "alpha", cx);
3511 assert_eq!(
3512 visible_entries_as_strings(&sidebar, cx),
3513 vec![
3514 "v [alpha-project]",
3515 " Fix bug in sidebar <== selected",
3516 " Add tests for editor",
3517 ]
3518 );
3519
3520 // Now search for something that matches only a workspace name when there
3521 // are also threads with matching titles — the non-matching workspace's
3522 // threads should still appear if their titles match.
3523 type_in_search(&sidebar, "alp", cx);
3524 assert_eq!(
3525 visible_entries_as_strings(&sidebar, cx),
3526 vec![
3527 "v [alpha-project]",
3528 " Fix bug in sidebar <== selected",
3529 " Add tests for editor",
3530 ]
3531 );
3532 }
3533
3534 #[gpui::test]
3535 async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
3536 let project = init_test_project("/my-project", cx).await;
3537 let (multi_workspace, cx) =
3538 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3539 let sidebar = setup_sidebar(&multi_workspace, cx);
3540
3541 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3542 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3543
3544 // Create 8 threads. The oldest one has a unique name and will be
3545 // behind View More (only 5 shown by default).
3546 for i in 0..8u32 {
3547 let title = if i == 0 {
3548 "Hidden gem thread".to_string()
3549 } else {
3550 format!("Thread {}", i + 1)
3551 };
3552 let save_task = thread_store.update(cx, |store, cx| {
3553 store.save_thread(
3554 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3555 make_test_thread(
3556 &title,
3557 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3558 ),
3559 path_list.clone(),
3560 cx,
3561 )
3562 });
3563 save_task.await.unwrap();
3564 }
3565 cx.run_until_parked();
3566
3567 // Confirm the thread is not visible and View More is shown.
3568 let entries = visible_entries_as_strings(&sidebar, cx);
3569 assert!(
3570 entries.iter().any(|e| e.contains("View More")),
3571 "should have View More button"
3572 );
3573 assert!(
3574 !entries.iter().any(|e| e.contains("Hidden gem")),
3575 "Hidden gem should be behind View More"
3576 );
3577
3578 // User searches for the hidden thread — it appears, and View More is gone.
3579 type_in_search(&sidebar, "hidden gem", cx);
3580 let filtered = visible_entries_as_strings(&sidebar, cx);
3581 assert_eq!(
3582 filtered,
3583 vec!["v [my-project]", " Hidden gem thread <== selected",]
3584 );
3585 assert!(
3586 !filtered.iter().any(|e| e.contains("View More")),
3587 "View More should not appear when filtering"
3588 );
3589 }
3590
3591 #[gpui::test]
3592 async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
3593 let project = init_test_project("/my-project", cx).await;
3594 let (multi_workspace, cx) =
3595 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3596 let sidebar = setup_sidebar(&multi_workspace, cx);
3597
3598 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3599 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3600
3601 let save_task = thread_store.update(cx, |store, cx| {
3602 store.save_thread(
3603 acp::SessionId::new(Arc::from("thread-1")),
3604 make_test_thread(
3605 "Important thread",
3606 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3607 ),
3608 path_list.clone(),
3609 cx,
3610 )
3611 });
3612 save_task.await.unwrap();
3613 cx.run_until_parked();
3614
3615 // User focuses the sidebar and collapses the group using keyboard:
3616 // manually select the header, then press CollapseSelectedEntry to collapse.
3617 open_and_focus_sidebar(&sidebar, cx);
3618 sidebar.update_in(cx, |sidebar, _window, _cx| {
3619 sidebar.selection = Some(0);
3620 });
3621 cx.dispatch_action(CollapseSelectedEntry);
3622 cx.run_until_parked();
3623
3624 assert_eq!(
3625 visible_entries_as_strings(&sidebar, cx),
3626 vec!["> [my-project] <== selected"]
3627 );
3628
3629 // User types a search — the thread appears even though its group is collapsed.
3630 type_in_search(&sidebar, "important", cx);
3631 assert_eq!(
3632 visible_entries_as_strings(&sidebar, cx),
3633 vec!["> [my-project]", " Important thread <== selected",]
3634 );
3635 }
3636
3637 #[gpui::test]
3638 async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
3639 let project = init_test_project("/my-project", cx).await;
3640 let (multi_workspace, cx) =
3641 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3642 let sidebar = setup_sidebar(&multi_workspace, cx);
3643
3644 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3645 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3646
3647 for (id, title, hour) in [
3648 ("t-1", "Fix crash in panel", 3),
3649 ("t-2", "Fix lint warnings", 2),
3650 ("t-3", "Add new feature", 1),
3651 ] {
3652 let save_task = thread_store.update(cx, |store, cx| {
3653 store.save_thread(
3654 acp::SessionId::new(Arc::from(id)),
3655 make_test_thread(
3656 title,
3657 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3658 ),
3659 path_list.clone(),
3660 cx,
3661 )
3662 });
3663 save_task.await.unwrap();
3664 }
3665 cx.run_until_parked();
3666
3667 open_and_focus_sidebar(&sidebar, cx);
3668
3669 // User types "fix" — two threads match.
3670 type_in_search(&sidebar, "fix", cx);
3671 assert_eq!(
3672 visible_entries_as_strings(&sidebar, cx),
3673 vec![
3674 "v [my-project]",
3675 " Fix crash in panel <== selected",
3676 " Fix lint warnings",
3677 ]
3678 );
3679
3680 // Selection starts on the first matching thread. User presses
3681 // SelectNext to move to the second match.
3682 cx.dispatch_action(SelectNext);
3683 assert_eq!(
3684 visible_entries_as_strings(&sidebar, cx),
3685 vec![
3686 "v [my-project]",
3687 " Fix crash in panel",
3688 " Fix lint warnings <== selected",
3689 ]
3690 );
3691
3692 // User can also jump back with SelectPrevious.
3693 cx.dispatch_action(SelectPrevious);
3694 assert_eq!(
3695 visible_entries_as_strings(&sidebar, cx),
3696 vec![
3697 "v [my-project]",
3698 " Fix crash in panel <== selected",
3699 " Fix lint warnings",
3700 ]
3701 );
3702 }
3703
3704 #[gpui::test]
3705 async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
3706 let project = init_test_project("/my-project", cx).await;
3707 let (multi_workspace, cx) =
3708 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3709 let sidebar = setup_sidebar(&multi_workspace, cx);
3710
3711 multi_workspace.update_in(cx, |mw, window, cx| {
3712 mw.create_workspace(window, cx);
3713 });
3714 cx.run_until_parked();
3715
3716 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3717 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3718
3719 let save_task = thread_store.update(cx, |store, cx| {
3720 store.save_thread(
3721 acp::SessionId::new(Arc::from("hist-1")),
3722 make_test_thread(
3723 "Historical Thread",
3724 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
3725 ),
3726 path_list.clone(),
3727 cx,
3728 )
3729 });
3730 save_task.await.unwrap();
3731 cx.run_until_parked();
3732 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3733 cx.run_until_parked();
3734
3735 assert_eq!(
3736 visible_entries_as_strings(&sidebar, cx),
3737 vec![
3738 "v [my-project]",
3739 " Historical Thread",
3740 "v [Empty Workspace]",
3741 " [+ New Thread]",
3742 ]
3743 );
3744
3745 // Switch to workspace 1 so we can verify the confirm switches back.
3746 multi_workspace.update_in(cx, |mw, window, cx| {
3747 mw.activate_index(1, window, cx);
3748 });
3749 cx.run_until_parked();
3750 assert_eq!(
3751 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3752 1
3753 );
3754
3755 // Confirm on the historical (non-live) thread at index 1.
3756 // Before a previous fix, the workspace field was Option<usize> and
3757 // historical threads had None, so activate_thread early-returned
3758 // without switching the workspace.
3759 sidebar.update_in(cx, |sidebar, window, cx| {
3760 sidebar.selection = Some(1);
3761 sidebar.confirm(&Confirm, window, cx);
3762 });
3763 cx.run_until_parked();
3764
3765 assert_eq!(
3766 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3767 0
3768 );
3769 }
3770
3771 #[gpui::test]
3772 async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
3773 let project = init_test_project("/my-project", cx).await;
3774 let (multi_workspace, cx) =
3775 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3776 let sidebar = setup_sidebar(&multi_workspace, cx);
3777
3778 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3779 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3780
3781 let save_task = thread_store.update(cx, |store, cx| {
3782 store.save_thread(
3783 acp::SessionId::new(Arc::from("t-1")),
3784 make_test_thread(
3785 "Thread A",
3786 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3787 ),
3788 path_list.clone(),
3789 cx,
3790 )
3791 });
3792 save_task.await.unwrap();
3793 let save_task = thread_store.update(cx, |store, cx| {
3794 store.save_thread(
3795 acp::SessionId::new(Arc::from("t-2")),
3796 make_test_thread(
3797 "Thread B",
3798 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3799 ),
3800 path_list.clone(),
3801 cx,
3802 )
3803 });
3804 save_task.await.unwrap();
3805 cx.run_until_parked();
3806 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3807 cx.run_until_parked();
3808
3809 assert_eq!(
3810 visible_entries_as_strings(&sidebar, cx),
3811 vec!["v [my-project]", " Thread A", " Thread B",]
3812 );
3813
3814 // Keyboard confirm preserves selection.
3815 sidebar.update_in(cx, |sidebar, window, cx| {
3816 sidebar.selection = Some(1);
3817 sidebar.confirm(&Confirm, window, cx);
3818 });
3819 assert_eq!(
3820 sidebar.read_with(cx, |sidebar, _| sidebar.selection),
3821 Some(1)
3822 );
3823
3824 // Click handlers clear selection to None so no highlight lingers
3825 // after a click regardless of focus state. The hover style provides
3826 // visual feedback during mouse interaction instead.
3827 sidebar.update_in(cx, |sidebar, window, cx| {
3828 sidebar.selection = None;
3829 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3830 sidebar.toggle_collapse(&path_list, window, cx);
3831 });
3832 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3833
3834 // When the user tabs back into the sidebar, focus_in no longer
3835 // restores selection — it stays None.
3836 sidebar.update_in(cx, |sidebar, window, cx| {
3837 sidebar.focus_in(window, cx);
3838 });
3839 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3840 }
3841
3842 #[gpui::test]
3843 async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
3844 let project = init_test_project("/my-project", cx).await;
3845 let (multi_workspace, cx) =
3846 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3847 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3848
3849 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3850
3851 let connection = StubAgentConnection::new();
3852 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3853 acp::ContentChunk::new("Hi there!".into()),
3854 )]);
3855 open_thread_with_connection(&panel, connection, cx);
3856 send_message(&panel, cx);
3857
3858 let session_id = active_session_id(&panel, cx);
3859 save_thread_to_store(&session_id, &path_list, cx).await;
3860 cx.run_until_parked();
3861
3862 assert_eq!(
3863 visible_entries_as_strings(&sidebar, cx),
3864 vec!["v [my-project]", " Hello *"]
3865 );
3866
3867 // Simulate the agent generating a title. The notification chain is:
3868 // AcpThread::set_title emits TitleUpdated →
3869 // ConnectionView::handle_thread_event calls cx.notify() →
3870 // AgentPanel observer fires and emits AgentPanelEvent →
3871 // Sidebar subscription calls update_entries / rebuild_contents.
3872 //
3873 // Before the fix, handle_thread_event did NOT call cx.notify() for
3874 // TitleUpdated, so the AgentPanel observer never fired and the
3875 // sidebar kept showing the old title.
3876 let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
3877 thread.update(cx, |thread, cx| {
3878 thread
3879 .set_title("Friendly Greeting with AI".into(), cx)
3880 .detach();
3881 });
3882 cx.run_until_parked();
3883
3884 assert_eq!(
3885 visible_entries_as_strings(&sidebar, cx),
3886 vec!["v [my-project]", " Friendly Greeting with AI *"]
3887 );
3888 }
3889
3890 #[gpui::test]
3891 async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
3892 let project_a = init_test_project("/project-a", cx).await;
3893 let (multi_workspace, cx) = cx
3894 .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3895 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3896
3897 let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3898
3899 // Save a thread so it appears in the list.
3900 let connection_a = StubAgentConnection::new();
3901 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3902 acp::ContentChunk::new("Done".into()),
3903 )]);
3904 open_thread_with_connection(&panel_a, connection_a, cx);
3905 send_message(&panel_a, cx);
3906 let session_id_a = active_session_id(&panel_a, cx);
3907 save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3908
3909 // Add a second workspace with its own agent panel.
3910 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3911 fs.as_fake()
3912 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
3913 .await;
3914 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
3915 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3916 mw.test_add_workspace(project_b.clone(), window, cx)
3917 });
3918 let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
3919 cx.run_until_parked();
3920
3921 let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
3922
3923 // ── 1. Initial state: no focused thread ──────────────────────────────
3924 // Workspace B is active (just added) and has no thread, so its header
3925 // is the active entry.
3926 sidebar.read_with(cx, |sidebar, _cx| {
3927 assert_eq!(
3928 sidebar.focused_thread, None,
3929 "Initially no thread should be focused"
3930 );
3931 let active_entry = sidebar
3932 .active_entry_index
3933 .and_then(|ix| sidebar.contents.entries.get(ix));
3934 assert!(
3935 matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
3936 "Active entry should be the active workspace header"
3937 );
3938 });
3939
3940 // ── 2. Click thread in workspace A via sidebar ───────────────────────
3941 sidebar.update_in(cx, |sidebar, window, cx| {
3942 sidebar.activate_thread(
3943 acp_thread::AgentSessionInfo {
3944 session_id: session_id_a.clone(),
3945 cwd: None,
3946 title: Some("Test".into()),
3947 updated_at: None,
3948 created_at: None,
3949 meta: None,
3950 },
3951 &workspace_a,
3952 window,
3953 cx,
3954 );
3955 });
3956 cx.run_until_parked();
3957
3958 sidebar.read_with(cx, |sidebar, _cx| {
3959 assert_eq!(
3960 sidebar.focused_thread.as_ref(),
3961 Some(&session_id_a),
3962 "After clicking a thread, it should be the focused thread"
3963 );
3964 let active_entry = sidebar.active_entry_index
3965 .and_then(|ix| sidebar.contents.entries.get(ix));
3966 assert!(
3967 matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
3968 "Active entry should be the clicked thread"
3969 );
3970 });
3971
3972 workspace_a.read_with(cx, |workspace, cx| {
3973 assert!(
3974 workspace.panel::<AgentPanel>(cx).is_some(),
3975 "Agent panel should exist"
3976 );
3977 let dock = workspace.right_dock().read(cx);
3978 assert!(
3979 dock.is_open(),
3980 "Clicking a thread should open the agent panel dock"
3981 );
3982 });
3983
3984 // ── 3. Open thread in workspace B, then click it via sidebar ─────────
3985 let connection_b = StubAgentConnection::new();
3986 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3987 acp::ContentChunk::new("Thread B".into()),
3988 )]);
3989 open_thread_with_connection(&panel_b, connection_b, cx);
3990 send_message(&panel_b, cx);
3991 let session_id_b = active_session_id(&panel_b, cx);
3992 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
3993 save_thread_to_store(&session_id_b, &path_list_b, cx).await;
3994 cx.run_until_parked();
3995
3996 // Opening a thread in a non-active workspace should NOT change
3997 // focused_thread — it's derived from the active workspace.
3998 sidebar.read_with(cx, |sidebar, _cx| {
3999 assert_eq!(
4000 sidebar.focused_thread.as_ref(),
4001 Some(&session_id_a),
4002 "Opening a thread in a non-active workspace should not affect focused_thread"
4003 );
4004 });
4005
4006 // Workspace A is currently active. Click a thread in workspace B,
4007 // which also triggers a workspace switch.
4008 sidebar.update_in(cx, |sidebar, window, cx| {
4009 sidebar.activate_thread(
4010 acp_thread::AgentSessionInfo {
4011 session_id: session_id_b.clone(),
4012 cwd: None,
4013 title: Some("Thread B".into()),
4014 updated_at: None,
4015 created_at: None,
4016 meta: None,
4017 },
4018 &workspace_b,
4019 window,
4020 cx,
4021 );
4022 });
4023 cx.run_until_parked();
4024
4025 sidebar.read_with(cx, |sidebar, _cx| {
4026 assert_eq!(
4027 sidebar.focused_thread.as_ref(),
4028 Some(&session_id_b),
4029 "Clicking a thread in another workspace should focus that thread"
4030 );
4031 let active_entry = sidebar
4032 .active_entry_index
4033 .and_then(|ix| sidebar.contents.entries.get(ix));
4034 assert!(
4035 matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b),
4036 "Active entry should be the cross-workspace thread"
4037 );
4038 });
4039
4040 // ── 4. Switch workspace → focused_thread reflects new workspace ──────
4041 multi_workspace.update_in(cx, |mw, window, cx| {
4042 mw.activate_next_workspace(window, cx);
4043 });
4044 cx.run_until_parked();
4045
4046 // Workspace A is now active. Its agent panel still has session_id_a
4047 // loaded, so focused_thread should reflect that.
4048 sidebar.read_with(cx, |sidebar, _cx| {
4049 assert_eq!(
4050 sidebar.focused_thread.as_ref(),
4051 Some(&session_id_a),
4052 "Switching workspaces should derive focused_thread from the new active workspace"
4053 );
4054 let active_entry = sidebar
4055 .active_entry_index
4056 .and_then(|ix| sidebar.contents.entries.get(ix));
4057 assert!(
4058 matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
4059 "Active entry should be workspace_a's active thread"
4060 );
4061 });
4062
4063 // ── 5. Opening a thread in a non-active workspace is ignored ──────────
4064 let connection_b2 = StubAgentConnection::new();
4065 connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4066 acp::ContentChunk::new("New thread".into()),
4067 )]);
4068 open_thread_with_connection(&panel_b, connection_b2, cx);
4069 send_message(&panel_b, cx);
4070 let session_id_b2 = active_session_id(&panel_b, cx);
4071 save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
4072 cx.run_until_parked();
4073
4074 // Workspace A is still active, so focused_thread stays on session_id_a.
4075 sidebar.read_with(cx, |sidebar, _cx| {
4076 assert_eq!(
4077 sidebar.focused_thread.as_ref(),
4078 Some(&session_id_a),
4079 "Opening a thread in a non-active workspace should not affect focused_thread"
4080 );
4081 });
4082
4083 // ── 6. Activating workspace B shows its active thread ────────────────
4084 sidebar.update_in(cx, |sidebar, window, cx| {
4085 sidebar.activate_workspace(&workspace_b, window, cx);
4086 });
4087 cx.run_until_parked();
4088
4089 // Workspace B is now active with session_id_b2 loaded.
4090 sidebar.read_with(cx, |sidebar, _cx| {
4091 assert_eq!(
4092 sidebar.focused_thread.as_ref(),
4093 Some(&session_id_b2),
4094 "Activating workspace_b should show workspace_b's active thread"
4095 );
4096 let active_entry = sidebar
4097 .active_entry_index
4098 .and_then(|ix| sidebar.contents.entries.get(ix));
4099 assert!(
4100 matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
4101 "Active entry should be workspace_b's active thread"
4102 );
4103 });
4104
4105 // ── 7. Switching back to workspace A reflects its thread ─────────────
4106 multi_workspace.update_in(cx, |mw, window, cx| {
4107 mw.activate_next_workspace(window, cx);
4108 });
4109 cx.run_until_parked();
4110
4111 sidebar.read_with(cx, |sidebar, _cx| {
4112 assert_eq!(
4113 sidebar.focused_thread.as_ref(),
4114 Some(&session_id_a),
4115 "Switching back to workspace_a should show its active thread"
4116 );
4117 });
4118 }
4119
4120 async fn save_named_thread(
4121 session_id: &str,
4122 title: &str,
4123 path_list: &PathList,
4124 cx: &mut gpui::VisualTestContext,
4125 ) {
4126 let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
4127 let save_task = thread_store.update(cx, |store, cx| {
4128 store.save_thread(
4129 acp::SessionId::new(Arc::from(session_id)),
4130 make_test_thread(
4131 title,
4132 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4133 ),
4134 path_list.clone(),
4135 cx,
4136 )
4137 });
4138 save_task.await.unwrap();
4139 cx.run_until_parked();
4140 }
4141
4142 async fn init_test_project_with_git(
4143 worktree_path: &str,
4144 cx: &mut TestAppContext,
4145 ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
4146 init_test(cx);
4147 let fs = FakeFs::new(cx.executor());
4148 fs.insert_tree(
4149 worktree_path,
4150 serde_json::json!({
4151 ".git": {},
4152 "src": {},
4153 }),
4154 )
4155 .await;
4156 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4157 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
4158 (project, fs)
4159 }
4160
4161 #[gpui::test]
4162 async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
4163 let (project, fs) = init_test_project_with_git("/project", cx).await;
4164
4165 fs.as_fake()
4166 .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4167 state.worktrees.push(git::repository::Worktree {
4168 path: std::path::PathBuf::from("/wt/rosewood"),
4169 ref_name: "refs/heads/rosewood".into(),
4170 sha: "abc".into(),
4171 });
4172 })
4173 .unwrap();
4174
4175 project
4176 .update(cx, |project, cx| project.git_scans_complete(cx))
4177 .await;
4178
4179 let (multi_workspace, cx) =
4180 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4181 let sidebar = setup_sidebar(&multi_workspace, cx);
4182
4183 let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
4184 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
4185 save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await;
4186 save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await;
4187
4188 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4189 cx.run_until_parked();
4190
4191 // Search for "rosewood" — should match the worktree name, not the title.
4192 type_in_search(&sidebar, "rosewood", cx);
4193
4194 assert_eq!(
4195 visible_entries_as_strings(&sidebar, cx),
4196 vec!["v [project]", " Fix Bug {rosewood} <== selected"],
4197 );
4198 }
4199
4200 #[gpui::test]
4201 async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
4202 let (project, fs) = init_test_project_with_git("/project", cx).await;
4203
4204 project
4205 .update(cx, |project, cx| project.git_scans_complete(cx))
4206 .await;
4207
4208 let (multi_workspace, cx) =
4209 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4210 let sidebar = setup_sidebar(&multi_workspace, cx);
4211
4212 // Save a thread against a worktree path that doesn't exist yet.
4213 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
4214 save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await;
4215
4216 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4217 cx.run_until_parked();
4218
4219 // Thread is not visible yet — no worktree knows about this path.
4220 assert_eq!(
4221 visible_entries_as_strings(&sidebar, cx),
4222 vec!["v [project]", " [+ New Thread]"]
4223 );
4224
4225 // Now add the worktree to the git state and trigger a rescan.
4226 fs.as_fake()
4227 .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
4228 state.worktrees.push(git::repository::Worktree {
4229 path: std::path::PathBuf::from("/wt/rosewood"),
4230 ref_name: "refs/heads/rosewood".into(),
4231 sha: "abc".into(),
4232 });
4233 })
4234 .unwrap();
4235
4236 cx.run_until_parked();
4237
4238 assert_eq!(
4239 visible_entries_as_strings(&sidebar, cx),
4240 vec!["v [project]", " Worktree Thread {rosewood}",]
4241 );
4242 }
4243
4244 #[gpui::test]
4245 async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
4246 init_test(cx);
4247 let fs = FakeFs::new(cx.executor());
4248
4249 // Create the main repo directory (not opened as a workspace yet).
4250 fs.insert_tree(
4251 "/project",
4252 serde_json::json!({
4253 ".git": {
4254 "worktrees": {
4255 "feature-a": {
4256 "commondir": "../../",
4257 "HEAD": "ref: refs/heads/feature-a",
4258 },
4259 "feature-b": {
4260 "commondir": "../../",
4261 "HEAD": "ref: refs/heads/feature-b",
4262 },
4263 },
4264 },
4265 "src": {},
4266 }),
4267 )
4268 .await;
4269
4270 // Two worktree checkouts whose .git files point back to the main repo.
4271 fs.insert_tree(
4272 "/wt-feature-a",
4273 serde_json::json!({
4274 ".git": "gitdir: /project/.git/worktrees/feature-a",
4275 "src": {},
4276 }),
4277 )
4278 .await;
4279 fs.insert_tree(
4280 "/wt-feature-b",
4281 serde_json::json!({
4282 ".git": "gitdir: /project/.git/worktrees/feature-b",
4283 "src": {},
4284 }),
4285 )
4286 .await;
4287
4288 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4289
4290 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4291 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
4292
4293 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4294 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4295
4296 // Open both worktrees as workspaces — no main repo yet.
4297 let (multi_workspace, cx) = cx
4298 .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4299 multi_workspace.update_in(cx, |mw, window, cx| {
4300 mw.test_add_workspace(project_b.clone(), window, cx);
4301 });
4302 let sidebar = setup_sidebar(&multi_workspace, cx);
4303
4304 let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4305 let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
4306 save_named_thread("thread-a", "Thread A", &paths_a, cx).await;
4307 save_named_thread("thread-b", "Thread B", &paths_b, cx).await;
4308
4309 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4310 cx.run_until_parked();
4311
4312 // Without the main repo, each worktree has its own header.
4313 assert_eq!(
4314 visible_entries_as_strings(&sidebar, cx),
4315 vec![
4316 "v [wt-feature-a]",
4317 " Thread A",
4318 "v [wt-feature-b]",
4319 " Thread B",
4320 ]
4321 );
4322
4323 // Configure the main repo to list both worktrees before opening
4324 // it so the initial git scan picks them up.
4325 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4326 state.worktrees.push(git::repository::Worktree {
4327 path: std::path::PathBuf::from("/wt-feature-a"),
4328 ref_name: "refs/heads/feature-a".into(),
4329 sha: "aaa".into(),
4330 });
4331 state.worktrees.push(git::repository::Worktree {
4332 path: std::path::PathBuf::from("/wt-feature-b"),
4333 ref_name: "refs/heads/feature-b".into(),
4334 sha: "bbb".into(),
4335 });
4336 })
4337 .unwrap();
4338
4339 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4340 main_project
4341 .update(cx, |p, cx| p.git_scans_complete(cx))
4342 .await;
4343
4344 multi_workspace.update_in(cx, |mw, window, cx| {
4345 mw.test_add_workspace(main_project.clone(), window, cx);
4346 });
4347 cx.run_until_parked();
4348
4349 // Both worktree workspaces should now be absorbed under the main
4350 // repo header, with worktree chips.
4351 assert_eq!(
4352 visible_entries_as_strings(&sidebar, cx),
4353 vec![
4354 "v [project]",
4355 " Thread A {wt-feature-a}",
4356 " Thread B {wt-feature-b}",
4357 ]
4358 );
4359
4360 // Remove feature-b from the main repo's linked worktrees.
4361 // The feature-b workspace should be pruned automatically.
4362 fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
4363 state
4364 .worktrees
4365 .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
4366 })
4367 .unwrap();
4368
4369 cx.run_until_parked();
4370
4371 // feature-b's workspace is pruned; feature-a remains absorbed
4372 // under the main repo.
4373 assert_eq!(
4374 visible_entries_as_strings(&sidebar, cx),
4375 vec!["v [project]", " Thread A {wt-feature-a}",]
4376 );
4377 }
4378
4379 #[gpui::test]
4380 async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
4381 cx: &mut TestAppContext,
4382 ) {
4383 init_test(cx);
4384 let fs = FakeFs::new(cx.executor());
4385
4386 fs.insert_tree(
4387 "/project",
4388 serde_json::json!({
4389 ".git": {
4390 "worktrees": {
4391 "feature-a": {
4392 "commondir": "../../",
4393 "HEAD": "ref: refs/heads/feature-a",
4394 },
4395 },
4396 },
4397 "src": {},
4398 }),
4399 )
4400 .await;
4401
4402 fs.insert_tree(
4403 "/wt-feature-a",
4404 serde_json::json!({
4405 ".git": "gitdir: /project/.git/worktrees/feature-a",
4406 "src": {},
4407 }),
4408 )
4409 .await;
4410
4411 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4412 state.worktrees.push(git::repository::Worktree {
4413 path: std::path::PathBuf::from("/wt-feature-a"),
4414 ref_name: "refs/heads/feature-a".into(),
4415 sha: "aaa".into(),
4416 });
4417 })
4418 .unwrap();
4419
4420 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4421
4422 // Only open the main repo — no workspace for the worktree.
4423 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4424 main_project
4425 .update(cx, |p, cx| p.git_scans_complete(cx))
4426 .await;
4427
4428 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4429 MultiWorkspace::test_new(main_project.clone(), window, cx)
4430 });
4431 let sidebar = setup_sidebar(&multi_workspace, cx);
4432
4433 // Save a thread for the worktree path (no workspace for it).
4434 let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4435 save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
4436
4437 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4438 cx.run_until_parked();
4439
4440 // Thread should appear under the main repo with a worktree chip.
4441 assert_eq!(
4442 visible_entries_as_strings(&sidebar, cx),
4443 vec!["v [project]", " WT Thread {wt-feature-a}"],
4444 );
4445
4446 // Only 1 workspace should exist.
4447 assert_eq!(
4448 multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4449 1,
4450 );
4451
4452 // Focus the sidebar and select the worktree thread.
4453 open_and_focus_sidebar(&sidebar, cx);
4454 sidebar.update_in(cx, |sidebar, _window, _cx| {
4455 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
4456 });
4457
4458 // Confirm to open the worktree thread.
4459 cx.dispatch_action(Confirm);
4460 cx.run_until_parked();
4461
4462 // A new workspace should have been created for the worktree path.
4463 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
4464 assert_eq!(
4465 mw.workspaces().len(),
4466 2,
4467 "confirming a worktree thread without a workspace should open one",
4468 );
4469 mw.workspaces()[1].clone()
4470 });
4471
4472 let (new_path_list, _) = new_workspace.read_with(cx, |_, cx| {
4473 workspace_path_list_and_label(&new_workspace, cx)
4474 });
4475 assert_eq!(
4476 new_path_list,
4477 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
4478 "the new workspace should have been opened for the worktree path",
4479 );
4480 }
4481
4482 #[gpui::test]
4483 async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
4484 cx: &mut TestAppContext,
4485 ) {
4486 init_test(cx);
4487 let fs = FakeFs::new(cx.executor());
4488
4489 fs.insert_tree(
4490 "/project",
4491 serde_json::json!({
4492 ".git": {
4493 "worktrees": {
4494 "feature-a": {
4495 "commondir": "../../",
4496 "HEAD": "ref: refs/heads/feature-a",
4497 },
4498 },
4499 },
4500 "src": {},
4501 }),
4502 )
4503 .await;
4504
4505 fs.insert_tree(
4506 "/wt-feature-a",
4507 serde_json::json!({
4508 ".git": "gitdir: /project/.git/worktrees/feature-a",
4509 "src": {},
4510 }),
4511 )
4512 .await;
4513
4514 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4515 state.worktrees.push(git::repository::Worktree {
4516 path: std::path::PathBuf::from("/wt-feature-a"),
4517 ref_name: "refs/heads/feature-a".into(),
4518 sha: "aaa".into(),
4519 });
4520 })
4521 .unwrap();
4522
4523 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4524
4525 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4526 let worktree_project =
4527 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4528
4529 main_project
4530 .update(cx, |p, cx| p.git_scans_complete(cx))
4531 .await;
4532 worktree_project
4533 .update(cx, |p, cx| p.git_scans_complete(cx))
4534 .await;
4535
4536 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4537 MultiWorkspace::test_new(main_project.clone(), window, cx)
4538 });
4539
4540 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4541 mw.test_add_workspace(worktree_project.clone(), window, cx)
4542 });
4543
4544 // Activate the main workspace before setting up the sidebar.
4545 multi_workspace.update_in(cx, |mw, window, cx| {
4546 mw.activate_index(0, window, cx);
4547 });
4548
4549 let sidebar = setup_sidebar(&multi_workspace, cx);
4550
4551 let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
4552 let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4553 save_named_thread("thread-main", "Main Thread", &paths_main, cx).await;
4554 save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
4555
4556 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4557 cx.run_until_parked();
4558
4559 // The worktree workspace should be absorbed under the main repo.
4560 let entries = visible_entries_as_strings(&sidebar, cx);
4561 assert_eq!(entries.len(), 3);
4562 assert_eq!(entries[0], "v [project]");
4563 assert!(entries.contains(&" Main Thread".to_string()));
4564 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
4565
4566 let wt_thread_index = entries
4567 .iter()
4568 .position(|e| e.contains("WT Thread"))
4569 .expect("should find the worktree thread entry");
4570
4571 assert_eq!(
4572 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4573 0,
4574 "main workspace should be active initially"
4575 );
4576
4577 // Focus the sidebar and select the absorbed worktree thread.
4578 open_and_focus_sidebar(&sidebar, cx);
4579 sidebar.update_in(cx, |sidebar, _window, _cx| {
4580 sidebar.selection = Some(wt_thread_index);
4581 });
4582
4583 // Confirm to activate the worktree thread.
4584 cx.dispatch_action(Confirm);
4585 cx.run_until_parked();
4586
4587 // The worktree workspace should now be active, not the main one.
4588 let active_workspace = multi_workspace.read_with(cx, |mw, _| {
4589 mw.workspaces()[mw.active_workspace_index()].clone()
4590 });
4591 assert_eq!(
4592 active_workspace, worktree_workspace,
4593 "clicking an absorbed worktree thread should activate the worktree workspace"
4594 );
4595 }
4596}