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