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