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