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