1use acp_thread::ThreadStatus;
2use action_log::DiffStats;
3use agent_client_protocol::{self as acp};
4use agent_settings::AgentSettings;
5use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata};
6use agent_ui::threads_archive_view::{
7 ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
8};
9use agent_ui::{
10 Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
11};
12use chrono::Utc;
13use editor::Editor;
14use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
15use gpui::{
16 Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState,
17 Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px,
18};
19use menu::{
20 Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
21};
22use project::{Event as ProjectEvent, linked_worktree_short_name};
23use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
24use ui::utils::platform_title_bar_height;
25
26use settings::Settings as _;
27use std::collections::{HashMap, HashSet};
28use std::mem;
29use std::rc::Rc;
30use theme::ActiveTheme;
31use ui::{
32 AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
33 PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
34};
35use util::ResultExt as _;
36use util::path_list::PathList;
37use workspace::{
38 AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open,
39 Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
40 sidebar_side_context_menu,
41};
42
43use zed_actions::OpenRecent;
44use zed_actions::editor::{MoveDown, MoveUp};
45
46use zed_actions::agents_sidebar::FocusSidebarFilter;
47
48use crate::project_group_builder::ProjectGroupBuilder;
49
50mod project_group_builder;
51
52gpui::actions!(
53 agents_sidebar,
54 [
55 /// Creates a new thread in the currently selected or active project group.
56 NewThreadInGroup,
57 /// Toggles between the thread list and the archive view.
58 ToggleArchive,
59 ]
60);
61
62const DEFAULT_WIDTH: Pixels = px(300.0);
63const MIN_WIDTH: Pixels = px(200.0);
64const MAX_WIDTH: Pixels = px(800.0);
65const DEFAULT_THREADS_SHOWN: usize = 5;
66
67#[derive(Debug, Default)]
68enum SidebarView {
69 #[default]
70 ThreadList,
71 Archive(Entity<ThreadsArchiveView>),
72}
73
74#[derive(Clone, Debug)]
75struct ActiveThreadInfo {
76 session_id: acp::SessionId,
77 title: SharedString,
78 status: AgentThreadStatus,
79 icon: IconName,
80 icon_from_external_svg: Option<SharedString>,
81 is_background: bool,
82 is_title_generating: bool,
83 diff_stats: DiffStats,
84}
85
86impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
87 fn from(info: &ActiveThreadInfo) -> Self {
88 Self {
89 session_id: info.session_id.clone(),
90 work_dirs: None,
91 title: Some(info.title.clone()),
92 updated_at: Some(Utc::now()),
93 created_at: Some(Utc::now()),
94 meta: None,
95 }
96 }
97}
98
99#[derive(Clone)]
100enum ThreadEntryWorkspace {
101 Open(Entity<Workspace>),
102 Closed(PathList),
103}
104
105#[derive(Clone)]
106struct ThreadEntry {
107 agent: Agent,
108 session_info: acp_thread::AgentSessionInfo,
109 icon: IconName,
110 icon_from_external_svg: Option<SharedString>,
111 status: AgentThreadStatus,
112 workspace: ThreadEntryWorkspace,
113 is_live: bool,
114 is_background: bool,
115 is_title_generating: bool,
116 highlight_positions: Vec<usize>,
117 worktree_name: Option<SharedString>,
118 worktree_full_path: Option<SharedString>,
119 worktree_highlight_positions: Vec<usize>,
120 diff_stats: DiffStats,
121}
122
123impl ThreadEntry {
124 /// Updates this thread entry with active thread information.
125 ///
126 /// The existing [`ThreadEntry`] was likely deserialized from the database
127 /// but if we have a correspond thread already loaded we want to apply the
128 /// live information.
129 fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
130 self.session_info.title = Some(info.title.clone());
131 self.status = info.status;
132 self.icon = info.icon;
133 self.icon_from_external_svg = info.icon_from_external_svg.clone();
134 self.is_live = true;
135 self.is_background = info.is_background;
136 self.is_title_generating = info.is_title_generating;
137 self.diff_stats = info.diff_stats;
138 }
139}
140
141#[derive(Clone)]
142enum ListEntry {
143 ProjectHeader {
144 path_list: PathList,
145 label: SharedString,
146 workspace: Entity<Workspace>,
147 highlight_positions: Vec<usize>,
148 has_running_threads: bool,
149 waiting_thread_count: usize,
150 is_active: bool,
151 },
152 Thread(ThreadEntry),
153 ViewMore {
154 path_list: PathList,
155 is_fully_expanded: bool,
156 },
157 NewThread {
158 path_list: PathList,
159 workspace: Entity<Workspace>,
160 is_active_draft: bool,
161 },
162}
163
164impl From<ThreadEntry> for ListEntry {
165 fn from(thread: ThreadEntry) -> Self {
166 ListEntry::Thread(thread)
167 }
168}
169
170#[derive(Default)]
171struct SidebarContents {
172 entries: Vec<ListEntry>,
173 notified_threads: HashSet<acp::SessionId>,
174 project_header_indices: Vec<usize>,
175 has_open_projects: bool,
176}
177
178impl SidebarContents {
179 fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
180 self.notified_threads.contains(session_id)
181 }
182}
183
184fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
185 let mut positions = Vec::new();
186 let mut query_chars = query.chars().peekable();
187
188 for (byte_idx, candidate_char) in candidate.char_indices() {
189 if let Some(&query_char) = query_chars.peek() {
190 if candidate_char.eq_ignore_ascii_case(&query_char) {
191 positions.push(byte_idx);
192 query_chars.next();
193 }
194 } else {
195 break;
196 }
197 }
198
199 if query_chars.peek().is_none() {
200 Some(positions)
201 } else {
202 None
203 }
204}
205
206// TODO: The mapping from workspace root paths to git repositories needs a
207// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
208// thread persistence (which PathList is saved to the database), and thread
209// querying (which PathList is used to read threads back). All of these need
210// to agree on how repos are resolved for a given workspace, especially in
211// multi-root and nested-repo configurations.
212fn root_repository_snapshots(
213 workspace: &Entity<Workspace>,
214 cx: &App,
215) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
216 let path_list = workspace_path_list(workspace, cx);
217 let project = workspace.read(cx).project().read(cx);
218 project.repositories(cx).values().filter_map(move |repo| {
219 let snapshot = repo.read(cx).snapshot();
220 let is_root = path_list
221 .paths()
222 .iter()
223 .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
224 is_root.then_some(snapshot)
225 })
226}
227
228fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
229 PathList::new(&workspace.read(cx).root_paths(cx))
230}
231
232/// The sidebar re-derives its entire entry list from scratch on every
233/// change via `update_entries` → `rebuild_contents`. Avoid adding
234/// incremental or inter-event coordination state — if something can
235/// be computed from the current world state, compute it in the rebuild.
236pub struct Sidebar {
237 multi_workspace: WeakEntity<MultiWorkspace>,
238 width: Pixels,
239 focus_handle: FocusHandle,
240 filter_editor: Entity<Editor>,
241 list_state: ListState,
242 contents: SidebarContents,
243 /// The index of the list item that currently has the keyboard focus
244 ///
245 /// Note: This is NOT the same as the active item.
246 selection: Option<usize>,
247 /// Derived from the active panel's thread in `rebuild_contents`.
248 /// Only updated when the panel returns `Some` — never cleared by
249 /// derivation, since the panel may transiently return `None` while
250 /// loading. User actions may write directly for immediate feedback.
251 focused_thread: Option<acp::SessionId>,
252 agent_panel_visible: bool,
253 active_thread_is_draft: bool,
254 hovered_thread_index: Option<usize>,
255 collapsed_groups: HashSet<PathList>,
256 expanded_groups: HashMap<PathList, usize>,
257 view: SidebarView,
258 recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
259 project_header_menu_ix: Option<usize>,
260 _subscriptions: Vec<gpui::Subscription>,
261 _draft_observation: Option<gpui::Subscription>,
262}
263
264impl Sidebar {
265 pub fn new(
266 multi_workspace: Entity<MultiWorkspace>,
267 window: &mut Window,
268 cx: &mut Context<Self>,
269 ) -> Self {
270 let focus_handle = cx.focus_handle();
271 cx.on_focus_in(&focus_handle, window, Self::focus_in)
272 .detach();
273
274 let filter_editor = cx.new(|cx| {
275 let mut editor = Editor::single_line(window, cx);
276 editor.set_use_modal_editing(true);
277 editor.set_placeholder_text("Search…", window, cx);
278 editor
279 });
280
281 cx.subscribe_in(
282 &multi_workspace,
283 window,
284 |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
285 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
286 this.observe_draft_editor(cx);
287 this.update_entries(cx);
288 }
289 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
290 this.subscribe_to_workspace(workspace, window, cx);
291 this.update_entries(cx);
292 }
293 MultiWorkspaceEvent::WorkspaceRemoved(_) => {
294 this.update_entries(cx);
295 }
296 },
297 )
298 .detach();
299
300 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
301 if let editor::EditorEvent::BufferEdited = event {
302 let query = this.filter_editor.read(cx).text(cx);
303 if !query.is_empty() {
304 this.selection.take();
305 }
306 this.update_entries(cx);
307 if !query.is_empty() {
308 this.select_first_entry();
309 }
310 }
311 })
312 .detach();
313
314 cx.observe(
315 &SidebarThreadMetadataStore::global(cx),
316 |this, _store, cx| {
317 this.update_entries(cx);
318 },
319 )
320 .detach();
321
322 cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
323 this.update_entries(cx);
324 })
325 .detach();
326
327 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
328 cx.defer_in(window, move |this, window, cx| {
329 for workspace in &workspaces {
330 this.subscribe_to_workspace(workspace, window, cx);
331 }
332 this.update_entries(cx);
333 });
334
335 Self {
336 multi_workspace: multi_workspace.downgrade(),
337 width: DEFAULT_WIDTH,
338 focus_handle,
339 filter_editor,
340 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
341 contents: SidebarContents::default(),
342 selection: None,
343 focused_thread: None,
344 agent_panel_visible: false,
345 active_thread_is_draft: false,
346 hovered_thread_index: None,
347 collapsed_groups: HashSet::new(),
348 expanded_groups: HashMap::new(),
349 view: SidebarView::default(),
350 recent_projects_popover_handle: PopoverMenuHandle::default(),
351 project_header_menu_ix: None,
352 _subscriptions: Vec::new(),
353 _draft_observation: None,
354 }
355 }
356
357 fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
358 self.multi_workspace
359 .upgrade()
360 .map_or(false, |mw| mw.read(cx).workspace() == workspace)
361 }
362
363 fn subscribe_to_workspace(
364 &mut self,
365 workspace: &Entity<Workspace>,
366 window: &mut Window,
367 cx: &mut Context<Self>,
368 ) {
369 let project = workspace.read(cx).project().clone();
370 cx.subscribe_in(
371 &project,
372 window,
373 |this, _project, event, _window, cx| match event {
374 ProjectEvent::WorktreeAdded(_)
375 | ProjectEvent::WorktreeRemoved(_)
376 | ProjectEvent::WorktreeOrderChanged => {
377 this.update_entries(cx);
378 }
379 _ => {}
380 },
381 )
382 .detach();
383
384 let git_store = workspace.read(cx).project().read(cx).git_store().clone();
385 cx.subscribe_in(
386 &git_store,
387 window,
388 |this, _, event: &project::git_store::GitStoreEvent, window, cx| {
389 if matches!(
390 event,
391 project::git_store::GitStoreEvent::RepositoryUpdated(
392 _,
393 project::git_store::RepositoryEvent::GitWorktreeListChanged,
394 _,
395 )
396 ) {
397 this.prune_stale_worktree_workspaces(window, cx);
398 this.update_entries(cx);
399 }
400 },
401 )
402 .detach();
403
404 cx.subscribe_in(
405 workspace,
406 window,
407 |this, _workspace, event: &workspace::Event, window, cx| {
408 if let workspace::Event::PanelAdded(view) = event {
409 if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
410 this.subscribe_to_agent_panel(&agent_panel, window, cx);
411 }
412 }
413 },
414 )
415 .detach();
416
417 self.observe_docks(workspace, cx);
418
419 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
420 self.subscribe_to_agent_panel(&agent_panel, window, cx);
421 if self.is_active_workspace(workspace, cx) {
422 self.agent_panel_visible = AgentPanel::is_visible(workspace, cx);
423 }
424 self.observe_draft_editor(cx);
425 }
426 }
427
428 fn subscribe_to_agent_panel(
429 &mut self,
430 agent_panel: &Entity<AgentPanel>,
431 window: &mut Window,
432 cx: &mut Context<Self>,
433 ) {
434 cx.subscribe_in(
435 agent_panel,
436 window,
437 |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
438 AgentPanelEvent::ActiveViewChanged => {
439 let is_new_draft = agent_panel
440 .read(cx)
441 .active_conversation_view()
442 .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
443 if is_new_draft {
444 this.focused_thread = None;
445 }
446 this.observe_draft_editor(cx);
447 this.update_entries(cx);
448 }
449 AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => {
450 this.update_entries(cx);
451 }
452 },
453 )
454 .detach();
455 }
456
457 fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
458 let docks: Vec<_> = workspace
459 .read(cx)
460 .all_docks()
461 .into_iter()
462 .cloned()
463 .collect();
464 let workspace = workspace.downgrade();
465 for dock in docks {
466 let workspace = workspace.clone();
467 cx.observe(&dock, move |this, _dock, cx| {
468 let Some(workspace) = workspace.upgrade() else {
469 return;
470 };
471 if !this.is_active_workspace(&workspace, cx) {
472 return;
473 }
474
475 let is_visible = AgentPanel::is_visible(&workspace, cx);
476
477 if this.agent_panel_visible != is_visible {
478 this.agent_panel_visible = is_visible;
479 cx.notify();
480 }
481 })
482 .detach();
483 }
484 }
485
486 fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
487 self._draft_observation = self
488 .multi_workspace
489 .upgrade()
490 .and_then(|mw| {
491 let ws = mw.read(cx).workspace();
492 ws.read(cx).panel::<AgentPanel>(cx)
493 })
494 .and_then(|panel| {
495 let cv = panel.read(cx).active_conversation_view()?;
496 let tv = cv.read(cx).active_thread()?;
497 Some(tv.read(cx).message_editor.clone())
498 })
499 .map(|editor| {
500 cx.observe(&editor, |_this, _editor, cx| {
501 cx.notify();
502 })
503 });
504 }
505
506 fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
507 let mw = self.multi_workspace.upgrade()?;
508 let workspace = mw.read(cx).workspace();
509 let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
510 let conversation_view = panel.read(cx).active_conversation_view()?;
511 let thread_view = conversation_view.read(cx).active_thread()?;
512 let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
513 let cleaned = Self::clean_mention_links(&raw);
514 let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
515 if text.is_empty() {
516 None
517 } else {
518 const MAX_CHARS: usize = 250;
519 if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
520 text.truncate(truncate_at);
521 }
522 Some(text.into())
523 }
524 }
525
526 fn clean_mention_links(input: &str) -> String {
527 let mut result = String::with_capacity(input.len());
528 let mut remaining = input;
529
530 while let Some(start) = remaining.find("[@") {
531 result.push_str(&remaining[..start]);
532 let after_bracket = &remaining[start + 1..]; // skip '['
533 if let Some(close_bracket) = after_bracket.find("](") {
534 let mention = &after_bracket[..close_bracket]; // "@something"
535 let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
536 if let Some(close_paren) = after_link_start.find(')') {
537 result.push_str(mention);
538 remaining = &after_link_start[close_paren + 1..];
539 continue;
540 }
541 }
542 // Couldn't parse full link syntax — emit the literal "[@" and move on.
543 result.push_str("[@");
544 remaining = &remaining[start + 2..];
545 }
546 result.push_str(remaining);
547 result
548 }
549
550 /// Rebuilds the sidebar contents from current workspace and thread state.
551 ///
552 /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
553 /// repository, then populates thread entries from the metadata store and
554 /// merges live thread info from active agent panels.
555 ///
556 /// Aim for a single forward pass over workspaces and threads plus an
557 /// O(T log T) sort. Avoid adding extra scans over the data.
558 ///
559 /// Properties:
560 ///
561 /// - Should always show every workspace in the multiworkspace
562 /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
563 /// - Should always show every thread, associated with each workspace in the multiworkspace
564 /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
565 fn rebuild_contents(&mut self, cx: &App) {
566 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
567 return;
568 };
569 let mw = multi_workspace.read(cx);
570 let workspaces = mw.workspaces().to_vec();
571 let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
572
573 let agent_server_store = workspaces
574 .first()
575 .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
576
577 let query = self.filter_editor.read(cx).text(cx);
578
579 // Re-derive agent_panel_visible from the active workspace so it stays
580 // correct after workspace switches.
581 self.agent_panel_visible = active_workspace
582 .as_ref()
583 .map_or(false, |ws| AgentPanel::is_visible(ws, cx));
584
585 // Derive active_thread_is_draft BEFORE focused_thread so we can
586 // use it as a guard below.
587 self.active_thread_is_draft = active_workspace
588 .as_ref()
589 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
590 .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
591
592 // Derive focused_thread from the active workspace's agent panel.
593 // Only update when the panel gives us a positive signal — if the
594 // panel returns None (e.g. still loading after a thread activation),
595 // keep the previous value so eager writes from user actions survive.
596 let panel_focused = active_workspace
597 .as_ref()
598 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
599 .and_then(|panel| {
600 panel
601 .read(cx)
602 .active_conversation_view()
603 .and_then(|cv| cv.read(cx).parent_id(cx))
604 });
605 if panel_focused.is_some() && !self.active_thread_is_draft {
606 self.focused_thread = panel_focused;
607 }
608
609 let previous = mem::take(&mut self.contents);
610
611 let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
612 .entries
613 .iter()
614 .filter_map(|entry| match entry {
615 ListEntry::Thread(thread) if thread.is_live => {
616 Some((thread.session_info.session_id.clone(), thread.status))
617 }
618 _ => None,
619 })
620 .collect();
621
622 let mut entries = Vec::new();
623 let mut notified_threads = previous.notified_threads;
624 let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
625 let mut project_header_indices: Vec<usize> = Vec::new();
626
627 // Use ProjectGroupBuilder to canonically group workspaces by their
628 // main git repository. This replaces the manual absorbed-workspace
629 // detection that was here before.
630 let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
631
632 let has_open_projects = workspaces
633 .iter()
634 .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
635
636 let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option<SharedString>) {
637 match &row.agent_id {
638 None => (Agent::NativeAgent, IconName::ZedAgent, None),
639 Some(id) => {
640 let custom_icon = agent_server_store
641 .as_ref()
642 .and_then(|store| store.read(cx).agent_icon(id));
643 (
644 Agent::Custom { id: id.clone() },
645 IconName::Terminal,
646 custom_icon,
647 )
648 }
649 }
650 };
651
652 for (group_name, group) in project_groups.groups() {
653 let path_list = group_name.path_list().clone();
654 if path_list.paths().is_empty() {
655 continue;
656 }
657
658 let label = group_name.display_name();
659
660 let is_collapsed = self.collapsed_groups.contains(&path_list);
661 let should_load_threads = !is_collapsed || !query.is_empty();
662
663 let is_active = active_workspace
664 .as_ref()
665 .is_some_and(|active| group.workspaces.contains(active));
666
667 // Pick a representative workspace for the group: prefer the active
668 // workspace if it belongs to this group, otherwise use the first.
669 //
670 // This is the workspace that will be activated by the project group
671 // header.
672 let representative_workspace = active_workspace
673 .as_ref()
674 .filter(|_| is_active)
675 .unwrap_or_else(|| group.first_workspace());
676
677 // Collect live thread infos from all workspaces in this group.
678 let live_infos: Vec<_> = group
679 .workspaces
680 .iter()
681 .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
682 .collect();
683
684 let mut threads: Vec<ThreadEntry> = Vec::new();
685 let mut has_running_threads = false;
686 let mut waiting_thread_count: usize = 0;
687
688 if should_load_threads {
689 let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
690 let thread_store = SidebarThreadMetadataStore::global(cx);
691
692 // Load threads from each workspace in the group.
693 for workspace in &group.workspaces {
694 let ws_path_list = workspace_path_list(workspace, cx);
695
696 // Determine if this workspace covers a git worktree (its
697 // path canonicalizes to the main repo, not itself). If so,
698 // threads from it get a worktree chip in the sidebar.
699 let worktree_info: Option<(SharedString, SharedString)> =
700 ws_path_list.paths().first().and_then(|path| {
701 let canonical = project_groups.canonicalize_path(path);
702 if canonical != path.as_path() {
703 let name =
704 linked_worktree_short_name(canonical, path).unwrap_or_default();
705 let full_path: SharedString = path.display().to_string().into();
706 Some((name, full_path))
707 } else {
708 None
709 }
710 });
711
712 let workspace_threads: Vec<_> = thread_store
713 .read(cx)
714 .entries_for_path(&ws_path_list)
715 .collect();
716 for thread in workspace_threads {
717 if !seen_session_ids.insert(thread.session_id.clone()) {
718 continue;
719 }
720 let (agent, icon, icon_from_external_svg) = resolve_agent(&thread);
721 threads.push(ThreadEntry {
722 agent,
723 session_info: acp_thread::AgentSessionInfo {
724 session_id: thread.session_id.clone(),
725 work_dirs: None,
726 title: Some(thread.title.clone()),
727 updated_at: Some(thread.updated_at),
728 created_at: thread.created_at,
729 meta: None,
730 },
731 icon,
732 icon_from_external_svg,
733 status: AgentThreadStatus::default(),
734 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
735 is_live: false,
736 is_background: false,
737 is_title_generating: false,
738 highlight_positions: Vec::new(),
739 worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()),
740 worktree_full_path: worktree_info
741 .as_ref()
742 .map(|(_, path)| path.clone()),
743 worktree_highlight_positions: Vec::new(),
744 diff_stats: DiffStats::default(),
745 });
746 }
747 }
748
749 // Load threads from linked git worktrees that don't have an
750 // open workspace in this group. Only include worktrees that
751 // belong to this group (not shared with another group).
752 let linked_worktree_path_lists = group
753 .workspaces
754 .iter()
755 .flat_map(|ws| root_repository_snapshots(ws, cx))
756 .filter(|snapshot| !snapshot.is_linked_worktree())
757 .flat_map(|snapshot| {
758 snapshot
759 .linked_worktrees()
760 .iter()
761 .filter(|wt| {
762 project_groups.group_owns_worktree(group, &path_list, &wt.path)
763 })
764 .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
765 .collect::<Vec<_>>()
766 });
767
768 for worktree_path_list in linked_worktree_path_lists {
769 for row in thread_store.read(cx).entries_for_path(&worktree_path_list) {
770 if !seen_session_ids.insert(row.session_id.clone()) {
771 continue;
772 }
773 let worktree_info = row.folder_paths.paths().first().and_then(|path| {
774 let canonical = project_groups.canonicalize_path(path);
775 if canonical != path.as_path() {
776 let name =
777 linked_worktree_short_name(canonical, path).unwrap_or_default();
778 let full_path: SharedString = path.display().to_string().into();
779 Some((name, full_path))
780 } else {
781 None
782 }
783 });
784 let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
785 threads.push(ThreadEntry {
786 agent,
787 session_info: acp_thread::AgentSessionInfo {
788 session_id: row.session_id.clone(),
789 work_dirs: None,
790 title: Some(row.title.clone()),
791 updated_at: Some(row.updated_at),
792 created_at: row.created_at,
793 meta: None,
794 },
795 icon,
796 icon_from_external_svg,
797 status: AgentThreadStatus::default(),
798 workspace: ThreadEntryWorkspace::Closed(row.folder_paths.clone()),
799 is_live: false,
800 is_background: false,
801 is_title_generating: false,
802 highlight_positions: Vec::new(),
803 worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()),
804 worktree_full_path: worktree_info.map(|(_, path)| path),
805 worktree_highlight_positions: Vec::new(),
806 diff_stats: DiffStats::default(),
807 });
808 }
809 }
810
811 // Build a lookup from live_infos and compute running/waiting
812 // counts in a single pass.
813 let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
814 HashMap::new();
815 for info in &live_infos {
816 live_info_by_session.insert(&info.session_id, info);
817 if info.status == AgentThreadStatus::Running {
818 has_running_threads = true;
819 }
820 if info.status == AgentThreadStatus::WaitingForConfirmation {
821 waiting_thread_count += 1;
822 }
823 }
824
825 // Merge live info into threads and update notification state
826 // in a single pass.
827 for thread in &mut threads {
828 if let Some(info) = live_info_by_session.get(&thread.session_info.session_id) {
829 thread.apply_active_info(info);
830 }
831
832 let session_id = &thread.session_info.session_id;
833
834 let is_thread_workspace_active = match &thread.workspace {
835 ThreadEntryWorkspace::Open(thread_workspace) => active_workspace
836 .as_ref()
837 .is_some_and(|active| active == thread_workspace),
838 ThreadEntryWorkspace::Closed(_) => false,
839 };
840
841 if thread.status == AgentThreadStatus::Completed
842 && !is_thread_workspace_active
843 && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
844 {
845 notified_threads.insert(session_id.clone());
846 }
847
848 if is_thread_workspace_active && !thread.is_background {
849 notified_threads.remove(session_id);
850 }
851 }
852
853 threads.sort_by(|a, b| {
854 let a_time = a.session_info.created_at.or(a.session_info.updated_at);
855 let b_time = b.session_info.created_at.or(b.session_info.updated_at);
856 b_time.cmp(&a_time)
857 });
858 } else {
859 for info in live_infos {
860 if info.status == AgentThreadStatus::Running {
861 has_running_threads = true;
862 }
863 if info.status == AgentThreadStatus::WaitingForConfirmation {
864 waiting_thread_count += 1;
865 }
866 }
867 }
868
869 if !query.is_empty() {
870 let workspace_highlight_positions =
871 fuzzy_match_positions(&query, &label).unwrap_or_default();
872 let workspace_matched = !workspace_highlight_positions.is_empty();
873
874 let mut matched_threads: Vec<ThreadEntry> = Vec::new();
875 for mut thread in threads {
876 let title = thread
877 .session_info
878 .title
879 .as_ref()
880 .map(|s| s.as_ref())
881 .unwrap_or("");
882 if let Some(positions) = fuzzy_match_positions(&query, title) {
883 thread.highlight_positions = positions;
884 }
885 if let Some(worktree_name) = &thread.worktree_name {
886 if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
887 thread.worktree_highlight_positions = positions;
888 }
889 }
890 let worktree_matched = !thread.worktree_highlight_positions.is_empty();
891 if workspace_matched
892 || !thread.highlight_positions.is_empty()
893 || worktree_matched
894 {
895 matched_threads.push(thread);
896 }
897 }
898
899 if matched_threads.is_empty() && !workspace_matched {
900 continue;
901 }
902
903 project_header_indices.push(entries.len());
904 entries.push(ListEntry::ProjectHeader {
905 path_list: path_list.clone(),
906 label,
907 workspace: representative_workspace.clone(),
908 highlight_positions: workspace_highlight_positions,
909 has_running_threads,
910 waiting_thread_count,
911 is_active,
912 });
913
914 for thread in matched_threads {
915 current_session_ids.insert(thread.session_info.session_id.clone());
916 entries.push(thread.into());
917 }
918 } else {
919 let thread_count = threads.len();
920 let is_draft_for_workspace = self.agent_panel_visible
921 && self.active_thread_is_draft
922 && self.focused_thread.is_none()
923 && is_active;
924
925 let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace;
926
927 project_header_indices.push(entries.len());
928 entries.push(ListEntry::ProjectHeader {
929 path_list: path_list.clone(),
930 label,
931 workspace: representative_workspace.clone(),
932 highlight_positions: Vec::new(),
933 has_running_threads,
934 waiting_thread_count,
935 is_active,
936 });
937
938 if is_collapsed {
939 continue;
940 }
941
942 if show_new_thread_entry {
943 entries.push(ListEntry::NewThread {
944 path_list: path_list.clone(),
945 workspace: representative_workspace.clone(),
946 is_active_draft: is_draft_for_workspace,
947 });
948 }
949
950 let total = threads.len();
951
952 let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
953 let threads_to_show =
954 DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
955 let count = threads_to_show.min(total);
956
957 let mut promoted_threads: HashSet<acp::SessionId> = HashSet::new();
958
959 // Build visible entries in a single pass. Threads within
960 // the cutoff are always shown. Threads beyond it are shown
961 // only if they should be promoted (running, waiting, or
962 // focused)
963 for (index, thread) in threads.into_iter().enumerate() {
964 let is_hidden = index >= count;
965
966 let session_id = &thread.session_info.session_id;
967 if is_hidden {
968 let is_promoted = thread.status == AgentThreadStatus::Running
969 || thread.status == AgentThreadStatus::WaitingForConfirmation
970 || notified_threads.contains(session_id)
971 || self
972 .focused_thread
973 .as_ref()
974 .is_some_and(|id| id == session_id);
975 if is_promoted {
976 promoted_threads.insert(session_id.clone());
977 }
978 if !promoted_threads.contains(session_id) {
979 continue;
980 }
981 }
982
983 current_session_ids.insert(session_id.clone());
984 entries.push(thread.into());
985 }
986
987 let visible = count + promoted_threads.len();
988 let is_fully_expanded = visible >= total;
989
990 if total > DEFAULT_THREADS_SHOWN {
991 entries.push(ListEntry::ViewMore {
992 path_list: path_list.clone(),
993 is_fully_expanded,
994 });
995 }
996 }
997 }
998
999 // Prune stale notifications using the session IDs we collected during
1000 // the build pass (no extra scan needed).
1001 notified_threads.retain(|id| current_session_ids.contains(id));
1002
1003 self.contents = SidebarContents {
1004 entries,
1005 notified_threads,
1006 project_header_indices,
1007 has_open_projects,
1008 };
1009 }
1010
1011 /// Rebuilds the sidebar's visible entries from already-cached state.
1012 fn update_entries(&mut self, cx: &mut Context<Self>) {
1013 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1014 return;
1015 };
1016 if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1017 return;
1018 }
1019
1020 let had_notifications = self.has_notifications(cx);
1021 let scroll_position = self.list_state.logical_scroll_top();
1022
1023 self.rebuild_contents(cx);
1024
1025 self.list_state.reset(self.contents.entries.len());
1026 self.list_state.scroll_to(scroll_position);
1027
1028 if had_notifications != self.has_notifications(cx) {
1029 multi_workspace.update(cx, |_, cx| {
1030 cx.notify();
1031 });
1032 }
1033
1034 cx.notify();
1035 }
1036
1037 fn select_first_entry(&mut self) {
1038 self.selection = self
1039 .contents
1040 .entries
1041 .iter()
1042 .position(|entry| matches!(entry, ListEntry::Thread(_)))
1043 .or_else(|| {
1044 if self.contents.entries.is_empty() {
1045 None
1046 } else {
1047 Some(0)
1048 }
1049 });
1050 }
1051
1052 fn render_list_entry(
1053 &mut self,
1054 ix: usize,
1055 window: &mut Window,
1056 cx: &mut Context<Self>,
1057 ) -> AnyElement {
1058 let Some(entry) = self.contents.entries.get(ix) else {
1059 return div().into_any_element();
1060 };
1061 let is_focused = self.focus_handle.is_focused(window);
1062 // is_selected means the keyboard selector is here.
1063 let is_selected = is_focused && self.selection == Some(ix);
1064
1065 let is_group_header_after_first =
1066 ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1067
1068 let rendered = match entry {
1069 ListEntry::ProjectHeader {
1070 path_list,
1071 label,
1072 workspace,
1073 highlight_positions,
1074 has_running_threads,
1075 waiting_thread_count,
1076 is_active,
1077 } => self.render_project_header(
1078 ix,
1079 false,
1080 path_list,
1081 label,
1082 workspace,
1083 highlight_positions,
1084 *has_running_threads,
1085 *waiting_thread_count,
1086 *is_active,
1087 is_selected,
1088 cx,
1089 ),
1090 ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
1091 ListEntry::ViewMore {
1092 path_list,
1093 is_fully_expanded,
1094 } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
1095 ListEntry::NewThread {
1096 path_list,
1097 workspace,
1098 is_active_draft,
1099 } => {
1100 self.render_new_thread(ix, path_list, workspace, *is_active_draft, is_selected, cx)
1101 }
1102 };
1103
1104 if is_group_header_after_first {
1105 v_flex()
1106 .w_full()
1107 .border_t_1()
1108 .border_color(cx.theme().colors().border.opacity(0.5))
1109 .child(rendered)
1110 .into_any_element()
1111 } else {
1112 rendered
1113 }
1114 }
1115
1116 fn render_project_header(
1117 &self,
1118 ix: usize,
1119 is_sticky: bool,
1120 path_list: &PathList,
1121 label: &SharedString,
1122 workspace: &Entity<Workspace>,
1123 highlight_positions: &[usize],
1124 has_running_threads: bool,
1125 waiting_thread_count: usize,
1126 is_active: bool,
1127 is_selected: bool,
1128 cx: &mut Context<Self>,
1129 ) -> AnyElement {
1130 let id_prefix = if is_sticky { "sticky-" } else { "" };
1131 let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1132 let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1133
1134 let is_collapsed = self.collapsed_groups.contains(path_list);
1135 let disclosure_icon = if is_collapsed {
1136 IconName::ChevronRight
1137 } else {
1138 IconName::ChevronDown
1139 };
1140
1141 let has_new_thread_entry = self
1142 .contents
1143 .entries
1144 .get(ix + 1)
1145 .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
1146 let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
1147
1148 let workspace_for_remove = workspace.clone();
1149 let workspace_for_menu = workspace.clone();
1150 let workspace_for_open = workspace.clone();
1151
1152 let path_list_for_toggle = path_list.clone();
1153 let path_list_for_collapse = path_list.clone();
1154 let view_more_expanded = self.expanded_groups.contains_key(path_list);
1155
1156 let label = if highlight_positions.is_empty() {
1157 Label::new(label.clone())
1158 .color(Color::Muted)
1159 .into_any_element()
1160 } else {
1161 HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1162 .color(Color::Muted)
1163 .into_any_element()
1164 };
1165
1166 let color = cx.theme().colors();
1167 let hover_color = color
1168 .element_active
1169 .blend(color.element_background.opacity(0.2));
1170
1171 h_flex()
1172 .id(id)
1173 .group(&group_name)
1174 .h(Tab::content_height(cx))
1175 .w_full()
1176 .pl_1p5()
1177 .pr_1()
1178 .border_1()
1179 .map(|this| {
1180 if is_selected {
1181 this.border_color(color.border_focused)
1182 } else {
1183 this.border_color(gpui::transparent_black())
1184 }
1185 })
1186 .justify_between()
1187 .hover(|s| s.bg(hover_color))
1188 .child(
1189 h_flex()
1190 .relative()
1191 .min_w_0()
1192 .w_full()
1193 .gap_1p5()
1194 .child(
1195 h_flex().size_4().flex_none().justify_center().child(
1196 Icon::new(disclosure_icon)
1197 .size(IconSize::Small)
1198 .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))),
1199 ),
1200 )
1201 .child(label)
1202 .when(is_collapsed, |this| {
1203 this.when(has_running_threads, |this| {
1204 this.child(
1205 Icon::new(IconName::LoadCircle)
1206 .size(IconSize::XSmall)
1207 .color(Color::Muted)
1208 .with_rotate_animation(2),
1209 )
1210 })
1211 .when(waiting_thread_count > 0, |this| {
1212 let tooltip_text = if waiting_thread_count == 1 {
1213 "1 thread is waiting for confirmation".to_string()
1214 } else {
1215 format!(
1216 "{waiting_thread_count} threads are waiting for confirmation",
1217 )
1218 };
1219 this.child(
1220 div()
1221 .id(format!("{id_prefix}waiting-indicator-{ix}"))
1222 .child(
1223 Icon::new(IconName::Warning)
1224 .size(IconSize::XSmall)
1225 .color(Color::Warning),
1226 )
1227 .tooltip(Tooltip::text(tooltip_text)),
1228 )
1229 })
1230 }),
1231 )
1232 .child({
1233 let workspace_for_new_thread = workspace.clone();
1234 let path_list_for_new_thread = path_list.clone();
1235
1236 h_flex()
1237 .when(self.project_header_menu_ix != Some(ix), |this| {
1238 this.visible_on_hover(group_name)
1239 })
1240 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1241 cx.stop_propagation();
1242 })
1243 .child(self.render_project_header_menu(
1244 ix,
1245 id_prefix,
1246 &workspace_for_menu,
1247 &workspace_for_remove,
1248 cx,
1249 ))
1250 .when(view_more_expanded && !is_collapsed, |this| {
1251 this.child(
1252 IconButton::new(
1253 SharedString::from(format!(
1254 "{id_prefix}project-header-collapse-{ix}",
1255 )),
1256 IconName::ListCollapse,
1257 )
1258 .icon_size(IconSize::Small)
1259 .icon_color(Color::Muted)
1260 .tooltip(Tooltip::text("Collapse Displayed Threads"))
1261 .on_click(cx.listener({
1262 let path_list_for_collapse = path_list_for_collapse.clone();
1263 move |this, _, _window, cx| {
1264 this.selection = None;
1265 this.expanded_groups.remove(&path_list_for_collapse);
1266 this.update_entries(cx);
1267 }
1268 })),
1269 )
1270 })
1271 .when(!is_active, |this| {
1272 this.child(
1273 IconButton::new(
1274 SharedString::from(format!(
1275 "{id_prefix}project-header-open-workspace-{ix}",
1276 )),
1277 IconName::Focus,
1278 )
1279 .icon_size(IconSize::Small)
1280 .icon_color(Color::Muted)
1281 .tooltip(Tooltip::text("Activate Workspace"))
1282 .on_click(cx.listener({
1283 move |this, _, window, cx| {
1284 this.focused_thread = None;
1285 if let Some(multi_workspace) = this.multi_workspace.upgrade() {
1286 multi_workspace.update(cx, |multi_workspace, cx| {
1287 multi_workspace
1288 .activate(workspace_for_open.clone(), cx);
1289 });
1290 }
1291 if AgentPanel::is_visible(&workspace_for_open, cx) {
1292 workspace_for_open.update(cx, |workspace, cx| {
1293 workspace.focus_panel::<AgentPanel>(window, cx);
1294 });
1295 }
1296 }
1297 })),
1298 )
1299 })
1300 .when(show_new_thread_button, |this| {
1301 this.child(
1302 IconButton::new(
1303 SharedString::from(format!(
1304 "{id_prefix}project-header-new-thread-{ix}",
1305 )),
1306 IconName::Plus,
1307 )
1308 .icon_size(IconSize::Small)
1309 .icon_color(Color::Muted)
1310 .tooltip(Tooltip::text("New Thread"))
1311 .on_click(cx.listener({
1312 let workspace_for_new_thread = workspace_for_new_thread.clone();
1313 let path_list_for_new_thread = path_list_for_new_thread.clone();
1314 move |this, _, window, cx| {
1315 // Uncollapse the group if collapsed so
1316 // the new-thread entry becomes visible.
1317 this.collapsed_groups.remove(&path_list_for_new_thread);
1318 this.selection = None;
1319 this.create_new_thread(&workspace_for_new_thread, window, cx);
1320 }
1321 })),
1322 )
1323 })
1324 })
1325 .on_click(cx.listener(move |this, _, window, cx| {
1326 this.selection = None;
1327 this.toggle_collapse(&path_list_for_toggle, window, cx);
1328 }))
1329 .into_any_element()
1330 }
1331
1332 fn render_project_header_menu(
1333 &self,
1334 ix: usize,
1335 id_prefix: &str,
1336 workspace: &Entity<Workspace>,
1337 workspace_for_remove: &Entity<Workspace>,
1338 cx: &mut Context<Self>,
1339 ) -> impl IntoElement {
1340 let workspace_for_menu = workspace.clone();
1341 let workspace_for_remove = workspace_for_remove.clone();
1342 let multi_workspace = self.multi_workspace.clone();
1343 let this = cx.weak_entity();
1344
1345 PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1346 .on_open(Rc::new({
1347 let this = this.clone();
1348 move |_window, cx| {
1349 this.update(cx, |sidebar, cx| {
1350 sidebar.project_header_menu_ix = Some(ix);
1351 cx.notify();
1352 })
1353 .ok();
1354 }
1355 }))
1356 .menu(move |window, cx| {
1357 let workspace = workspace_for_menu.clone();
1358 let workspace_for_remove = workspace_for_remove.clone();
1359 let multi_workspace = multi_workspace.clone();
1360
1361 let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
1362 let worktrees: Vec<_> = workspace
1363 .read(cx)
1364 .visible_worktrees(cx)
1365 .map(|worktree| {
1366 let worktree_read = worktree.read(cx);
1367 let id = worktree_read.id();
1368 let name: SharedString =
1369 worktree_read.root_name().as_unix_str().to_string().into();
1370 (id, name)
1371 })
1372 .collect();
1373
1374 let worktree_count = worktrees.len();
1375
1376 let mut menu = menu
1377 .header("Project Folders")
1378 .end_slot_action(Box::new(menu::EndSlot));
1379
1380 for (worktree_id, name) in &worktrees {
1381 let worktree_id = *worktree_id;
1382 let workspace_for_worktree = workspace.clone();
1383 let workspace_for_remove_worktree = workspace_for_remove.clone();
1384 let multi_workspace_for_worktree = multi_workspace.clone();
1385
1386 let remove_handler = move |window: &mut Window, cx: &mut App| {
1387 if worktree_count <= 1 {
1388 if let Some(mw) = multi_workspace_for_worktree.upgrade() {
1389 let ws = workspace_for_remove_worktree.clone();
1390 mw.update(cx, |multi_workspace, cx| {
1391 if let Some(index) = multi_workspace
1392 .workspaces()
1393 .iter()
1394 .position(|w| *w == ws)
1395 {
1396 multi_workspace.remove_workspace(index, window, cx);
1397 }
1398 });
1399 }
1400 } else {
1401 workspace_for_worktree.update(cx, |workspace, cx| {
1402 workspace.project().update(cx, |project, cx| {
1403 project.remove_worktree(worktree_id, cx);
1404 });
1405 });
1406 }
1407 };
1408
1409 menu = menu.entry_with_end_slot_on_hover(
1410 name.clone(),
1411 None,
1412 |_, _| {},
1413 IconName::Close,
1414 "Remove Folder".into(),
1415 remove_handler,
1416 );
1417 }
1418
1419 let workspace_for_add = workspace.clone();
1420 let multi_workspace_for_add = multi_workspace.clone();
1421 let menu = menu.separator().entry(
1422 "Add Folder to Project",
1423 Some(Box::new(AddFolderToProject)),
1424 move |window, cx| {
1425 if let Some(mw) = multi_workspace_for_add.upgrade() {
1426 mw.update(cx, |mw, cx| {
1427 mw.activate(workspace_for_add.clone(), cx);
1428 });
1429 }
1430 workspace_for_add.update(cx, |workspace, cx| {
1431 workspace.add_folder_to_project(&AddFolderToProject, window, cx);
1432 });
1433 },
1434 );
1435
1436 let workspace_count = multi_workspace
1437 .upgrade()
1438 .map_or(0, |mw| mw.read(cx).workspaces().len());
1439 let menu = if workspace_count > 1 {
1440 let workspace_for_move = workspace.clone();
1441 let multi_workspace_for_move = multi_workspace.clone();
1442 menu.entry(
1443 "Move to New Window",
1444 Some(Box::new(
1445 zed_actions::agents_sidebar::MoveWorkspaceToNewWindow,
1446 )),
1447 move |window, cx| {
1448 if let Some(mw) = multi_workspace_for_move.upgrade() {
1449 mw.update(cx, |multi_workspace, cx| {
1450 if let Some(index) = multi_workspace
1451 .workspaces()
1452 .iter()
1453 .position(|w| *w == workspace_for_move)
1454 {
1455 multi_workspace
1456 .move_workspace_to_new_window(index, window, cx);
1457 }
1458 });
1459 }
1460 },
1461 )
1462 } else {
1463 menu
1464 };
1465
1466 let workspace_for_remove = workspace_for_remove.clone();
1467 let multi_workspace_for_remove = multi_workspace.clone();
1468 menu.separator()
1469 .entry("Remove Project", None, move |window, cx| {
1470 if let Some(mw) = multi_workspace_for_remove.upgrade() {
1471 let ws = workspace_for_remove.clone();
1472 mw.update(cx, |multi_workspace, cx| {
1473 if let Some(index) =
1474 multi_workspace.workspaces().iter().position(|w| *w == ws)
1475 {
1476 multi_workspace.remove_workspace(index, window, cx);
1477 }
1478 });
1479 }
1480 })
1481 });
1482
1483 let this = this.clone();
1484 window
1485 .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1486 this.update(cx, |sidebar, cx| {
1487 sidebar.project_header_menu_ix = None;
1488 cx.notify();
1489 })
1490 .ok();
1491 })
1492 .detach();
1493
1494 Some(menu)
1495 })
1496 .trigger(
1497 IconButton::new(
1498 SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1499 IconName::Ellipsis,
1500 )
1501 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1502 .icon_size(IconSize::Small)
1503 .icon_color(Color::Muted),
1504 )
1505 .anchor(gpui::Corner::TopRight)
1506 .offset(gpui::Point {
1507 x: px(0.),
1508 y: px(1.),
1509 })
1510 }
1511
1512 fn render_sticky_header(
1513 &self,
1514 window: &mut Window,
1515 cx: &mut Context<Self>,
1516 ) -> Option<AnyElement> {
1517 let scroll_top = self.list_state.logical_scroll_top();
1518
1519 let &header_idx = self
1520 .contents
1521 .project_header_indices
1522 .iter()
1523 .rev()
1524 .find(|&&idx| idx <= scroll_top.item_ix)?;
1525
1526 let needs_sticky = header_idx < scroll_top.item_ix
1527 || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1528
1529 if !needs_sticky {
1530 return None;
1531 }
1532
1533 let ListEntry::ProjectHeader {
1534 path_list,
1535 label,
1536 workspace,
1537 highlight_positions,
1538 has_running_threads,
1539 waiting_thread_count,
1540 is_active,
1541 } = self.contents.entries.get(header_idx)?
1542 else {
1543 return None;
1544 };
1545
1546 let is_focused = self.focus_handle.is_focused(window);
1547 let is_selected = is_focused && self.selection == Some(header_idx);
1548
1549 let header_element = self.render_project_header(
1550 header_idx,
1551 true,
1552 &path_list,
1553 &label,
1554 workspace,
1555 &highlight_positions,
1556 *has_running_threads,
1557 *waiting_thread_count,
1558 *is_active,
1559 is_selected,
1560 cx,
1561 );
1562
1563 let top_offset = self
1564 .contents
1565 .project_header_indices
1566 .iter()
1567 .find(|&&idx| idx > header_idx)
1568 .and_then(|&next_idx| {
1569 let bounds = self.list_state.bounds_for_item(next_idx)?;
1570 let viewport = self.list_state.viewport_bounds();
1571 let y_in_viewport = bounds.origin.y - viewport.origin.y;
1572 let header_height = bounds.size.height;
1573 (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1574 })
1575 .unwrap_or(px(0.));
1576
1577 let color = cx.theme().colors();
1578 let background = color
1579 .title_bar_background
1580 .blend(color.panel_background.opacity(0.2));
1581
1582 let element = v_flex()
1583 .absolute()
1584 .top(top_offset)
1585 .left_0()
1586 .w_full()
1587 .bg(background)
1588 .border_b_1()
1589 .border_color(color.border.opacity(0.5))
1590 .child(header_element)
1591 .shadow_xs()
1592 .into_any_element();
1593
1594 Some(element)
1595 }
1596
1597 fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1598 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1599 return;
1600 };
1601 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
1602
1603 // Collect all worktree paths that are currently listed by any main
1604 // repo open in any workspace.
1605 let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
1606 for workspace in &workspaces {
1607 for snapshot in root_repository_snapshots(workspace, cx) {
1608 if snapshot.is_linked_worktree() {
1609 continue;
1610 }
1611 for git_worktree in snapshot.linked_worktrees() {
1612 known_worktree_paths.insert(git_worktree.path.to_path_buf());
1613 }
1614 }
1615 }
1616
1617 // Find workspaces that consist of exactly one root folder which is a
1618 // stale worktree checkout. Multi-root workspaces are never pruned —
1619 // losing one worktree shouldn't destroy a workspace that also
1620 // contains other folders.
1621 let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
1622 for workspace in &workspaces {
1623 let path_list = workspace_path_list(workspace, cx);
1624 if path_list.paths().len() != 1 {
1625 continue;
1626 }
1627 let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| {
1628 snapshot.is_linked_worktree()
1629 && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
1630 });
1631 if should_prune {
1632 to_remove.push(workspace.clone());
1633 }
1634 }
1635
1636 for workspace in &to_remove {
1637 self.remove_workspace(workspace, window, cx);
1638 }
1639 }
1640
1641 fn remove_workspace(
1642 &mut self,
1643 workspace: &Entity<Workspace>,
1644 window: &mut Window,
1645 cx: &mut Context<Self>,
1646 ) {
1647 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1648 return;
1649 };
1650
1651 multi_workspace.update(cx, |multi_workspace, cx| {
1652 let Some(index) = multi_workspace
1653 .workspaces()
1654 .iter()
1655 .position(|w| w == workspace)
1656 else {
1657 return;
1658 };
1659 multi_workspace.remove_workspace(index, window, cx);
1660 });
1661 }
1662
1663 fn toggle_collapse(
1664 &mut self,
1665 path_list: &PathList,
1666 _window: &mut Window,
1667 cx: &mut Context<Self>,
1668 ) {
1669 if self.collapsed_groups.contains(path_list) {
1670 self.collapsed_groups.remove(path_list);
1671 } else {
1672 self.collapsed_groups.insert(path_list.clone());
1673 }
1674 self.update_entries(cx);
1675 }
1676
1677 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
1678 let mut dispatch_context = KeyContext::new_with_defaults();
1679 dispatch_context.add("ThreadsSidebar");
1680 dispatch_context.add("menu");
1681
1682 let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) {
1683 "searching"
1684 } else {
1685 "not_searching"
1686 };
1687
1688 dispatch_context.add(identifier);
1689 dispatch_context
1690 }
1691
1692 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1693 if !self.focus_handle.is_focused(window) {
1694 return;
1695 }
1696
1697 if let SidebarView::Archive(archive) = &self.view {
1698 let has_selection = archive.read(cx).has_selection();
1699 if !has_selection {
1700 archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1701 }
1702 } else if self.selection.is_none() {
1703 self.filter_editor.focus_handle(cx).focus(window, cx);
1704 }
1705 }
1706
1707 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1708 if self.reset_filter_editor_text(window, cx) {
1709 self.update_entries(cx);
1710 } else {
1711 self.selection = None;
1712 self.filter_editor.focus_handle(cx).focus(window, cx);
1713 cx.notify();
1714 }
1715 }
1716
1717 fn focus_sidebar_filter(
1718 &mut self,
1719 _: &FocusSidebarFilter,
1720 window: &mut Window,
1721 cx: &mut Context<Self>,
1722 ) {
1723 self.selection = None;
1724 if let SidebarView::Archive(archive) = &self.view {
1725 archive.update(cx, |view, cx| {
1726 view.clear_selection();
1727 view.focus_filter_editor(window, cx);
1728 });
1729 } else {
1730 self.filter_editor.focus_handle(cx).focus(window, cx);
1731 }
1732
1733 // When vim mode is active, the editor defaults to normal mode which
1734 // blocks text input. Switch to insert mode so the user can type
1735 // immediately.
1736 if vim_mode_setting::VimModeSetting::get_global(cx).0 {
1737 if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
1738 window.dispatch_action(action, cx);
1739 }
1740 }
1741
1742 cx.notify();
1743 }
1744
1745 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1746 self.filter_editor.update(cx, |editor, cx| {
1747 if editor.buffer().read(cx).len(cx).0 > 0 {
1748 editor.set_text("", window, cx);
1749 true
1750 } else {
1751 false
1752 }
1753 })
1754 }
1755
1756 fn has_filter_query(&self, cx: &App) -> bool {
1757 !self.filter_editor.read(cx).text(cx).is_empty()
1758 }
1759
1760 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1761 self.select_next(&SelectNext, window, cx);
1762 if self.selection.is_some() {
1763 self.focus_handle.focus(window, cx);
1764 }
1765 }
1766
1767 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1768 self.select_previous(&SelectPrevious, window, cx);
1769 if self.selection.is_some() {
1770 self.focus_handle.focus(window, cx);
1771 }
1772 }
1773
1774 fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1775 if self.selection.is_none() {
1776 self.select_next(&SelectNext, window, cx);
1777 }
1778 if self.selection.is_some() {
1779 self.focus_handle.focus(window, cx);
1780 }
1781 }
1782
1783 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1784 let next = match self.selection {
1785 Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1786 Some(_) if !self.contents.entries.is_empty() => 0,
1787 None if !self.contents.entries.is_empty() => 0,
1788 _ => return,
1789 };
1790 self.selection = Some(next);
1791 self.list_state.scroll_to_reveal_item(next);
1792 cx.notify();
1793 }
1794
1795 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1796 match self.selection {
1797 Some(0) => {
1798 self.selection = None;
1799 self.filter_editor.focus_handle(cx).focus(window, cx);
1800 cx.notify();
1801 }
1802 Some(ix) => {
1803 self.selection = Some(ix - 1);
1804 self.list_state.scroll_to_reveal_item(ix - 1);
1805 cx.notify();
1806 }
1807 None if !self.contents.entries.is_empty() => {
1808 let last = self.contents.entries.len() - 1;
1809 self.selection = Some(last);
1810 self.list_state.scroll_to_reveal_item(last);
1811 cx.notify();
1812 }
1813 None => {}
1814 }
1815 }
1816
1817 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1818 if !self.contents.entries.is_empty() {
1819 self.selection = Some(0);
1820 self.list_state.scroll_to_reveal_item(0);
1821 cx.notify();
1822 }
1823 }
1824
1825 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1826 if let Some(last) = self.contents.entries.len().checked_sub(1) {
1827 self.selection = Some(last);
1828 self.list_state.scroll_to_reveal_item(last);
1829 cx.notify();
1830 }
1831 }
1832
1833 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1834 let Some(ix) = self.selection else { return };
1835 let Some(entry) = self.contents.entries.get(ix) else {
1836 return;
1837 };
1838
1839 match entry {
1840 ListEntry::ProjectHeader { path_list, .. } => {
1841 let path_list = path_list.clone();
1842 self.toggle_collapse(&path_list, window, cx);
1843 }
1844 ListEntry::Thread(thread) => {
1845 let session_info = thread.session_info.clone();
1846 match &thread.workspace {
1847 ThreadEntryWorkspace::Open(workspace) => {
1848 let workspace = workspace.clone();
1849 self.activate_thread(
1850 thread.agent.clone(),
1851 session_info,
1852 &workspace,
1853 window,
1854 cx,
1855 );
1856 }
1857 ThreadEntryWorkspace::Closed(path_list) => {
1858 self.open_workspace_and_activate_thread(
1859 thread.agent.clone(),
1860 session_info,
1861 path_list.clone(),
1862 window,
1863 cx,
1864 );
1865 }
1866 }
1867 }
1868 ListEntry::ViewMore {
1869 path_list,
1870 is_fully_expanded,
1871 ..
1872 } => {
1873 let path_list = path_list.clone();
1874 if *is_fully_expanded {
1875 self.expanded_groups.remove(&path_list);
1876 } else {
1877 let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1878 self.expanded_groups.insert(path_list, current + 1);
1879 }
1880 self.update_entries(cx);
1881 }
1882 ListEntry::NewThread { workspace, .. } => {
1883 let workspace = workspace.clone();
1884 self.create_new_thread(&workspace, window, cx);
1885 }
1886 }
1887 }
1888
1889 fn find_workspace_across_windows(
1890 &self,
1891 cx: &App,
1892 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1893 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
1894 cx.windows()
1895 .into_iter()
1896 .filter_map(|window| window.downcast::<MultiWorkspace>())
1897 .find_map(|window| {
1898 let workspace = window.read(cx).ok().and_then(|multi_workspace| {
1899 multi_workspace
1900 .workspaces()
1901 .iter()
1902 .find(|workspace| predicate(workspace, cx))
1903 .cloned()
1904 })?;
1905 Some((window, workspace))
1906 })
1907 }
1908
1909 fn find_workspace_in_current_window(
1910 &self,
1911 cx: &App,
1912 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1913 ) -> Option<Entity<Workspace>> {
1914 self.multi_workspace.upgrade().and_then(|multi_workspace| {
1915 multi_workspace
1916 .read(cx)
1917 .workspaces()
1918 .iter()
1919 .find(|workspace| predicate(workspace, cx))
1920 .cloned()
1921 })
1922 }
1923
1924 fn load_agent_thread_in_workspace(
1925 workspace: &Entity<Workspace>,
1926 agent: Agent,
1927 session_info: acp_thread::AgentSessionInfo,
1928 window: &mut Window,
1929 cx: &mut App,
1930 ) {
1931 workspace.update(cx, |workspace, cx| {
1932 workspace.open_panel::<AgentPanel>(window, cx);
1933 });
1934
1935 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1936 agent_panel.update(cx, |panel, cx| {
1937 panel.load_agent_thread(
1938 agent,
1939 session_info.session_id,
1940 session_info.work_dirs,
1941 session_info.title,
1942 true,
1943 window,
1944 cx,
1945 );
1946 });
1947 }
1948 }
1949
1950 fn activate_thread_locally(
1951 &mut self,
1952 agent: Agent,
1953 session_info: acp_thread::AgentSessionInfo,
1954 workspace: &Entity<Workspace>,
1955 window: &mut Window,
1956 cx: &mut Context<Self>,
1957 ) {
1958 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1959 return;
1960 };
1961
1962 // Set focused_thread eagerly so the sidebar highlight updates
1963 // immediately, rather than waiting for a deferred AgentPanel
1964 // event which can race with ActiveWorkspaceChanged clearing it.
1965 self.focused_thread = Some(session_info.session_id.clone());
1966
1967 multi_workspace.update(cx, |multi_workspace, cx| {
1968 multi_workspace.activate(workspace.clone(), cx);
1969 });
1970
1971 Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx);
1972
1973 self.update_entries(cx);
1974 }
1975
1976 fn activate_thread_in_other_window(
1977 &self,
1978 agent: Agent,
1979 session_info: acp_thread::AgentSessionInfo,
1980 workspace: Entity<Workspace>,
1981 target_window: WindowHandle<MultiWorkspace>,
1982 cx: &mut Context<Self>,
1983 ) {
1984 let target_session_id = session_info.session_id.clone();
1985
1986 let activated = target_window
1987 .update(cx, |multi_workspace, window, cx| {
1988 window.activate_window();
1989 multi_workspace.activate(workspace.clone(), cx);
1990 Self::load_agent_thread_in_workspace(&workspace, agent, session_info, window, cx);
1991 })
1992 .log_err()
1993 .is_some();
1994
1995 if activated {
1996 if let Some(target_sidebar) = target_window
1997 .read(cx)
1998 .ok()
1999 .and_then(|multi_workspace| {
2000 multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2001 })
2002 .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2003 {
2004 target_sidebar.update(cx, |sidebar, cx| {
2005 sidebar.focused_thread = Some(target_session_id);
2006 sidebar.update_entries(cx);
2007 });
2008 }
2009 }
2010 }
2011
2012 fn activate_thread(
2013 &mut self,
2014 agent: Agent,
2015 session_info: acp_thread::AgentSessionInfo,
2016 workspace: &Entity<Workspace>,
2017 window: &mut Window,
2018 cx: &mut Context<Self>,
2019 ) {
2020 if self
2021 .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2022 .is_some()
2023 {
2024 self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2025 return;
2026 }
2027
2028 let Some((target_window, workspace)) =
2029 self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2030 else {
2031 return;
2032 };
2033
2034 self.activate_thread_in_other_window(agent, session_info, workspace, target_window, cx);
2035 }
2036
2037 fn open_workspace_and_activate_thread(
2038 &mut self,
2039 agent: Agent,
2040 session_info: acp_thread::AgentSessionInfo,
2041 path_list: PathList,
2042 window: &mut Window,
2043 cx: &mut Context<Self>,
2044 ) {
2045 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2046 return;
2047 };
2048
2049 let paths: Vec<std::path::PathBuf> =
2050 path_list.paths().iter().map(|p| p.to_path_buf()).collect();
2051
2052 let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
2053
2054 cx.spawn_in(window, async move |this, cx| {
2055 let workspace = open_task.await?;
2056
2057 this.update_in(cx, |this, window, cx| {
2058 this.activate_thread(agent, session_info, &workspace, window, cx);
2059 })?;
2060 anyhow::Ok(())
2061 })
2062 .detach_and_log_err(cx);
2063 }
2064
2065 fn find_current_workspace_for_path_list(
2066 &self,
2067 path_list: &PathList,
2068 cx: &App,
2069 ) -> Option<Entity<Workspace>> {
2070 self.find_workspace_in_current_window(cx, |workspace, cx| {
2071 workspace_path_list(workspace, cx).paths() == path_list.paths()
2072 })
2073 }
2074
2075 fn find_open_workspace_for_path_list(
2076 &self,
2077 path_list: &PathList,
2078 cx: &App,
2079 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2080 self.find_workspace_across_windows(cx, |workspace, cx| {
2081 workspace_path_list(workspace, cx).paths() == path_list.paths()
2082 })
2083 }
2084
2085 fn activate_archived_thread(
2086 &mut self,
2087 agent: Agent,
2088 session_info: acp_thread::AgentSessionInfo,
2089 window: &mut Window,
2090 cx: &mut Context<Self>,
2091 ) {
2092 // Eagerly save thread metadata so that the sidebar is updated immediately
2093 SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
2094 store.save(
2095 ThreadMetadata::from_session_info(agent.id(), &session_info),
2096 cx,
2097 )
2098 });
2099
2100 if let Some(path_list) = &session_info.work_dirs {
2101 if let Some(workspace) = self.find_current_workspace_for_path_list(path_list, cx) {
2102 self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2103 } else if let Some((target_window, workspace)) =
2104 self.find_open_workspace_for_path_list(path_list, cx)
2105 {
2106 self.activate_thread_in_other_window(
2107 agent,
2108 session_info,
2109 workspace,
2110 target_window,
2111 cx,
2112 );
2113 } else {
2114 let path_list = path_list.clone();
2115 self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
2116 }
2117 return;
2118 }
2119
2120 let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
2121 w.read(cx)
2122 .workspaces()
2123 .get(w.read(cx).active_workspace_index())
2124 .cloned()
2125 });
2126
2127 if let Some(workspace) = active_workspace {
2128 self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2129 }
2130 }
2131
2132 fn expand_selected_entry(
2133 &mut self,
2134 _: &SelectChild,
2135 _window: &mut Window,
2136 cx: &mut Context<Self>,
2137 ) {
2138 let Some(ix) = self.selection else { return };
2139
2140 match self.contents.entries.get(ix) {
2141 Some(ListEntry::ProjectHeader { path_list, .. }) => {
2142 if self.collapsed_groups.contains(path_list) {
2143 let path_list = path_list.clone();
2144 self.collapsed_groups.remove(&path_list);
2145 self.update_entries(cx);
2146 } else if ix + 1 < self.contents.entries.len() {
2147 self.selection = Some(ix + 1);
2148 self.list_state.scroll_to_reveal_item(ix + 1);
2149 cx.notify();
2150 }
2151 }
2152 _ => {}
2153 }
2154 }
2155
2156 fn collapse_selected_entry(
2157 &mut self,
2158 _: &SelectParent,
2159 _window: &mut Window,
2160 cx: &mut Context<Self>,
2161 ) {
2162 let Some(ix) = self.selection else { return };
2163
2164 match self.contents.entries.get(ix) {
2165 Some(ListEntry::ProjectHeader { path_list, .. }) => {
2166 if !self.collapsed_groups.contains(path_list) {
2167 let path_list = path_list.clone();
2168 self.collapsed_groups.insert(path_list);
2169 self.update_entries(cx);
2170 }
2171 }
2172 Some(
2173 ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2174 ) => {
2175 for i in (0..ix).rev() {
2176 if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2177 self.contents.entries.get(i)
2178 {
2179 let path_list = path_list.clone();
2180 self.selection = Some(i);
2181 self.collapsed_groups.insert(path_list);
2182 self.update_entries(cx);
2183 break;
2184 }
2185 }
2186 }
2187 None => {}
2188 }
2189 }
2190
2191 fn toggle_selected_fold(
2192 &mut self,
2193 _: &editor::actions::ToggleFold,
2194 _window: &mut Window,
2195 cx: &mut Context<Self>,
2196 ) {
2197 let Some(ix) = self.selection else { return };
2198
2199 // Find the group header for the current selection.
2200 let header_ix = match self.contents.entries.get(ix) {
2201 Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2202 Some(
2203 ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2204 ) => (0..ix).rev().find(|&i| {
2205 matches!(
2206 self.contents.entries.get(i),
2207 Some(ListEntry::ProjectHeader { .. })
2208 )
2209 }),
2210 None => None,
2211 };
2212
2213 if let Some(header_ix) = header_ix {
2214 if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2215 self.contents.entries.get(header_ix)
2216 {
2217 let path_list = path_list.clone();
2218 if self.collapsed_groups.contains(&path_list) {
2219 self.collapsed_groups.remove(&path_list);
2220 } else {
2221 self.selection = Some(header_ix);
2222 self.collapsed_groups.insert(path_list);
2223 }
2224 self.update_entries(cx);
2225 }
2226 }
2227 }
2228
2229 fn fold_all(
2230 &mut self,
2231 _: &editor::actions::FoldAll,
2232 _window: &mut Window,
2233 cx: &mut Context<Self>,
2234 ) {
2235 for entry in &self.contents.entries {
2236 if let ListEntry::ProjectHeader { path_list, .. } = entry {
2237 self.collapsed_groups.insert(path_list.clone());
2238 }
2239 }
2240 self.update_entries(cx);
2241 }
2242
2243 fn unfold_all(
2244 &mut self,
2245 _: &editor::actions::UnfoldAll,
2246 _window: &mut Window,
2247 cx: &mut Context<Self>,
2248 ) {
2249 self.collapsed_groups.clear();
2250 self.update_entries(cx);
2251 }
2252
2253 fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2254 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2255 return;
2256 };
2257
2258 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
2259 for workspace in workspaces {
2260 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2261 let cancelled =
2262 agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2263 if cancelled {
2264 return;
2265 }
2266 }
2267 }
2268 }
2269
2270 fn archive_thread(
2271 &mut self,
2272 session_id: &acp::SessionId,
2273 window: &mut Window,
2274 cx: &mut Context<Self>,
2275 ) {
2276 // If we're archiving the currently focused thread, move focus to the
2277 // nearest thread within the same project group. We never cross group
2278 // boundaries — if the group has no other threads, clear focus and open
2279 // a blank new thread in the panel instead.
2280 if self.focused_thread.as_ref() == Some(session_id) {
2281 let current_pos = self.contents.entries.iter().position(|entry| {
2282 matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
2283 });
2284
2285 // Find the workspace that owns this thread's project group by
2286 // walking backwards to the nearest ProjectHeader. We must use
2287 // *this* workspace (not the active workspace) because the user
2288 // might be archiving a thread in a non-active group.
2289 let group_workspace = current_pos.and_then(|pos| {
2290 self.contents.entries[..pos]
2291 .iter()
2292 .rev()
2293 .find_map(|e| match e {
2294 ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2295 _ => None,
2296 })
2297 });
2298
2299 let next_thread = current_pos.and_then(|pos| {
2300 let group_start = self.contents.entries[..pos]
2301 .iter()
2302 .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2303 .map_or(0, |i| i + 1);
2304 let group_end = self.contents.entries[pos + 1..]
2305 .iter()
2306 .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2307 .map_or(self.contents.entries.len(), |i| pos + 1 + i);
2308
2309 let above = self.contents.entries[group_start..pos]
2310 .iter()
2311 .rev()
2312 .find_map(|entry| {
2313 if let ListEntry::Thread(t) = entry {
2314 Some(t)
2315 } else {
2316 None
2317 }
2318 });
2319
2320 above.or_else(|| {
2321 self.contents.entries[pos + 1..group_end]
2322 .iter()
2323 .find_map(|entry| {
2324 if let ListEntry::Thread(t) = entry {
2325 Some(t)
2326 } else {
2327 None
2328 }
2329 })
2330 })
2331 });
2332
2333 if let Some(next) = next_thread {
2334 self.focused_thread = Some(next.session_info.session_id.clone());
2335
2336 // Use the thread's own workspace when it has one open (e.g. an absorbed
2337 // linked worktree thread that appears under the main workspace's header
2338 // but belongs to its own workspace). Loading into the wrong panel binds
2339 // the thread to the wrong project, which corrupts its stored folder_paths
2340 // when metadata is saved via ThreadMetadata::from_thread.
2341 let target_workspace = match &next.workspace {
2342 ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
2343 ThreadEntryWorkspace::Closed(_) => group_workspace,
2344 };
2345
2346 if let Some(workspace) = target_workspace {
2347 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2348 agent_panel.update(cx, |panel, cx| {
2349 panel.load_agent_thread(
2350 next.agent.clone(),
2351 next.session_info.session_id.clone(),
2352 next.session_info.work_dirs.clone(),
2353 next.session_info.title.clone(),
2354 true,
2355 window,
2356 cx,
2357 );
2358 });
2359 }
2360 }
2361 } else {
2362 self.focused_thread = None;
2363 if let Some(workspace) = &group_workspace {
2364 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2365 agent_panel.update(cx, |panel, cx| {
2366 panel.new_thread(&NewThread, window, cx);
2367 });
2368 }
2369 }
2370 }
2371 }
2372
2373 SidebarThreadMetadataStore::global(cx)
2374 .update(cx, |store, cx| store.delete(session_id.clone(), cx));
2375 }
2376
2377 fn remove_selected_thread(
2378 &mut self,
2379 _: &RemoveSelectedThread,
2380 window: &mut Window,
2381 cx: &mut Context<Self>,
2382 ) {
2383 let Some(ix) = self.selection else {
2384 return;
2385 };
2386 let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
2387 return;
2388 };
2389 if thread.agent != Agent::NativeAgent {
2390 return;
2391 }
2392 let session_id = thread.session_info.session_id.clone();
2393 self.archive_thread(&session_id, window, cx);
2394 }
2395
2396 fn render_thread(
2397 &self,
2398 ix: usize,
2399 thread: &ThreadEntry,
2400 is_focused: bool,
2401 cx: &mut Context<Self>,
2402 ) -> AnyElement {
2403 let has_notification = self
2404 .contents
2405 .is_thread_notified(&thread.session_info.session_id);
2406
2407 let title: SharedString = thread
2408 .session_info
2409 .title
2410 .clone()
2411 .unwrap_or_else(|| "Untitled".into());
2412 let session_info = thread.session_info.clone();
2413 let thread_workspace = thread.workspace.clone();
2414
2415 let is_hovered = self.hovered_thread_index == Some(ix);
2416 let is_selected = self.agent_panel_visible
2417 && self.focused_thread.as_ref() == Some(&session_info.session_id);
2418 let is_running = matches!(
2419 thread.status,
2420 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
2421 );
2422
2423 let session_id_for_delete = thread.session_info.session_id.clone();
2424 let focus_handle = self.focus_handle.clone();
2425
2426 let id = SharedString::from(format!("thread-entry-{}", ix));
2427
2428 let timestamp = thread
2429 .session_info
2430 .created_at
2431 .or(thread.session_info.updated_at)
2432 .map(format_history_entry_timestamp);
2433
2434 ThreadItem::new(id, title)
2435 .icon(thread.icon)
2436 .status(thread.status)
2437 .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
2438 this.custom_icon_from_external_svg(svg)
2439 })
2440 .when_some(thread.worktree_name.clone(), |this, name| {
2441 let this = this.worktree(name);
2442 match thread.worktree_full_path.clone() {
2443 Some(path) => this.worktree_full_path(path),
2444 None => this,
2445 }
2446 })
2447 .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
2448 .when_some(timestamp, |this, ts| this.timestamp(ts))
2449 .highlight_positions(thread.highlight_positions.to_vec())
2450 .title_generating(thread.is_title_generating)
2451 .notified(has_notification)
2452 .when(thread.diff_stats.lines_added > 0, |this| {
2453 this.added(thread.diff_stats.lines_added as usize)
2454 })
2455 .when(thread.diff_stats.lines_removed > 0, |this| {
2456 this.removed(thread.diff_stats.lines_removed as usize)
2457 })
2458 .selected(is_selected)
2459 .focused(is_focused)
2460 .hovered(is_hovered)
2461 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
2462 if *is_hovered {
2463 this.hovered_thread_index = Some(ix);
2464 } else if this.hovered_thread_index == Some(ix) {
2465 this.hovered_thread_index = None;
2466 }
2467 cx.notify();
2468 }))
2469 .when(is_hovered && is_running, |this| {
2470 this.action_slot(
2471 IconButton::new("stop-thread", IconName::Stop)
2472 .icon_size(IconSize::Small)
2473 .icon_color(Color::Error)
2474 .style(ButtonStyle::Tinted(TintColor::Error))
2475 .tooltip(Tooltip::text("Stop Generation"))
2476 .on_click({
2477 let session_id = session_id_for_delete.clone();
2478 cx.listener(move |this, _, _window, cx| {
2479 this.stop_thread(&session_id, cx);
2480 })
2481 }),
2482 )
2483 })
2484 .when(is_hovered && !is_running, |this| {
2485 this.action_slot(
2486 IconButton::new("archive-thread", IconName::Archive)
2487 .icon_size(IconSize::Small)
2488 .icon_color(Color::Muted)
2489 .tooltip({
2490 let focus_handle = focus_handle.clone();
2491 move |_window, cx| {
2492 Tooltip::for_action_in(
2493 "Archive Thread",
2494 &RemoveSelectedThread,
2495 &focus_handle,
2496 cx,
2497 )
2498 }
2499 })
2500 .on_click({
2501 let session_id = session_id_for_delete.clone();
2502 cx.listener(move |this, _, window, cx| {
2503 this.archive_thread(&session_id, window, cx);
2504 })
2505 }),
2506 )
2507 })
2508 .on_click({
2509 let agent = thread.agent.clone();
2510 cx.listener(move |this, _, window, cx| {
2511 this.selection = None;
2512 match &thread_workspace {
2513 ThreadEntryWorkspace::Open(workspace) => {
2514 this.activate_thread(
2515 agent.clone(),
2516 session_info.clone(),
2517 workspace,
2518 window,
2519 cx,
2520 );
2521 }
2522 ThreadEntryWorkspace::Closed(path_list) => {
2523 this.open_workspace_and_activate_thread(
2524 agent.clone(),
2525 session_info.clone(),
2526 path_list.clone(),
2527 window,
2528 cx,
2529 );
2530 }
2531 }
2532 })
2533 })
2534 .into_any_element()
2535 }
2536
2537 fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
2538 div()
2539 .min_w_0()
2540 .flex_1()
2541 .capture_action(
2542 cx.listener(|this, _: &editor::actions::Newline, window, cx| {
2543 this.editor_confirm(window, cx);
2544 }),
2545 )
2546 .child(self.filter_editor.clone())
2547 }
2548
2549 fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
2550 let multi_workspace = self.multi_workspace.upgrade();
2551
2552 let workspace = multi_workspace
2553 .as_ref()
2554 .map(|mw| mw.read(cx).workspace().downgrade());
2555
2556 let focus_handle = workspace
2557 .as_ref()
2558 .and_then(|ws| ws.upgrade())
2559 .map(|w| w.read(cx).focus_handle(cx))
2560 .unwrap_or_else(|| cx.focus_handle());
2561
2562 let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
2563 .as_ref()
2564 .map(|mw| {
2565 mw.read(cx)
2566 .workspaces()
2567 .iter()
2568 .filter_map(|ws| ws.read(cx).database_id())
2569 .collect()
2570 })
2571 .unwrap_or_default();
2572
2573 let popover_handle = self.recent_projects_popover_handle.clone();
2574
2575 PopoverMenu::new("sidebar-recent-projects-menu")
2576 .with_handle(popover_handle)
2577 .menu(move |window, cx| {
2578 workspace.as_ref().map(|ws| {
2579 SidebarRecentProjects::popover(
2580 ws.clone(),
2581 sibling_workspace_ids.clone(),
2582 focus_handle.clone(),
2583 window,
2584 cx,
2585 )
2586 })
2587 })
2588 .trigger_with_tooltip(
2589 IconButton::new("open-project", IconName::OpenFolder)
2590 .icon_size(IconSize::Small)
2591 .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
2592 |_window, cx| {
2593 Tooltip::for_action(
2594 "Add Project",
2595 &OpenRecent {
2596 create_new_window: false,
2597 },
2598 cx,
2599 )
2600 },
2601 )
2602 .offset(gpui::Point {
2603 x: px(-2.0),
2604 y: px(-2.0),
2605 })
2606 .anchor(gpui::Corner::BottomRight)
2607 }
2608
2609 fn render_view_more(
2610 &self,
2611 ix: usize,
2612 path_list: &PathList,
2613 is_fully_expanded: bool,
2614 is_selected: bool,
2615 cx: &mut Context<Self>,
2616 ) -> AnyElement {
2617 let path_list = path_list.clone();
2618 let id = SharedString::from(format!("view-more-{}", ix));
2619
2620 let label: SharedString = if is_fully_expanded {
2621 "Collapse".into()
2622 } else {
2623 "View More".into()
2624 };
2625
2626 ThreadItem::new(id, label)
2627 .focused(is_selected)
2628 .icon_visible(false)
2629 .title_label_color(Color::Muted)
2630 .on_click(cx.listener(move |this, _, _window, cx| {
2631 this.selection = None;
2632 if is_fully_expanded {
2633 this.expanded_groups.remove(&path_list);
2634 } else {
2635 let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
2636 this.expanded_groups.insert(path_list.clone(), current + 1);
2637 }
2638 this.update_entries(cx);
2639 }))
2640 .into_any_element()
2641 }
2642
2643 fn new_thread_in_group(
2644 &mut self,
2645 _: &NewThreadInGroup,
2646 window: &mut Window,
2647 cx: &mut Context<Self>,
2648 ) {
2649 // If there is a keyboard selection, walk backwards through
2650 // `project_header_indices` to find the header that owns the selected
2651 // row. Otherwise fall back to the active workspace.
2652 let workspace = if let Some(selected_ix) = self.selection {
2653 self.contents
2654 .project_header_indices
2655 .iter()
2656 .rev()
2657 .find(|&&header_ix| header_ix <= selected_ix)
2658 .and_then(|&header_ix| match &self.contents.entries[header_ix] {
2659 ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2660 _ => None,
2661 })
2662 } else {
2663 // Use the currently active workspace.
2664 self.multi_workspace
2665 .upgrade()
2666 .map(|mw| mw.read(cx).workspace().clone())
2667 };
2668
2669 let Some(workspace) = workspace else {
2670 return;
2671 };
2672
2673 self.create_new_thread(&workspace, window, cx);
2674 }
2675
2676 fn create_new_thread(
2677 &mut self,
2678 workspace: &Entity<Workspace>,
2679 window: &mut Window,
2680 cx: &mut Context<Self>,
2681 ) {
2682 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2683 return;
2684 };
2685
2686 // Clear focused_thread immediately so no existing thread stays
2687 // highlighted while the new blank thread is being shown. Without this,
2688 // if the target workspace is already active (so ActiveWorkspaceChanged
2689 // never fires), the previous thread's highlight would linger.
2690 self.focused_thread = None;
2691
2692 multi_workspace.update(cx, |multi_workspace, cx| {
2693 multi_workspace.activate(workspace.clone(), cx);
2694 });
2695
2696 workspace.update(cx, |workspace, cx| {
2697 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
2698 agent_panel.update(cx, |panel, cx| {
2699 panel.new_thread(&NewThread, window, cx);
2700 });
2701 }
2702 workspace.focus_panel::<AgentPanel>(window, cx);
2703 });
2704 }
2705
2706 fn render_new_thread(
2707 &self,
2708 ix: usize,
2709 _path_list: &PathList,
2710 workspace: &Entity<Workspace>,
2711 is_active_draft: bool,
2712 is_selected: bool,
2713 cx: &mut Context<Self>,
2714 ) -> AnyElement {
2715 let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft;
2716
2717 let label: SharedString = if is_active {
2718 self.active_draft_text(cx)
2719 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
2720 } else {
2721 DEFAULT_THREAD_TITLE.into()
2722 };
2723
2724 let workspace = workspace.clone();
2725 let id = SharedString::from(format!("new-thread-btn-{}", ix));
2726
2727 let thread_item = ThreadItem::new(id, label)
2728 .icon(IconName::Plus)
2729 .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
2730 .selected(is_active)
2731 .focused(is_selected)
2732 .when(!is_active, |this| {
2733 this.on_click(cx.listener(move |this, _, window, cx| {
2734 this.selection = None;
2735 this.create_new_thread(&workspace, window, cx);
2736 }))
2737 });
2738
2739 if is_active {
2740 div()
2741 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
2742 cx.stop_propagation();
2743 })
2744 .child(thread_item)
2745 .into_any_element()
2746 } else {
2747 thread_item.into_any_element()
2748 }
2749 }
2750
2751 fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
2752 let has_query = self.has_filter_query(cx);
2753 let message = if has_query {
2754 "No threads match your search."
2755 } else {
2756 "No threads yet"
2757 };
2758
2759 v_flex()
2760 .id("sidebar-no-results")
2761 .p_4()
2762 .size_full()
2763 .items_center()
2764 .justify_center()
2765 .child(
2766 Label::new(message)
2767 .size(LabelSize::Small)
2768 .color(Color::Muted),
2769 )
2770 }
2771
2772 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2773 v_flex()
2774 .id("sidebar-empty-state")
2775 .p_4()
2776 .size_full()
2777 .items_center()
2778 .justify_center()
2779 .gap_1()
2780 .track_focus(&self.focus_handle(cx))
2781 .child(
2782 Button::new("open_project", "Open Project")
2783 .full_width()
2784 .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
2785 .on_click(|_, window, cx| {
2786 window.dispatch_action(
2787 Open {
2788 create_new_window: false,
2789 }
2790 .boxed_clone(),
2791 cx,
2792 );
2793 }),
2794 )
2795 .child(
2796 h_flex()
2797 .w_1_2()
2798 .gap_2()
2799 .child(Divider::horizontal())
2800 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
2801 .child(Divider::horizontal()),
2802 )
2803 .child(
2804 Button::new("clone_repo", "Clone Repository")
2805 .full_width()
2806 .on_click(|_, window, cx| {
2807 window.dispatch_action(git::Clone.boxed_clone(), cx);
2808 }),
2809 )
2810 }
2811
2812 fn render_sidebar_header(
2813 &self,
2814 no_open_projects: bool,
2815 window: &Window,
2816 cx: &mut Context<Self>,
2817 ) -> impl IntoElement {
2818 let has_query = self.has_filter_query(cx);
2819 let sidebar_on_left = self.side(cx) == SidebarSide::Left;
2820 let traffic_lights =
2821 cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
2822 let header_height = platform_title_bar_height(window);
2823
2824 h_flex()
2825 .h(header_height)
2826 .mt_px()
2827 .pb_px()
2828 .map(|this| {
2829 if traffic_lights {
2830 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
2831 } else {
2832 this.pl_1p5()
2833 }
2834 })
2835 .pr_1p5()
2836 .gap_1()
2837 .when(!no_open_projects, |this| {
2838 this.border_b_1()
2839 .border_color(cx.theme().colors().border)
2840 .when(traffic_lights, |this| {
2841 this.child(Divider::vertical().color(ui::DividerColor::Border))
2842 })
2843 .child(
2844 div().ml_1().child(
2845 Icon::new(IconName::MagnifyingGlass)
2846 .size(IconSize::Small)
2847 .color(Color::Muted),
2848 ),
2849 )
2850 .child(self.render_filter_input(cx))
2851 .child(
2852 h_flex()
2853 .gap_1()
2854 .when(
2855 self.selection.is_some()
2856 && !self.filter_editor.focus_handle(cx).is_focused(window),
2857 |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
2858 )
2859 .when(has_query, |this| {
2860 this.child(
2861 IconButton::new("clear_filter", IconName::Close)
2862 .icon_size(IconSize::Small)
2863 .tooltip(Tooltip::text("Clear Search"))
2864 .on_click(cx.listener(|this, _, window, cx| {
2865 this.reset_filter_editor_text(window, cx);
2866 this.update_entries(cx);
2867 })),
2868 )
2869 }),
2870 )
2871 })
2872 }
2873
2874 fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
2875 let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
2876
2877 sidebar_side_context_menu("sidebar-toggle-menu", _cx)
2878 .anchor(if on_right {
2879 gpui::Corner::BottomRight
2880 } else {
2881 gpui::Corner::BottomLeft
2882 })
2883 .attach(if on_right {
2884 gpui::Corner::TopRight
2885 } else {
2886 gpui::Corner::TopLeft
2887 })
2888 .trigger(move |_is_active, _window, _cx| {
2889 let icon = if on_right {
2890 IconName::ThreadsSidebarRightOpen
2891 } else {
2892 IconName::ThreadsSidebarLeftOpen
2893 };
2894 IconButton::new("sidebar-close-toggle", icon)
2895 .icon_size(IconSize::Small)
2896 .tooltip(Tooltip::element(move |_window, cx| {
2897 v_flex()
2898 .gap_1()
2899 .child(
2900 h_flex()
2901 .gap_2()
2902 .justify_between()
2903 .child(Label::new("Toggle Sidebar"))
2904 .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
2905 )
2906 .child(
2907 h_flex()
2908 .pt_1()
2909 .gap_2()
2910 .border_t_1()
2911 .border_color(cx.theme().colors().border_variant)
2912 .justify_between()
2913 .child(Label::new("Focus Sidebar"))
2914 .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
2915 )
2916 .into_any_element()
2917 }))
2918 .on_click(|_, window, cx| {
2919 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2920 multi_workspace.update(cx, |multi_workspace, cx| {
2921 multi_workspace.close_sidebar(window, cx);
2922 });
2923 }
2924 })
2925 })
2926 }
2927
2928 fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
2929 let on_right = self.side(cx) == SidebarSide::Right;
2930 let is_archive = matches!(self.view, SidebarView::Archive(..));
2931 let action_buttons = h_flex()
2932 .gap_1()
2933 .child(
2934 IconButton::new("archive", IconName::Archive)
2935 .icon_size(IconSize::Small)
2936 .toggle_state(is_archive)
2937 .tooltip(move |_, cx| {
2938 Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
2939 })
2940 .on_click(cx.listener(|this, _, window, cx| {
2941 this.toggle_archive(&ToggleArchive, window, cx);
2942 })),
2943 )
2944 .child(self.render_recent_projects_button(cx));
2945 let border_color = cx.theme().colors().border;
2946 let toggle_button = self.render_sidebar_toggle_button(cx);
2947
2948 let bar = h_flex()
2949 .p_1()
2950 .gap_1()
2951 .justify_between()
2952 .border_t_1()
2953 .border_color(border_color);
2954
2955 if on_right {
2956 bar.child(action_buttons).child(toggle_button)
2957 } else {
2958 bar.child(toggle_button).child(action_buttons)
2959 }
2960 }
2961
2962 fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
2963 match &self.view {
2964 SidebarView::ThreadList => self.show_archive(window, cx),
2965 SidebarView::Archive(_) => self.show_thread_list(window, cx),
2966 }
2967 }
2968
2969 fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2970 let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
2971 w.read(cx)
2972 .workspaces()
2973 .get(w.read(cx).active_workspace_index())
2974 .cloned()
2975 }) else {
2976 return;
2977 };
2978
2979 let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
2980 return;
2981 };
2982
2983 let thread_store = agent_panel.read(cx).thread_store().clone();
2984 let fs = active_workspace.read(cx).project().read(cx).fs().clone();
2985 let agent_connection_store = agent_panel.read(cx).connection_store().clone();
2986 let agent_server_store = active_workspace
2987 .read(cx)
2988 .project()
2989 .read(cx)
2990 .agent_server_store()
2991 .clone();
2992
2993 let archive_view = cx.new(|cx| {
2994 ThreadsArchiveView::new(
2995 agent_connection_store,
2996 agent_server_store,
2997 thread_store,
2998 fs,
2999 window,
3000 cx,
3001 )
3002 });
3003 let subscription = cx.subscribe_in(
3004 &archive_view,
3005 window,
3006 |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
3007 ThreadsArchiveViewEvent::Close => {
3008 this.show_thread_list(window, cx);
3009 }
3010 ThreadsArchiveViewEvent::Unarchive {
3011 agent,
3012 session_info,
3013 } => {
3014 this.show_thread_list(window, cx);
3015 this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
3016 }
3017 },
3018 );
3019
3020 self._subscriptions.push(subscription);
3021 self.view = SidebarView::Archive(archive_view.clone());
3022 archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
3023 cx.notify();
3024 }
3025
3026 fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3027 self.view = SidebarView::ThreadList;
3028 self._subscriptions.clear();
3029 let handle = self.filter_editor.read(cx).focus_handle(cx);
3030 handle.focus(window, cx);
3031 cx.notify();
3032 }
3033}
3034
3035impl WorkspaceSidebar for Sidebar {
3036 fn width(&self, _cx: &App) -> Pixels {
3037 self.width
3038 }
3039
3040 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
3041 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
3042 cx.notify();
3043 }
3044
3045 fn has_notifications(&self, _cx: &App) -> bool {
3046 !self.contents.notified_threads.is_empty()
3047 }
3048
3049 fn is_threads_list_view_active(&self) -> bool {
3050 matches!(self.view, SidebarView::ThreadList)
3051 }
3052
3053 fn side(&self, cx: &App) -> SidebarSide {
3054 AgentSettings::get_global(cx).sidebar_side()
3055 }
3056
3057 fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3058 self.selection = None;
3059 cx.notify();
3060 }
3061}
3062
3063impl Focusable for Sidebar {
3064 fn focus_handle(&self, _cx: &App) -> FocusHandle {
3065 self.focus_handle.clone()
3066 }
3067}
3068
3069impl Render for Sidebar {
3070 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3071 let _titlebar_height = ui::utils::platform_title_bar_height(window);
3072 let ui_font = theme_settings::setup_ui_font(window, cx);
3073 let sticky_header = self.render_sticky_header(window, cx);
3074
3075 let color = cx.theme().colors();
3076 let bg = color
3077 .title_bar_background
3078 .blend(color.panel_background.opacity(0.32));
3079
3080 let no_open_projects = !self.contents.has_open_projects;
3081 let no_search_results = self.contents.entries.is_empty();
3082
3083 v_flex()
3084 .id("workspace-sidebar")
3085 .key_context(self.dispatch_context(window, cx))
3086 .track_focus(&self.focus_handle)
3087 .on_action(cx.listener(Self::select_next))
3088 .on_action(cx.listener(Self::select_previous))
3089 .on_action(cx.listener(Self::editor_move_down))
3090 .on_action(cx.listener(Self::editor_move_up))
3091 .on_action(cx.listener(Self::select_first))
3092 .on_action(cx.listener(Self::select_last))
3093 .on_action(cx.listener(Self::confirm))
3094 .on_action(cx.listener(Self::expand_selected_entry))
3095 .on_action(cx.listener(Self::collapse_selected_entry))
3096 .on_action(cx.listener(Self::toggle_selected_fold))
3097 .on_action(cx.listener(Self::fold_all))
3098 .on_action(cx.listener(Self::unfold_all))
3099 .on_action(cx.listener(Self::cancel))
3100 .on_action(cx.listener(Self::remove_selected_thread))
3101 .on_action(cx.listener(Self::new_thread_in_group))
3102 .on_action(cx.listener(Self::toggle_archive))
3103 .on_action(cx.listener(Self::focus_sidebar_filter))
3104 .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
3105 this.recent_projects_popover_handle.toggle(window, cx);
3106 }))
3107 .font(ui_font)
3108 .h_full()
3109 .w(self.width)
3110 .bg(bg)
3111 .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
3112 .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
3113 .border_color(color.border)
3114 .map(|this| match &self.view {
3115 SidebarView::ThreadList => this
3116 .child(self.render_sidebar_header(no_open_projects, window, cx))
3117 .map(|this| {
3118 if no_open_projects {
3119 this.child(self.render_empty_state(cx))
3120 } else {
3121 this.child(
3122 v_flex()
3123 .relative()
3124 .flex_1()
3125 .overflow_hidden()
3126 .child(
3127 list(
3128 self.list_state.clone(),
3129 cx.processor(Self::render_list_entry),
3130 )
3131 .flex_1()
3132 .size_full(),
3133 )
3134 .when(no_search_results, |this| {
3135 this.child(self.render_no_results(cx))
3136 })
3137 .when_some(sticky_header, |this, header| this.child(header))
3138 .vertical_scrollbar_for(&self.list_state, window, cx),
3139 )
3140 }
3141 }),
3142 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
3143 })
3144 .child(self.render_sidebar_bottom_bar(cx))
3145 }
3146}
3147
3148fn all_thread_infos_for_workspace(
3149 workspace: &Entity<Workspace>,
3150 cx: &App,
3151) -> impl Iterator<Item = ActiveThreadInfo> {
3152 let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
3153 return None.into_iter().flatten();
3154 };
3155 let agent_panel = agent_panel.read(cx);
3156
3157 let threads = agent_panel
3158 .parent_threads(cx)
3159 .into_iter()
3160 .map(|thread_view| {
3161 let thread_view_ref = thread_view.read(cx);
3162 let thread = thread_view_ref.thread.read(cx);
3163
3164 let icon = thread_view_ref.agent_icon;
3165 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
3166 let title = thread
3167 .title()
3168 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
3169 let is_native = thread_view_ref.as_native_thread(cx).is_some();
3170 let is_title_generating = is_native && thread.has_provisional_title();
3171 let session_id = thread.session_id().clone();
3172 let is_background = agent_panel.is_background_thread(&session_id);
3173
3174 let status = if thread.is_waiting_for_confirmation() {
3175 AgentThreadStatus::WaitingForConfirmation
3176 } else if thread.had_error() {
3177 AgentThreadStatus::Error
3178 } else {
3179 match thread.status() {
3180 ThreadStatus::Generating => AgentThreadStatus::Running,
3181 ThreadStatus::Idle => AgentThreadStatus::Completed,
3182 }
3183 };
3184
3185 let diff_stats = thread.action_log().read(cx).diff_stats(cx);
3186
3187 ActiveThreadInfo {
3188 session_id,
3189 title,
3190 status,
3191 icon,
3192 icon_from_external_svg,
3193 is_background,
3194 is_title_generating,
3195 diff_stats,
3196 }
3197 });
3198
3199 Some(threads).into_iter().flatten()
3200}
3201
3202#[cfg(test)]
3203mod tests {
3204 use super::*;
3205 use acp_thread::StubAgentConnection;
3206 use agent::ThreadStore;
3207 use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
3208 use assistant_text_thread::TextThreadStore;
3209 use chrono::DateTime;
3210 use feature_flags::FeatureFlagAppExt as _;
3211 use fs::FakeFs;
3212 use gpui::TestAppContext;
3213 use pretty_assertions::assert_eq;
3214 use settings::SettingsStore;
3215 use std::{path::PathBuf, sync::Arc};
3216 use util::path_list::PathList;
3217
3218 fn init_test(cx: &mut TestAppContext) {
3219 cx.update(|cx| {
3220 let settings_store = SettingsStore::test(cx);
3221 cx.set_global(settings_store);
3222 theme_settings::init(theme::LoadThemes::JustBase, cx);
3223 editor::init(cx);
3224 cx.update_flags(false, vec!["agent-v2".into()]);
3225 ThreadStore::init_global(cx);
3226 SidebarThreadMetadataStore::init_global(cx);
3227 language_model::LanguageModelRegistry::test(cx);
3228 prompt_store::init(cx);
3229 });
3230 }
3231
3232 fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
3233 sidebar.contents.entries.iter().any(|entry| {
3234 matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
3235 })
3236 }
3237
3238 async fn init_test_project(
3239 worktree_path: &str,
3240 cx: &mut TestAppContext,
3241 ) -> Entity<project::Project> {
3242 init_test(cx);
3243 let fs = FakeFs::new(cx.executor());
3244 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
3245 .await;
3246 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3247 project::Project::test(fs, [worktree_path.as_ref()], cx).await
3248 }
3249
3250 fn setup_sidebar(
3251 multi_workspace: &Entity<MultiWorkspace>,
3252 cx: &mut gpui::VisualTestContext,
3253 ) -> Entity<Sidebar> {
3254 let multi_workspace = multi_workspace.clone();
3255 let sidebar =
3256 cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
3257 multi_workspace.update(cx, |mw, cx| {
3258 mw.register_sidebar(sidebar.clone(), cx);
3259 });
3260 cx.run_until_parked();
3261 sidebar
3262 }
3263
3264 async fn save_n_test_threads(
3265 count: u32,
3266 path_list: &PathList,
3267 cx: &mut gpui::VisualTestContext,
3268 ) {
3269 for i in 0..count {
3270 save_thread_metadata(
3271 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3272 format!("Thread {}", i + 1).into(),
3273 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3274 path_list.clone(),
3275 cx,
3276 )
3277 .await;
3278 }
3279 cx.run_until_parked();
3280 }
3281
3282 async fn save_test_thread_metadata(
3283 session_id: &acp::SessionId,
3284 path_list: PathList,
3285 cx: &mut TestAppContext,
3286 ) {
3287 save_thread_metadata(
3288 session_id.clone(),
3289 "Test".into(),
3290 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3291 path_list,
3292 cx,
3293 )
3294 .await;
3295 }
3296
3297 async fn save_named_thread_metadata(
3298 session_id: &str,
3299 title: &str,
3300 path_list: &PathList,
3301 cx: &mut gpui::VisualTestContext,
3302 ) {
3303 save_thread_metadata(
3304 acp::SessionId::new(Arc::from(session_id)),
3305 SharedString::from(title.to_string()),
3306 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3307 path_list.clone(),
3308 cx,
3309 )
3310 .await;
3311 cx.run_until_parked();
3312 }
3313
3314 async fn save_thread_metadata(
3315 session_id: acp::SessionId,
3316 title: SharedString,
3317 updated_at: DateTime<Utc>,
3318 path_list: PathList,
3319 cx: &mut TestAppContext,
3320 ) {
3321 let metadata = ThreadMetadata {
3322 session_id,
3323 agent_id: None,
3324 title,
3325 updated_at,
3326 created_at: None,
3327 folder_paths: path_list,
3328 };
3329 cx.update(|cx| {
3330 SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
3331 });
3332 cx.run_until_parked();
3333 }
3334
3335 fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
3336 let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade());
3337 if let Some(multi_workspace) = multi_workspace {
3338 multi_workspace.update_in(cx, |mw, window, cx| {
3339 if !mw.sidebar_open() {
3340 mw.toggle_sidebar(window, cx);
3341 }
3342 });
3343 }
3344 cx.run_until_parked();
3345 sidebar.update_in(cx, |_, window, cx| {
3346 cx.focus_self(window);
3347 });
3348 cx.run_until_parked();
3349 }
3350
3351 fn visible_entries_as_strings(
3352 sidebar: &Entity<Sidebar>,
3353 cx: &mut gpui::VisualTestContext,
3354 ) -> Vec<String> {
3355 sidebar.read_with(cx, |sidebar, _cx| {
3356 sidebar
3357 .contents
3358 .entries
3359 .iter()
3360 .enumerate()
3361 .map(|(ix, entry)| {
3362 let selected = if sidebar.selection == Some(ix) {
3363 " <== selected"
3364 } else {
3365 ""
3366 };
3367 match entry {
3368 ListEntry::ProjectHeader {
3369 label,
3370 path_list,
3371 highlight_positions: _,
3372 ..
3373 } => {
3374 let icon = if sidebar.collapsed_groups.contains(path_list) {
3375 ">"
3376 } else {
3377 "v"
3378 };
3379 format!("{} [{}]{}", icon, label, selected)
3380 }
3381 ListEntry::Thread(thread) => {
3382 let title = thread
3383 .session_info
3384 .title
3385 .as_ref()
3386 .map(|s| s.as_ref())
3387 .unwrap_or("Untitled");
3388 let active = if thread.is_live { " *" } else { "" };
3389 let status_str = match thread.status {
3390 AgentThreadStatus::Running => " (running)",
3391 AgentThreadStatus::Error => " (error)",
3392 AgentThreadStatus::WaitingForConfirmation => " (waiting)",
3393 _ => "",
3394 };
3395 let notified = if sidebar
3396 .contents
3397 .is_thread_notified(&thread.session_info.session_id)
3398 {
3399 " (!)"
3400 } else {
3401 ""
3402 };
3403 let worktree = thread
3404 .worktree_name
3405 .as_ref()
3406 .map(|name| format!(" {{{}}}", name))
3407 .unwrap_or_default();
3408 format!(
3409 " {}{}{}{}{}{}",
3410 title, worktree, active, status_str, notified, selected
3411 )
3412 }
3413 ListEntry::ViewMore {
3414 is_fully_expanded, ..
3415 } => {
3416 if *is_fully_expanded {
3417 format!(" - Collapse{}", selected)
3418 } else {
3419 format!(" + View More{}", selected)
3420 }
3421 }
3422 ListEntry::NewThread { .. } => {
3423 format!(" [+ New Thread]{}", selected)
3424 }
3425 }
3426 })
3427 .collect()
3428 })
3429 }
3430
3431 #[test]
3432 fn test_clean_mention_links() {
3433 // Simple mention link
3434 assert_eq!(
3435 Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
3436 "check @Button.tsx"
3437 );
3438
3439 // Multiple mention links
3440 assert_eq!(
3441 Sidebar::clean_mention_links(
3442 "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
3443 ),
3444 "look at @foo.rs and @bar.rs"
3445 );
3446
3447 // No mention links — passthrough
3448 assert_eq!(
3449 Sidebar::clean_mention_links("plain text with no mentions"),
3450 "plain text with no mentions"
3451 );
3452
3453 // Incomplete link syntax — preserved as-is
3454 assert_eq!(
3455 Sidebar::clean_mention_links("broken [@mention without closing"),
3456 "broken [@mention without closing"
3457 );
3458
3459 // Regular markdown link (no @) — not touched
3460 assert_eq!(
3461 Sidebar::clean_mention_links("see [docs](https://example.com)"),
3462 "see [docs](https://example.com)"
3463 );
3464
3465 // Empty input
3466 assert_eq!(Sidebar::clean_mention_links(""), "");
3467 }
3468
3469 #[gpui::test]
3470 async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
3471 let project = init_test_project("/my-project", cx).await;
3472 let (multi_workspace, cx) =
3473 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3474 let sidebar = setup_sidebar(&multi_workspace, cx);
3475
3476 let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
3477 let weak_sidebar = sidebar.downgrade();
3478 let weak_multi_workspace = multi_workspace.downgrade();
3479
3480 drop(sidebar);
3481 drop(multi_workspace);
3482 cx.update(|window, _cx| window.remove_window());
3483 cx.run_until_parked();
3484
3485 weak_multi_workspace.assert_released();
3486 weak_sidebar.assert_released();
3487 weak_workspace.assert_released();
3488 }
3489
3490 #[gpui::test]
3491 async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
3492 let project = init_test_project("/my-project", cx).await;
3493 let (multi_workspace, cx) =
3494 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3495 let sidebar = setup_sidebar(&multi_workspace, cx);
3496
3497 assert_eq!(
3498 visible_entries_as_strings(&sidebar, cx),
3499 vec!["v [my-project]", " [+ New Thread]"]
3500 );
3501 }
3502
3503 #[gpui::test]
3504 async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
3505 let project = init_test_project("/my-project", cx).await;
3506 let (multi_workspace, cx) =
3507 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3508 let sidebar = setup_sidebar(&multi_workspace, cx);
3509
3510 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3511
3512 save_thread_metadata(
3513 acp::SessionId::new(Arc::from("thread-1")),
3514 "Fix crash in project panel".into(),
3515 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
3516 path_list.clone(),
3517 cx,
3518 )
3519 .await;
3520
3521 save_thread_metadata(
3522 acp::SessionId::new(Arc::from("thread-2")),
3523 "Add inline diff view".into(),
3524 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3525 path_list.clone(),
3526 cx,
3527 )
3528 .await;
3529 cx.run_until_parked();
3530
3531 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3532 cx.run_until_parked();
3533
3534 assert_eq!(
3535 visible_entries_as_strings(&sidebar, cx),
3536 vec![
3537 "v [my-project]",
3538 " Fix crash in project panel",
3539 " Add inline diff view",
3540 ]
3541 );
3542 }
3543
3544 #[gpui::test]
3545 async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
3546 let project = init_test_project("/project-a", cx).await;
3547 let (multi_workspace, cx) =
3548 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3549 let sidebar = setup_sidebar(&multi_workspace, cx);
3550
3551 // Single workspace with a thread
3552 let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3553
3554 save_thread_metadata(
3555 acp::SessionId::new(Arc::from("thread-a1")),
3556 "Thread A1".into(),
3557 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3558 path_list.clone(),
3559 cx,
3560 )
3561 .await;
3562 cx.run_until_parked();
3563
3564 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3565 cx.run_until_parked();
3566
3567 assert_eq!(
3568 visible_entries_as_strings(&sidebar, cx),
3569 vec!["v [project-a]", " Thread A1"]
3570 );
3571
3572 // Add a second workspace
3573 multi_workspace.update_in(cx, |mw, window, cx| {
3574 mw.create_test_workspace(window, cx).detach();
3575 });
3576 cx.run_until_parked();
3577
3578 assert_eq!(
3579 visible_entries_as_strings(&sidebar, cx),
3580 vec!["v [project-a]", " Thread A1",]
3581 );
3582
3583 // Remove the second workspace
3584 multi_workspace.update_in(cx, |mw, window, cx| {
3585 mw.remove_workspace(1, window, cx);
3586 });
3587 cx.run_until_parked();
3588
3589 assert_eq!(
3590 visible_entries_as_strings(&sidebar, cx),
3591 vec!["v [project-a]", " Thread A1"]
3592 );
3593 }
3594
3595 #[gpui::test]
3596 async fn test_view_more_pagination(cx: &mut TestAppContext) {
3597 let project = init_test_project("/my-project", cx).await;
3598 let (multi_workspace, cx) =
3599 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3600 let sidebar = setup_sidebar(&multi_workspace, cx);
3601
3602 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3603 save_n_test_threads(12, &path_list, cx).await;
3604
3605 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3606 cx.run_until_parked();
3607
3608 assert_eq!(
3609 visible_entries_as_strings(&sidebar, cx),
3610 vec![
3611 "v [my-project]",
3612 " Thread 12",
3613 " Thread 11",
3614 " Thread 10",
3615 " Thread 9",
3616 " Thread 8",
3617 " + View More",
3618 ]
3619 );
3620 }
3621
3622 #[gpui::test]
3623 async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
3624 let project = init_test_project("/my-project", cx).await;
3625 let (multi_workspace, cx) =
3626 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3627 let sidebar = setup_sidebar(&multi_workspace, cx);
3628
3629 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3630 // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
3631 save_n_test_threads(17, &path_list, cx).await;
3632
3633 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3634 cx.run_until_parked();
3635
3636 // Initially shows 5 threads + View More
3637 let entries = visible_entries_as_strings(&sidebar, cx);
3638 assert_eq!(entries.len(), 7); // header + 5 threads + View More
3639 assert!(entries.iter().any(|e| e.contains("View More")));
3640
3641 // Focus and navigate to View More, then confirm to expand by one batch
3642 open_and_focus_sidebar(&sidebar, cx);
3643 for _ in 0..7 {
3644 cx.dispatch_action(SelectNext);
3645 }
3646 cx.dispatch_action(Confirm);
3647 cx.run_until_parked();
3648
3649 // Now shows 10 threads + View More
3650 let entries = visible_entries_as_strings(&sidebar, cx);
3651 assert_eq!(entries.len(), 12); // header + 10 threads + View More
3652 assert!(entries.iter().any(|e| e.contains("View More")));
3653
3654 // Expand again by one batch
3655 sidebar.update_in(cx, |s, _window, cx| {
3656 let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
3657 s.expanded_groups.insert(path_list.clone(), current + 1);
3658 s.update_entries(cx);
3659 });
3660 cx.run_until_parked();
3661
3662 // Now shows 15 threads + View More
3663 let entries = visible_entries_as_strings(&sidebar, cx);
3664 assert_eq!(entries.len(), 17); // header + 15 threads + View More
3665 assert!(entries.iter().any(|e| e.contains("View More")));
3666
3667 // Expand one more time - should show all 17 threads with Collapse button
3668 sidebar.update_in(cx, |s, _window, cx| {
3669 let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
3670 s.expanded_groups.insert(path_list.clone(), current + 1);
3671 s.update_entries(cx);
3672 });
3673 cx.run_until_parked();
3674
3675 // All 17 threads shown with Collapse button
3676 let entries = visible_entries_as_strings(&sidebar, cx);
3677 assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
3678 assert!(!entries.iter().any(|e| e.contains("View More")));
3679 assert!(entries.iter().any(|e| e.contains("Collapse")));
3680
3681 // Click collapse - should go back to showing 5 threads
3682 sidebar.update_in(cx, |s, _window, cx| {
3683 s.expanded_groups.remove(&path_list);
3684 s.update_entries(cx);
3685 });
3686 cx.run_until_parked();
3687
3688 // Back to initial state: 5 threads + View More
3689 let entries = visible_entries_as_strings(&sidebar, cx);
3690 assert_eq!(entries.len(), 7); // header + 5 threads + View More
3691 assert!(entries.iter().any(|e| e.contains("View More")));
3692 }
3693
3694 #[gpui::test]
3695 async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
3696 let project = init_test_project("/my-project", cx).await;
3697 let (multi_workspace, cx) =
3698 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3699 let sidebar = setup_sidebar(&multi_workspace, cx);
3700
3701 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3702 save_n_test_threads(1, &path_list, cx).await;
3703
3704 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3705 cx.run_until_parked();
3706
3707 assert_eq!(
3708 visible_entries_as_strings(&sidebar, cx),
3709 vec!["v [my-project]", " Thread 1"]
3710 );
3711
3712 // Collapse
3713 sidebar.update_in(cx, |s, window, cx| {
3714 s.toggle_collapse(&path_list, window, cx);
3715 });
3716 cx.run_until_parked();
3717
3718 assert_eq!(
3719 visible_entries_as_strings(&sidebar, cx),
3720 vec!["> [my-project]"]
3721 );
3722
3723 // Expand
3724 sidebar.update_in(cx, |s, window, cx| {
3725 s.toggle_collapse(&path_list, window, cx);
3726 });
3727 cx.run_until_parked();
3728
3729 assert_eq!(
3730 visible_entries_as_strings(&sidebar, cx),
3731 vec!["v [my-project]", " Thread 1"]
3732 );
3733 }
3734
3735 #[gpui::test]
3736 async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
3737 let project = init_test_project("/my-project", cx).await;
3738 let (multi_workspace, cx) =
3739 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3740 let sidebar = setup_sidebar(&multi_workspace, cx);
3741
3742 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3743 let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
3744 let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
3745
3746 sidebar.update_in(cx, |s, _window, _cx| {
3747 s.collapsed_groups.insert(collapsed_path.clone());
3748 s.contents
3749 .notified_threads
3750 .insert(acp::SessionId::new(Arc::from("t-5")));
3751 s.contents.entries = vec![
3752 // Expanded project header
3753 ListEntry::ProjectHeader {
3754 path_list: expanded_path.clone(),
3755 label: "expanded-project".into(),
3756 workspace: workspace.clone(),
3757 highlight_positions: Vec::new(),
3758 has_running_threads: false,
3759 waiting_thread_count: 0,
3760 is_active: true,
3761 },
3762 ListEntry::Thread(ThreadEntry {
3763 agent: Agent::NativeAgent,
3764 session_info: acp_thread::AgentSessionInfo {
3765 session_id: acp::SessionId::new(Arc::from("t-1")),
3766 work_dirs: None,
3767 title: Some("Completed thread".into()),
3768 updated_at: Some(Utc::now()),
3769 created_at: Some(Utc::now()),
3770 meta: None,
3771 },
3772 icon: IconName::ZedAgent,
3773 icon_from_external_svg: None,
3774 status: AgentThreadStatus::Completed,
3775 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3776 is_live: false,
3777 is_background: false,
3778 is_title_generating: false,
3779 highlight_positions: Vec::new(),
3780 worktree_name: None,
3781 worktree_full_path: None,
3782 worktree_highlight_positions: Vec::new(),
3783 diff_stats: DiffStats::default(),
3784 }),
3785 // Active thread with Running status
3786 ListEntry::Thread(ThreadEntry {
3787 agent: Agent::NativeAgent,
3788 session_info: acp_thread::AgentSessionInfo {
3789 session_id: acp::SessionId::new(Arc::from("t-2")),
3790 work_dirs: None,
3791 title: Some("Running thread".into()),
3792 updated_at: Some(Utc::now()),
3793 created_at: Some(Utc::now()),
3794 meta: None,
3795 },
3796 icon: IconName::ZedAgent,
3797 icon_from_external_svg: None,
3798 status: AgentThreadStatus::Running,
3799 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3800 is_live: true,
3801 is_background: false,
3802 is_title_generating: false,
3803 highlight_positions: Vec::new(),
3804 worktree_name: None,
3805 worktree_full_path: None,
3806 worktree_highlight_positions: Vec::new(),
3807 diff_stats: DiffStats::default(),
3808 }),
3809 // Active thread with Error status
3810 ListEntry::Thread(ThreadEntry {
3811 agent: Agent::NativeAgent,
3812 session_info: acp_thread::AgentSessionInfo {
3813 session_id: acp::SessionId::new(Arc::from("t-3")),
3814 work_dirs: None,
3815 title: Some("Error thread".into()),
3816 updated_at: Some(Utc::now()),
3817 created_at: Some(Utc::now()),
3818 meta: None,
3819 },
3820 icon: IconName::ZedAgent,
3821 icon_from_external_svg: None,
3822 status: AgentThreadStatus::Error,
3823 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3824 is_live: true,
3825 is_background: false,
3826 is_title_generating: false,
3827 highlight_positions: Vec::new(),
3828 worktree_name: None,
3829 worktree_full_path: None,
3830 worktree_highlight_positions: Vec::new(),
3831 diff_stats: DiffStats::default(),
3832 }),
3833 // Thread with WaitingForConfirmation status, not active
3834 ListEntry::Thread(ThreadEntry {
3835 agent: Agent::NativeAgent,
3836 session_info: acp_thread::AgentSessionInfo {
3837 session_id: acp::SessionId::new(Arc::from("t-4")),
3838 work_dirs: None,
3839 title: Some("Waiting thread".into()),
3840 updated_at: Some(Utc::now()),
3841 created_at: Some(Utc::now()),
3842 meta: None,
3843 },
3844 icon: IconName::ZedAgent,
3845 icon_from_external_svg: None,
3846 status: AgentThreadStatus::WaitingForConfirmation,
3847 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3848 is_live: false,
3849 is_background: false,
3850 is_title_generating: false,
3851 highlight_positions: Vec::new(),
3852 worktree_name: None,
3853 worktree_full_path: None,
3854 worktree_highlight_positions: Vec::new(),
3855 diff_stats: DiffStats::default(),
3856 }),
3857 // Background thread that completed (should show notification)
3858 ListEntry::Thread(ThreadEntry {
3859 agent: Agent::NativeAgent,
3860 session_info: acp_thread::AgentSessionInfo {
3861 session_id: acp::SessionId::new(Arc::from("t-5")),
3862 work_dirs: None,
3863 title: Some("Notified thread".into()),
3864 updated_at: Some(Utc::now()),
3865 created_at: Some(Utc::now()),
3866 meta: None,
3867 },
3868 icon: IconName::ZedAgent,
3869 icon_from_external_svg: None,
3870 status: AgentThreadStatus::Completed,
3871 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3872 is_live: true,
3873 is_background: true,
3874 is_title_generating: false,
3875 highlight_positions: Vec::new(),
3876 worktree_name: None,
3877 worktree_full_path: None,
3878 worktree_highlight_positions: Vec::new(),
3879 diff_stats: DiffStats::default(),
3880 }),
3881 // View More entry
3882 ListEntry::ViewMore {
3883 path_list: expanded_path.clone(),
3884 is_fully_expanded: false,
3885 },
3886 // Collapsed project header
3887 ListEntry::ProjectHeader {
3888 path_list: collapsed_path.clone(),
3889 label: "collapsed-project".into(),
3890 workspace: workspace.clone(),
3891 highlight_positions: Vec::new(),
3892 has_running_threads: false,
3893 waiting_thread_count: 0,
3894 is_active: false,
3895 },
3896 ];
3897
3898 // Select the Running thread (index 2)
3899 s.selection = Some(2);
3900 });
3901
3902 assert_eq!(
3903 visible_entries_as_strings(&sidebar, cx),
3904 vec![
3905 "v [expanded-project]",
3906 " Completed thread",
3907 " Running thread * (running) <== selected",
3908 " Error thread * (error)",
3909 " Waiting thread (waiting)",
3910 " Notified thread * (!)",
3911 " + View More",
3912 "> [collapsed-project]",
3913 ]
3914 );
3915
3916 // Move selection to the collapsed header
3917 sidebar.update_in(cx, |s, _window, _cx| {
3918 s.selection = Some(7);
3919 });
3920
3921 assert_eq!(
3922 visible_entries_as_strings(&sidebar, cx).last().cloned(),
3923 Some("> [collapsed-project] <== selected".to_string()),
3924 );
3925
3926 // Clear selection
3927 sidebar.update_in(cx, |s, _window, _cx| {
3928 s.selection = None;
3929 });
3930
3931 // No entry should have the selected marker
3932 let entries = visible_entries_as_strings(&sidebar, cx);
3933 for entry in &entries {
3934 assert!(
3935 !entry.contains("<== selected"),
3936 "unexpected selection marker in: {}",
3937 entry
3938 );
3939 }
3940 }
3941
3942 #[gpui::test]
3943 async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
3944 let project = init_test_project("/my-project", cx).await;
3945 let (multi_workspace, cx) =
3946 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3947 let sidebar = setup_sidebar(&multi_workspace, cx);
3948
3949 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3950 save_n_test_threads(3, &path_list, cx).await;
3951
3952 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3953 cx.run_until_parked();
3954
3955 // Entries: [header, thread3, thread2, thread1]
3956 // Focusing the sidebar does not set a selection; select_next/select_previous
3957 // handle None gracefully by starting from the first or last entry.
3958 open_and_focus_sidebar(&sidebar, cx);
3959 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3960
3961 // First SelectNext from None starts at index 0
3962 cx.dispatch_action(SelectNext);
3963 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3964
3965 // Move down through remaining entries
3966 cx.dispatch_action(SelectNext);
3967 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3968
3969 cx.dispatch_action(SelectNext);
3970 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
3971
3972 cx.dispatch_action(SelectNext);
3973 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
3974
3975 // At the end, wraps back to first entry
3976 cx.dispatch_action(SelectNext);
3977 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3978
3979 // Navigate back to the end
3980 cx.dispatch_action(SelectNext);
3981 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3982 cx.dispatch_action(SelectNext);
3983 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
3984 cx.dispatch_action(SelectNext);
3985 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
3986
3987 // Move back up
3988 cx.dispatch_action(SelectPrevious);
3989 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
3990
3991 cx.dispatch_action(SelectPrevious);
3992 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3993
3994 cx.dispatch_action(SelectPrevious);
3995 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3996
3997 // At the top, selection clears (focus returns to editor)
3998 cx.dispatch_action(SelectPrevious);
3999 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4000 }
4001
4002 #[gpui::test]
4003 async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
4004 let project = init_test_project("/my-project", cx).await;
4005 let (multi_workspace, cx) =
4006 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4007 let sidebar = setup_sidebar(&multi_workspace, cx);
4008
4009 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4010 save_n_test_threads(3, &path_list, cx).await;
4011 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4012 cx.run_until_parked();
4013
4014 open_and_focus_sidebar(&sidebar, cx);
4015
4016 // SelectLast jumps to the end
4017 cx.dispatch_action(SelectLast);
4018 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
4019
4020 // SelectFirst jumps to the beginning
4021 cx.dispatch_action(SelectFirst);
4022 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4023 }
4024
4025 #[gpui::test]
4026 async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
4027 let project = init_test_project("/my-project", cx).await;
4028 let (multi_workspace, cx) =
4029 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4030 let sidebar = setup_sidebar(&multi_workspace, cx);
4031
4032 // Initially no selection
4033 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4034
4035 // Open the sidebar so it's rendered, then focus it to trigger focus_in.
4036 // focus_in no longer sets a default selection.
4037 open_and_focus_sidebar(&sidebar, cx);
4038 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4039
4040 // Manually set a selection, blur, then refocus — selection should be preserved
4041 sidebar.update_in(cx, |sidebar, _window, _cx| {
4042 sidebar.selection = Some(0);
4043 });
4044
4045 cx.update(|window, _cx| {
4046 window.blur();
4047 });
4048 cx.run_until_parked();
4049
4050 sidebar.update_in(cx, |_, window, cx| {
4051 cx.focus_self(window);
4052 });
4053 cx.run_until_parked();
4054 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4055 }
4056
4057 #[gpui::test]
4058 async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
4059 let project = init_test_project("/my-project", cx).await;
4060 let (multi_workspace, cx) =
4061 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4062 let sidebar = setup_sidebar(&multi_workspace, cx);
4063
4064 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4065 save_n_test_threads(1, &path_list, cx).await;
4066 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4067 cx.run_until_parked();
4068
4069 assert_eq!(
4070 visible_entries_as_strings(&sidebar, cx),
4071 vec!["v [my-project]", " Thread 1"]
4072 );
4073
4074 // Focus the sidebar and select the header (index 0)
4075 open_and_focus_sidebar(&sidebar, cx);
4076 sidebar.update_in(cx, |sidebar, _window, _cx| {
4077 sidebar.selection = Some(0);
4078 });
4079
4080 // Confirm on project header collapses the group
4081 cx.dispatch_action(Confirm);
4082 cx.run_until_parked();
4083
4084 assert_eq!(
4085 visible_entries_as_strings(&sidebar, cx),
4086 vec!["> [my-project] <== selected"]
4087 );
4088
4089 // Confirm again expands the group
4090 cx.dispatch_action(Confirm);
4091 cx.run_until_parked();
4092
4093 assert_eq!(
4094 visible_entries_as_strings(&sidebar, cx),
4095 vec!["v [my-project] <== selected", " Thread 1",]
4096 );
4097 }
4098
4099 #[gpui::test]
4100 async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
4101 let project = init_test_project("/my-project", cx).await;
4102 let (multi_workspace, cx) =
4103 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4104 let sidebar = setup_sidebar(&multi_workspace, cx);
4105
4106 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4107 save_n_test_threads(8, &path_list, cx).await;
4108 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4109 cx.run_until_parked();
4110
4111 // Should show header + 5 threads + "View More"
4112 let entries = visible_entries_as_strings(&sidebar, cx);
4113 assert_eq!(entries.len(), 7);
4114 assert!(entries.iter().any(|e| e.contains("View More")));
4115
4116 // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
4117 open_and_focus_sidebar(&sidebar, cx);
4118 for _ in 0..7 {
4119 cx.dispatch_action(SelectNext);
4120 }
4121 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
4122
4123 // Confirm on "View More" to expand
4124 cx.dispatch_action(Confirm);
4125 cx.run_until_parked();
4126
4127 // All 8 threads should now be visible with a "Collapse" button
4128 let entries = visible_entries_as_strings(&sidebar, cx);
4129 assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
4130 assert!(!entries.iter().any(|e| e.contains("View More")));
4131 assert!(entries.iter().any(|e| e.contains("Collapse")));
4132 }
4133
4134 #[gpui::test]
4135 async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
4136 let project = init_test_project("/my-project", cx).await;
4137 let (multi_workspace, cx) =
4138 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4139 let sidebar = setup_sidebar(&multi_workspace, cx);
4140
4141 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4142 save_n_test_threads(1, &path_list, cx).await;
4143 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4144 cx.run_until_parked();
4145
4146 assert_eq!(
4147 visible_entries_as_strings(&sidebar, cx),
4148 vec!["v [my-project]", " Thread 1"]
4149 );
4150
4151 // Focus sidebar and manually select the header (index 0). Press left to collapse.
4152 open_and_focus_sidebar(&sidebar, cx);
4153 sidebar.update_in(cx, |sidebar, _window, _cx| {
4154 sidebar.selection = Some(0);
4155 });
4156
4157 cx.dispatch_action(SelectParent);
4158 cx.run_until_parked();
4159
4160 assert_eq!(
4161 visible_entries_as_strings(&sidebar, cx),
4162 vec!["> [my-project] <== selected"]
4163 );
4164
4165 // Press right to expand
4166 cx.dispatch_action(SelectChild);
4167 cx.run_until_parked();
4168
4169 assert_eq!(
4170 visible_entries_as_strings(&sidebar, cx),
4171 vec!["v [my-project] <== selected", " Thread 1",]
4172 );
4173
4174 // Press right again on already-expanded header moves selection down
4175 cx.dispatch_action(SelectChild);
4176 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4177 }
4178
4179 #[gpui::test]
4180 async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
4181 let project = init_test_project("/my-project", cx).await;
4182 let (multi_workspace, cx) =
4183 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4184 let sidebar = setup_sidebar(&multi_workspace, cx);
4185
4186 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4187 save_n_test_threads(1, &path_list, cx).await;
4188 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4189 cx.run_until_parked();
4190
4191 // Focus sidebar (selection starts at None), then navigate down to the thread (child)
4192 open_and_focus_sidebar(&sidebar, cx);
4193 cx.dispatch_action(SelectNext);
4194 cx.dispatch_action(SelectNext);
4195 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4196
4197 assert_eq!(
4198 visible_entries_as_strings(&sidebar, cx),
4199 vec!["v [my-project]", " Thread 1 <== selected",]
4200 );
4201
4202 // Pressing left on a child collapses the parent group and selects it
4203 cx.dispatch_action(SelectParent);
4204 cx.run_until_parked();
4205
4206 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4207 assert_eq!(
4208 visible_entries_as_strings(&sidebar, cx),
4209 vec!["> [my-project] <== selected"]
4210 );
4211 }
4212
4213 #[gpui::test]
4214 async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
4215 let project = init_test_project("/empty-project", cx).await;
4216 let (multi_workspace, cx) =
4217 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4218 let sidebar = setup_sidebar(&multi_workspace, cx);
4219
4220 // An empty project has the header and a new thread button.
4221 assert_eq!(
4222 visible_entries_as_strings(&sidebar, cx),
4223 vec!["v [empty-project]", " [+ New Thread]"]
4224 );
4225
4226 // Focus sidebar — focus_in does not set a selection
4227 open_and_focus_sidebar(&sidebar, cx);
4228 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4229
4230 // First SelectNext from None starts at index 0 (header)
4231 cx.dispatch_action(SelectNext);
4232 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4233
4234 // SelectNext moves to the new thread button
4235 cx.dispatch_action(SelectNext);
4236 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4237
4238 // At the end, wraps back to first entry
4239 cx.dispatch_action(SelectNext);
4240 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4241
4242 // SelectPrevious from first entry clears selection (returns to editor)
4243 cx.dispatch_action(SelectPrevious);
4244 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4245 }
4246
4247 #[gpui::test]
4248 async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
4249 let project = init_test_project("/my-project", cx).await;
4250 let (multi_workspace, cx) =
4251 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4252 let sidebar = setup_sidebar(&multi_workspace, cx);
4253
4254 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4255 save_n_test_threads(1, &path_list, cx).await;
4256 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4257 cx.run_until_parked();
4258
4259 // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
4260 open_and_focus_sidebar(&sidebar, cx);
4261 cx.dispatch_action(SelectNext);
4262 cx.dispatch_action(SelectNext);
4263 assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4264
4265 // Collapse the group, which removes the thread from the list
4266 cx.dispatch_action(SelectParent);
4267 cx.run_until_parked();
4268
4269 // Selection should be clamped to the last valid index (0 = header)
4270 let selection = sidebar.read_with(cx, |s, _| s.selection);
4271 let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
4272 assert!(
4273 selection.unwrap_or(0) < entry_count,
4274 "selection {} should be within bounds (entries: {})",
4275 selection.unwrap_or(0),
4276 entry_count,
4277 );
4278 }
4279
4280 async fn init_test_project_with_agent_panel(
4281 worktree_path: &str,
4282 cx: &mut TestAppContext,
4283 ) -> Entity<project::Project> {
4284 agent_ui::test_support::init_test(cx);
4285 cx.update(|cx| {
4286 cx.update_flags(false, vec!["agent-v2".into()]);
4287 ThreadStore::init_global(cx);
4288 SidebarThreadMetadataStore::init_global(cx);
4289 language_model::LanguageModelRegistry::test(cx);
4290 prompt_store::init(cx);
4291 });
4292
4293 let fs = FakeFs::new(cx.executor());
4294 fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
4295 .await;
4296 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4297 project::Project::test(fs, [worktree_path.as_ref()], cx).await
4298 }
4299
4300 fn add_agent_panel(
4301 workspace: &Entity<Workspace>,
4302 project: &Entity<project::Project>,
4303 cx: &mut gpui::VisualTestContext,
4304 ) -> Entity<AgentPanel> {
4305 workspace.update_in(cx, |workspace, window, cx| {
4306 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4307 let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
4308 workspace.add_panel(panel.clone(), window, cx);
4309 panel
4310 })
4311 }
4312
4313 fn setup_sidebar_with_agent_panel(
4314 multi_workspace: &Entity<MultiWorkspace>,
4315 project: &Entity<project::Project>,
4316 cx: &mut gpui::VisualTestContext,
4317 ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
4318 let sidebar = setup_sidebar(multi_workspace, cx);
4319 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
4320 let panel = add_agent_panel(&workspace, project, cx);
4321 (sidebar, panel)
4322 }
4323
4324 #[gpui::test]
4325 async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
4326 let project = init_test_project_with_agent_panel("/my-project", cx).await;
4327 let (multi_workspace, cx) =
4328 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4329 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
4330
4331 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4332
4333 // Open thread A and keep it generating.
4334 let connection = StubAgentConnection::new();
4335 open_thread_with_connection(&panel, connection.clone(), cx);
4336 send_message(&panel, cx);
4337
4338 let session_id_a = active_session_id(&panel, cx);
4339 save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await;
4340
4341 cx.update(|_, cx| {
4342 connection.send_update(
4343 session_id_a.clone(),
4344 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4345 cx,
4346 );
4347 });
4348 cx.run_until_parked();
4349
4350 // Open thread B (idle, default response) — thread A goes to background.
4351 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4352 acp::ContentChunk::new("Done".into()),
4353 )]);
4354 open_thread_with_connection(&panel, connection, cx);
4355 send_message(&panel, cx);
4356
4357 let session_id_b = active_session_id(&panel, cx);
4358 save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await;
4359
4360 cx.run_until_parked();
4361
4362 let mut entries = visible_entries_as_strings(&sidebar, cx);
4363 entries[1..].sort();
4364 assert_eq!(
4365 entries,
4366 vec!["v [my-project]", " Hello *", " Hello * (running)",]
4367 );
4368 }
4369
4370 #[gpui::test]
4371 async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
4372 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
4373 let (multi_workspace, cx) = cx
4374 .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4375 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
4376
4377 let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4378
4379 // Open thread on workspace A and keep it generating.
4380 let connection_a = StubAgentConnection::new();
4381 open_thread_with_connection(&panel_a, connection_a.clone(), cx);
4382 send_message(&panel_a, cx);
4383
4384 let session_id_a = active_session_id(&panel_a, cx);
4385 save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
4386
4387 cx.update(|_, cx| {
4388 connection_a.send_update(
4389 session_id_a.clone(),
4390 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4391 cx,
4392 );
4393 });
4394 cx.run_until_parked();
4395
4396 // Add a second workspace and activate it (making workspace A the background).
4397 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
4398 let project_b = project::Project::test(fs, [], cx).await;
4399 multi_workspace.update_in(cx, |mw, window, cx| {
4400 mw.test_add_workspace(project_b, window, cx);
4401 });
4402 cx.run_until_parked();
4403
4404 // Thread A is still running; no notification yet.
4405 assert_eq!(
4406 visible_entries_as_strings(&sidebar, cx),
4407 vec!["v [project-a]", " Hello * (running)",]
4408 );
4409
4410 // Complete thread A's turn (transition Running → Completed).
4411 connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
4412 cx.run_until_parked();
4413
4414 // The completed background thread shows a notification indicator.
4415 assert_eq!(
4416 visible_entries_as_strings(&sidebar, cx),
4417 vec!["v [project-a]", " Hello * (!)",]
4418 );
4419 }
4420
4421 fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
4422 sidebar.update_in(cx, |sidebar, window, cx| {
4423 window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
4424 sidebar.filter_editor.update(cx, |editor, cx| {
4425 editor.set_text(query, window, cx);
4426 });
4427 });
4428 cx.run_until_parked();
4429 }
4430
4431 #[gpui::test]
4432 async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
4433 let project = init_test_project("/my-project", cx).await;
4434 let (multi_workspace, cx) =
4435 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4436 let sidebar = setup_sidebar(&multi_workspace, cx);
4437
4438 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4439
4440 for (id, title, hour) in [
4441 ("t-1", "Fix crash in project panel", 3),
4442 ("t-2", "Add inline diff view", 2),
4443 ("t-3", "Refactor settings module", 1),
4444 ] {
4445 save_thread_metadata(
4446 acp::SessionId::new(Arc::from(id)),
4447 title.into(),
4448 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4449 path_list.clone(),
4450 cx,
4451 )
4452 .await;
4453 }
4454 cx.run_until_parked();
4455
4456 assert_eq!(
4457 visible_entries_as_strings(&sidebar, cx),
4458 vec![
4459 "v [my-project]",
4460 " Fix crash in project panel",
4461 " Add inline diff view",
4462 " Refactor settings module",
4463 ]
4464 );
4465
4466 // User types "diff" in the search box — only the matching thread remains,
4467 // with its workspace header preserved for context.
4468 type_in_search(&sidebar, "diff", cx);
4469 assert_eq!(
4470 visible_entries_as_strings(&sidebar, cx),
4471 vec!["v [my-project]", " Add inline diff view <== selected",]
4472 );
4473
4474 // User changes query to something with no matches — list is empty.
4475 type_in_search(&sidebar, "nonexistent", cx);
4476 assert_eq!(
4477 visible_entries_as_strings(&sidebar, cx),
4478 Vec::<String>::new()
4479 );
4480 }
4481
4482 #[gpui::test]
4483 async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
4484 // Scenario: A user remembers a thread title but not the exact casing.
4485 // Search should match case-insensitively so they can still find it.
4486 let project = init_test_project("/my-project", cx).await;
4487 let (multi_workspace, cx) =
4488 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4489 let sidebar = setup_sidebar(&multi_workspace, cx);
4490
4491 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4492
4493 save_thread_metadata(
4494 acp::SessionId::new(Arc::from("thread-1")),
4495 "Fix Crash In Project Panel".into(),
4496 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4497 path_list.clone(),
4498 cx,
4499 )
4500 .await;
4501 cx.run_until_parked();
4502
4503 // Lowercase query matches mixed-case title.
4504 type_in_search(&sidebar, "fix crash", cx);
4505 assert_eq!(
4506 visible_entries_as_strings(&sidebar, cx),
4507 vec![
4508 "v [my-project]",
4509 " Fix Crash In Project Panel <== selected",
4510 ]
4511 );
4512
4513 // Uppercase query also matches the same title.
4514 type_in_search(&sidebar, "FIX CRASH", cx);
4515 assert_eq!(
4516 visible_entries_as_strings(&sidebar, cx),
4517 vec![
4518 "v [my-project]",
4519 " Fix Crash In Project Panel <== selected",
4520 ]
4521 );
4522 }
4523
4524 #[gpui::test]
4525 async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
4526 // Scenario: A user searches, finds what they need, then presses Escape
4527 // to dismiss the filter and see the full list again.
4528 let project = init_test_project("/my-project", cx).await;
4529 let (multi_workspace, cx) =
4530 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4531 let sidebar = setup_sidebar(&multi_workspace, cx);
4532
4533 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4534
4535 for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
4536 save_thread_metadata(
4537 acp::SessionId::new(Arc::from(id)),
4538 title.into(),
4539 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4540 path_list.clone(),
4541 cx,
4542 )
4543 .await;
4544 }
4545 cx.run_until_parked();
4546
4547 // Confirm the full list is showing.
4548 assert_eq!(
4549 visible_entries_as_strings(&sidebar, cx),
4550 vec!["v [my-project]", " Alpha thread", " Beta thread",]
4551 );
4552
4553 // User types a search query to filter down.
4554 open_and_focus_sidebar(&sidebar, cx);
4555 type_in_search(&sidebar, "alpha", cx);
4556 assert_eq!(
4557 visible_entries_as_strings(&sidebar, cx),
4558 vec!["v [my-project]", " Alpha thread <== selected",]
4559 );
4560
4561 // User presses Escape — filter clears, full list is restored.
4562 // The selection index (1) now points at the first thread entry.
4563 cx.dispatch_action(Cancel);
4564 cx.run_until_parked();
4565 assert_eq!(
4566 visible_entries_as_strings(&sidebar, cx),
4567 vec![
4568 "v [my-project]",
4569 " Alpha thread <== selected",
4570 " Beta thread",
4571 ]
4572 );
4573 }
4574
4575 #[gpui::test]
4576 async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
4577 let project_a = init_test_project("/project-a", cx).await;
4578 let (multi_workspace, cx) =
4579 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4580 let sidebar = setup_sidebar(&multi_workspace, cx);
4581
4582 let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4583
4584 for (id, title, hour) in [
4585 ("a1", "Fix bug in sidebar", 2),
4586 ("a2", "Add tests for editor", 1),
4587 ] {
4588 save_thread_metadata(
4589 acp::SessionId::new(Arc::from(id)),
4590 title.into(),
4591 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4592 path_list_a.clone(),
4593 cx,
4594 )
4595 .await;
4596 }
4597
4598 // Add a second workspace.
4599 multi_workspace.update_in(cx, |mw, window, cx| {
4600 mw.create_test_workspace(window, cx).detach();
4601 });
4602 cx.run_until_parked();
4603
4604 let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
4605
4606 for (id, title, hour) in [
4607 ("b1", "Refactor sidebar layout", 3),
4608 ("b2", "Fix typo in README", 1),
4609 ] {
4610 save_thread_metadata(
4611 acp::SessionId::new(Arc::from(id)),
4612 title.into(),
4613 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4614 path_list_b.clone(),
4615 cx,
4616 )
4617 .await;
4618 }
4619 cx.run_until_parked();
4620
4621 assert_eq!(
4622 visible_entries_as_strings(&sidebar, cx),
4623 vec![
4624 "v [project-a]",
4625 " Fix bug in sidebar",
4626 " Add tests for editor",
4627 ]
4628 );
4629
4630 // "sidebar" matches a thread in each workspace — both headers stay visible.
4631 type_in_search(&sidebar, "sidebar", cx);
4632 assert_eq!(
4633 visible_entries_as_strings(&sidebar, cx),
4634 vec!["v [project-a]", " Fix bug in sidebar <== selected",]
4635 );
4636
4637 // "typo" only matches in the second workspace — the first header disappears.
4638 type_in_search(&sidebar, "typo", cx);
4639 assert_eq!(
4640 visible_entries_as_strings(&sidebar, cx),
4641 Vec::<String>::new()
4642 );
4643
4644 // "project-a" matches the first workspace name — the header appears
4645 // with all child threads included.
4646 type_in_search(&sidebar, "project-a", cx);
4647 assert_eq!(
4648 visible_entries_as_strings(&sidebar, cx),
4649 vec![
4650 "v [project-a]",
4651 " Fix bug in sidebar <== selected",
4652 " Add tests for editor",
4653 ]
4654 );
4655 }
4656
4657 #[gpui::test]
4658 async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
4659 let project_a = init_test_project("/alpha-project", cx).await;
4660 let (multi_workspace, cx) =
4661 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4662 let sidebar = setup_sidebar(&multi_workspace, cx);
4663
4664 let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
4665
4666 for (id, title, hour) in [
4667 ("a1", "Fix bug in sidebar", 2),
4668 ("a2", "Add tests for editor", 1),
4669 ] {
4670 save_thread_metadata(
4671 acp::SessionId::new(Arc::from(id)),
4672 title.into(),
4673 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4674 path_list_a.clone(),
4675 cx,
4676 )
4677 .await;
4678 }
4679
4680 // Add a second workspace.
4681 multi_workspace.update_in(cx, |mw, window, cx| {
4682 mw.create_test_workspace(window, cx).detach();
4683 });
4684 cx.run_until_parked();
4685
4686 let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
4687
4688 for (id, title, hour) in [
4689 ("b1", "Refactor sidebar layout", 3),
4690 ("b2", "Fix typo in README", 1),
4691 ] {
4692 save_thread_metadata(
4693 acp::SessionId::new(Arc::from(id)),
4694 title.into(),
4695 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4696 path_list_b.clone(),
4697 cx,
4698 )
4699 .await;
4700 }
4701 cx.run_until_parked();
4702
4703 // "alpha" matches the workspace name "alpha-project" but no thread titles.
4704 // The workspace header should appear with all child threads included.
4705 type_in_search(&sidebar, "alpha", cx);
4706 assert_eq!(
4707 visible_entries_as_strings(&sidebar, cx),
4708 vec![
4709 "v [alpha-project]",
4710 " Fix bug in sidebar <== selected",
4711 " Add tests for editor",
4712 ]
4713 );
4714
4715 // "sidebar" matches thread titles in both workspaces but not workspace names.
4716 // Both headers appear with their matching threads.
4717 type_in_search(&sidebar, "sidebar", cx);
4718 assert_eq!(
4719 visible_entries_as_strings(&sidebar, cx),
4720 vec!["v [alpha-project]", " Fix bug in sidebar <== selected",]
4721 );
4722
4723 // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
4724 // doesn't match) — but does not match either workspace name or any thread.
4725 // Actually let's test something simpler: a query that matches both a workspace
4726 // name AND some threads in that workspace. Matching threads should still appear.
4727 type_in_search(&sidebar, "fix", cx);
4728 assert_eq!(
4729 visible_entries_as_strings(&sidebar, cx),
4730 vec!["v [alpha-project]", " Fix bug in sidebar <== selected",]
4731 );
4732
4733 // A query that matches a workspace name AND a thread in that same workspace.
4734 // Both the header (highlighted) and all child threads should appear.
4735 type_in_search(&sidebar, "alpha", cx);
4736 assert_eq!(
4737 visible_entries_as_strings(&sidebar, cx),
4738 vec![
4739 "v [alpha-project]",
4740 " Fix bug in sidebar <== selected",
4741 " Add tests for editor",
4742 ]
4743 );
4744
4745 // Now search for something that matches only a workspace name when there
4746 // are also threads with matching titles — the non-matching workspace's
4747 // threads should still appear if their titles match.
4748 type_in_search(&sidebar, "alp", cx);
4749 assert_eq!(
4750 visible_entries_as_strings(&sidebar, cx),
4751 vec![
4752 "v [alpha-project]",
4753 " Fix bug in sidebar <== selected",
4754 " Add tests for editor",
4755 ]
4756 );
4757 }
4758
4759 #[gpui::test]
4760 async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
4761 let project = init_test_project("/my-project", cx).await;
4762 let (multi_workspace, cx) =
4763 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4764 let sidebar = setup_sidebar(&multi_workspace, cx);
4765
4766 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4767
4768 // Create 8 threads. The oldest one has a unique name and will be
4769 // behind View More (only 5 shown by default).
4770 for i in 0..8u32 {
4771 let title = if i == 0 {
4772 "Hidden gem thread".to_string()
4773 } else {
4774 format!("Thread {}", i + 1)
4775 };
4776 save_thread_metadata(
4777 acp::SessionId::new(Arc::from(format!("thread-{}", i))),
4778 title.into(),
4779 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
4780 path_list.clone(),
4781 cx,
4782 )
4783 .await;
4784 }
4785 cx.run_until_parked();
4786
4787 // Confirm the thread is not visible and View More is shown.
4788 let entries = visible_entries_as_strings(&sidebar, cx);
4789 assert!(
4790 entries.iter().any(|e| e.contains("View More")),
4791 "should have View More button"
4792 );
4793 assert!(
4794 !entries.iter().any(|e| e.contains("Hidden gem")),
4795 "Hidden gem should be behind View More"
4796 );
4797
4798 // User searches for the hidden thread — it appears, and View More is gone.
4799 type_in_search(&sidebar, "hidden gem", cx);
4800 let filtered = visible_entries_as_strings(&sidebar, cx);
4801 assert_eq!(
4802 filtered,
4803 vec!["v [my-project]", " Hidden gem thread <== selected",]
4804 );
4805 assert!(
4806 !filtered.iter().any(|e| e.contains("View More")),
4807 "View More should not appear when filtering"
4808 );
4809 }
4810
4811 #[gpui::test]
4812 async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
4813 let project = init_test_project("/my-project", cx).await;
4814 let (multi_workspace, cx) =
4815 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4816 let sidebar = setup_sidebar(&multi_workspace, cx);
4817
4818 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4819
4820 save_thread_metadata(
4821 acp::SessionId::new(Arc::from("thread-1")),
4822 "Important thread".into(),
4823 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4824 path_list.clone(),
4825 cx,
4826 )
4827 .await;
4828 cx.run_until_parked();
4829
4830 // User focuses the sidebar and collapses the group using keyboard:
4831 // manually select the header, then press SelectParent to collapse.
4832 open_and_focus_sidebar(&sidebar, cx);
4833 sidebar.update_in(cx, |sidebar, _window, _cx| {
4834 sidebar.selection = Some(0);
4835 });
4836 cx.dispatch_action(SelectParent);
4837 cx.run_until_parked();
4838
4839 assert_eq!(
4840 visible_entries_as_strings(&sidebar, cx),
4841 vec!["> [my-project] <== selected"]
4842 );
4843
4844 // User types a search — the thread appears even though its group is collapsed.
4845 type_in_search(&sidebar, "important", cx);
4846 assert_eq!(
4847 visible_entries_as_strings(&sidebar, cx),
4848 vec!["> [my-project]", " Important thread <== selected",]
4849 );
4850 }
4851
4852 #[gpui::test]
4853 async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
4854 let project = init_test_project("/my-project", cx).await;
4855 let (multi_workspace, cx) =
4856 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4857 let sidebar = setup_sidebar(&multi_workspace, cx);
4858
4859 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4860
4861 for (id, title, hour) in [
4862 ("t-1", "Fix crash in panel", 3),
4863 ("t-2", "Fix lint warnings", 2),
4864 ("t-3", "Add new feature", 1),
4865 ] {
4866 save_thread_metadata(
4867 acp::SessionId::new(Arc::from(id)),
4868 title.into(),
4869 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4870 path_list.clone(),
4871 cx,
4872 )
4873 .await;
4874 }
4875 cx.run_until_parked();
4876
4877 open_and_focus_sidebar(&sidebar, cx);
4878
4879 // User types "fix" — two threads match.
4880 type_in_search(&sidebar, "fix", cx);
4881 assert_eq!(
4882 visible_entries_as_strings(&sidebar, cx),
4883 vec![
4884 "v [my-project]",
4885 " Fix crash in panel <== selected",
4886 " Fix lint warnings",
4887 ]
4888 );
4889
4890 // Selection starts on the first matching thread. User presses
4891 // SelectNext to move to the second match.
4892 cx.dispatch_action(SelectNext);
4893 assert_eq!(
4894 visible_entries_as_strings(&sidebar, cx),
4895 vec![
4896 "v [my-project]",
4897 " Fix crash in panel",
4898 " Fix lint warnings <== selected",
4899 ]
4900 );
4901
4902 // User can also jump back with SelectPrevious.
4903 cx.dispatch_action(SelectPrevious);
4904 assert_eq!(
4905 visible_entries_as_strings(&sidebar, cx),
4906 vec![
4907 "v [my-project]",
4908 " Fix crash in panel <== selected",
4909 " Fix lint warnings",
4910 ]
4911 );
4912 }
4913
4914 #[gpui::test]
4915 async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
4916 let project = init_test_project("/my-project", cx).await;
4917 let (multi_workspace, cx) =
4918 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4919 let sidebar = setup_sidebar(&multi_workspace, cx);
4920
4921 multi_workspace.update_in(cx, |mw, window, cx| {
4922 mw.create_test_workspace(window, cx).detach();
4923 });
4924 cx.run_until_parked();
4925
4926 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4927
4928 save_thread_metadata(
4929 acp::SessionId::new(Arc::from("hist-1")),
4930 "Historical Thread".into(),
4931 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
4932 path_list.clone(),
4933 cx,
4934 )
4935 .await;
4936 cx.run_until_parked();
4937 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4938 cx.run_until_parked();
4939
4940 assert_eq!(
4941 visible_entries_as_strings(&sidebar, cx),
4942 vec!["v [my-project]", " Historical Thread",]
4943 );
4944
4945 // Switch to workspace 1 so we can verify the confirm switches back.
4946 multi_workspace.update_in(cx, |mw, window, cx| {
4947 mw.activate_index(1, window, cx);
4948 });
4949 cx.run_until_parked();
4950 assert_eq!(
4951 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4952 1
4953 );
4954
4955 // Confirm on the historical (non-live) thread at index 1.
4956 // Before a previous fix, the workspace field was Option<usize> and
4957 // historical threads had None, so activate_thread early-returned
4958 // without switching the workspace.
4959 sidebar.update_in(cx, |sidebar, window, cx| {
4960 sidebar.selection = Some(1);
4961 sidebar.confirm(&Confirm, window, cx);
4962 });
4963 cx.run_until_parked();
4964
4965 assert_eq!(
4966 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4967 0
4968 );
4969 }
4970
4971 #[gpui::test]
4972 async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
4973 let project = init_test_project("/my-project", cx).await;
4974 let (multi_workspace, cx) =
4975 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4976 let sidebar = setup_sidebar(&multi_workspace, cx);
4977
4978 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4979
4980 save_thread_metadata(
4981 acp::SessionId::new(Arc::from("t-1")),
4982 "Thread A".into(),
4983 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
4984 path_list.clone(),
4985 cx,
4986 )
4987 .await;
4988
4989 save_thread_metadata(
4990 acp::SessionId::new(Arc::from("t-2")),
4991 "Thread B".into(),
4992 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4993 path_list.clone(),
4994 cx,
4995 )
4996 .await;
4997
4998 cx.run_until_parked();
4999 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5000 cx.run_until_parked();
5001
5002 assert_eq!(
5003 visible_entries_as_strings(&sidebar, cx),
5004 vec!["v [my-project]", " Thread A", " Thread B",]
5005 );
5006
5007 // Keyboard confirm preserves selection.
5008 sidebar.update_in(cx, |sidebar, window, cx| {
5009 sidebar.selection = Some(1);
5010 sidebar.confirm(&Confirm, window, cx);
5011 });
5012 assert_eq!(
5013 sidebar.read_with(cx, |sidebar, _| sidebar.selection),
5014 Some(1)
5015 );
5016
5017 // Click handlers clear selection to None so no highlight lingers
5018 // after a click regardless of focus state. The hover style provides
5019 // visual feedback during mouse interaction instead.
5020 sidebar.update_in(cx, |sidebar, window, cx| {
5021 sidebar.selection = None;
5022 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5023 sidebar.toggle_collapse(&path_list, window, cx);
5024 });
5025 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
5026
5027 // When the user tabs back into the sidebar, focus_in no longer
5028 // restores selection — it stays None.
5029 sidebar.update_in(cx, |sidebar, window, cx| {
5030 sidebar.focus_in(window, cx);
5031 });
5032 assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
5033 }
5034
5035 #[gpui::test]
5036 async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
5037 let project = init_test_project_with_agent_panel("/my-project", cx).await;
5038 let (multi_workspace, cx) =
5039 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5040 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5041
5042 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5043
5044 let connection = StubAgentConnection::new();
5045 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5046 acp::ContentChunk::new("Hi there!".into()),
5047 )]);
5048 open_thread_with_connection(&panel, connection, cx);
5049 send_message(&panel, cx);
5050
5051 let session_id = active_session_id(&panel, cx);
5052 save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
5053 cx.run_until_parked();
5054
5055 assert_eq!(
5056 visible_entries_as_strings(&sidebar, cx),
5057 vec!["v [my-project]", " Hello *"]
5058 );
5059
5060 // Simulate the agent generating a title. The notification chain is:
5061 // AcpThread::set_title emits TitleUpdated →
5062 // ConnectionView::handle_thread_event calls cx.notify() →
5063 // AgentPanel observer fires and emits AgentPanelEvent →
5064 // Sidebar subscription calls update_entries / rebuild_contents.
5065 //
5066 // Before the fix, handle_thread_event did NOT call cx.notify() for
5067 // TitleUpdated, so the AgentPanel observer never fired and the
5068 // sidebar kept showing the old title.
5069 let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
5070 thread.update(cx, |thread, cx| {
5071 thread
5072 .set_title("Friendly Greeting with AI".into(), cx)
5073 .detach();
5074 });
5075 cx.run_until_parked();
5076
5077 assert_eq!(
5078 visible_entries_as_strings(&sidebar, cx),
5079 vec!["v [my-project]", " Friendly Greeting with AI *"]
5080 );
5081 }
5082
5083 #[gpui::test]
5084 async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
5085 let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
5086 let (multi_workspace, cx) = cx
5087 .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5088 let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
5089
5090 let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
5091
5092 // Save a thread so it appears in the list.
5093 let connection_a = StubAgentConnection::new();
5094 connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5095 acp::ContentChunk::new("Done".into()),
5096 )]);
5097 open_thread_with_connection(&panel_a, connection_a, cx);
5098 send_message(&panel_a, cx);
5099 let session_id_a = active_session_id(&panel_a, cx);
5100 save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
5101
5102 // Add a second workspace with its own agent panel.
5103 let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
5104 fs.as_fake()
5105 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
5106 .await;
5107 let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
5108 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5109 mw.test_add_workspace(project_b.clone(), window, cx)
5110 });
5111 let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
5112 cx.run_until_parked();
5113
5114 let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
5115
5116 // ── 1. Initial state: focused thread derived from active panel ─────
5117 sidebar.read_with(cx, |sidebar, _cx| {
5118 assert_eq!(
5119 sidebar.focused_thread.as_ref(),
5120 Some(&session_id_a),
5121 "The active panel's thread should be focused on startup"
5122 );
5123 });
5124
5125 sidebar.update_in(cx, |sidebar, window, cx| {
5126 sidebar.activate_thread(
5127 Agent::NativeAgent,
5128 acp_thread::AgentSessionInfo {
5129 session_id: session_id_a.clone(),
5130 work_dirs: None,
5131 title: Some("Test".into()),
5132 updated_at: None,
5133 created_at: None,
5134 meta: None,
5135 },
5136 &workspace_a,
5137 window,
5138 cx,
5139 );
5140 });
5141 cx.run_until_parked();
5142
5143 sidebar.read_with(cx, |sidebar, _cx| {
5144 assert_eq!(
5145 sidebar.focused_thread.as_ref(),
5146 Some(&session_id_a),
5147 "After clicking a thread, it should be the focused thread"
5148 );
5149 assert!(
5150 has_thread_entry(sidebar, &session_id_a),
5151 "The clicked thread should be present in the entries"
5152 );
5153 });
5154
5155 workspace_a.read_with(cx, |workspace, cx| {
5156 assert!(
5157 workspace.panel::<AgentPanel>(cx).is_some(),
5158 "Agent panel should exist"
5159 );
5160 let dock = workspace.right_dock().read(cx);
5161 assert!(
5162 dock.is_open(),
5163 "Clicking a thread should open the agent panel dock"
5164 );
5165 });
5166
5167 let connection_b = StubAgentConnection::new();
5168 connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5169 acp::ContentChunk::new("Thread B".into()),
5170 )]);
5171 open_thread_with_connection(&panel_b, connection_b, cx);
5172 send_message(&panel_b, cx);
5173 let session_id_b = active_session_id(&panel_b, cx);
5174 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
5175 save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await;
5176 cx.run_until_parked();
5177
5178 // Workspace A is currently active. Click a thread in workspace B,
5179 // which also triggers a workspace switch.
5180 sidebar.update_in(cx, |sidebar, window, cx| {
5181 sidebar.activate_thread(
5182 Agent::NativeAgent,
5183 acp_thread::AgentSessionInfo {
5184 session_id: session_id_b.clone(),
5185 work_dirs: None,
5186 title: Some("Thread B".into()),
5187 updated_at: None,
5188 created_at: None,
5189 meta: None,
5190 },
5191 &workspace_b,
5192 window,
5193 cx,
5194 );
5195 });
5196 cx.run_until_parked();
5197
5198 sidebar.read_with(cx, |sidebar, _cx| {
5199 assert_eq!(
5200 sidebar.focused_thread.as_ref(),
5201 Some(&session_id_b),
5202 "Clicking a thread in another workspace should focus that thread"
5203 );
5204 assert!(
5205 has_thread_entry(sidebar, &session_id_b),
5206 "The cross-workspace thread should be present in the entries"
5207 );
5208 });
5209
5210 multi_workspace.update_in(cx, |mw, window, cx| {
5211 mw.activate_index(0, window, cx);
5212 });
5213 cx.run_until_parked();
5214
5215 sidebar.read_with(cx, |sidebar, _cx| {
5216 assert_eq!(
5217 sidebar.focused_thread.as_ref(),
5218 Some(&session_id_a),
5219 "Switching workspace should seed focused_thread from the new active panel"
5220 );
5221 assert!(
5222 has_thread_entry(sidebar, &session_id_a),
5223 "The seeded thread should be present in the entries"
5224 );
5225 });
5226
5227 let connection_b2 = StubAgentConnection::new();
5228 connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5229 acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
5230 )]);
5231 open_thread_with_connection(&panel_b, connection_b2, cx);
5232 send_message(&panel_b, cx);
5233 let session_id_b2 = active_session_id(&panel_b, cx);
5234 save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await;
5235 cx.run_until_parked();
5236
5237 // Panel B is not the active workspace's panel (workspace A is
5238 // active), so opening a thread there should not change focused_thread.
5239 // This prevents running threads in background workspaces from causing
5240 // the selection highlight to jump around.
5241 sidebar.read_with(cx, |sidebar, _cx| {
5242 assert_eq!(
5243 sidebar.focused_thread.as_ref(),
5244 Some(&session_id_a),
5245 "Opening a thread in a non-active panel should not change focused_thread"
5246 );
5247 });
5248
5249 workspace_b.update_in(cx, |workspace, window, cx| {
5250 workspace.focus_handle(cx).focus(window, cx);
5251 });
5252 cx.run_until_parked();
5253
5254 sidebar.read_with(cx, |sidebar, _cx| {
5255 assert_eq!(
5256 sidebar.focused_thread.as_ref(),
5257 Some(&session_id_a),
5258 "Defocusing the sidebar should not change focused_thread"
5259 );
5260 });
5261
5262 // Switching workspaces via the multi_workspace (simulates clicking
5263 // a workspace header) should clear focused_thread.
5264 multi_workspace.update_in(cx, |mw, window, cx| {
5265 if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) {
5266 mw.activate_index(index, window, cx);
5267 }
5268 });
5269 cx.run_until_parked();
5270
5271 sidebar.read_with(cx, |sidebar, _cx| {
5272 assert_eq!(
5273 sidebar.focused_thread.as_ref(),
5274 Some(&session_id_b2),
5275 "Switching workspace should seed focused_thread from the new active panel"
5276 );
5277 assert!(
5278 has_thread_entry(sidebar, &session_id_b2),
5279 "The seeded thread should be present in the entries"
5280 );
5281 });
5282
5283 // ── 8. Focusing the agent panel thread keeps focused_thread ────
5284 // Workspace B still has session_id_b2 loaded in the agent panel.
5285 // Clicking into the thread (simulated by focusing its view) should
5286 // keep focused_thread since it was already seeded on workspace switch.
5287 panel_b.update_in(cx, |panel, window, cx| {
5288 if let Some(thread_view) = panel.active_conversation_view() {
5289 thread_view.read(cx).focus_handle(cx).focus(window, cx);
5290 }
5291 });
5292 cx.run_until_parked();
5293
5294 sidebar.read_with(cx, |sidebar, _cx| {
5295 assert_eq!(
5296 sidebar.focused_thread.as_ref(),
5297 Some(&session_id_b2),
5298 "Focusing the agent panel thread should set focused_thread"
5299 );
5300 assert!(
5301 has_thread_entry(sidebar, &session_id_b2),
5302 "The focused thread should be present in the entries"
5303 );
5304 });
5305 }
5306
5307 #[gpui::test]
5308 async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
5309 let project = init_test_project_with_agent_panel("/project-a", cx).await;
5310 let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
5311 let (multi_workspace, cx) =
5312 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5313 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5314
5315 let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
5316
5317 // Start a thread and send a message so it has history.
5318 let connection = StubAgentConnection::new();
5319 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5320 acp::ContentChunk::new("Done".into()),
5321 )]);
5322 open_thread_with_connection(&panel, connection, cx);
5323 send_message(&panel, cx);
5324 let session_id = active_session_id(&panel, cx);
5325 save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await;
5326 cx.run_until_parked();
5327
5328 // Verify the thread appears in the sidebar.
5329 assert_eq!(
5330 visible_entries_as_strings(&sidebar, cx),
5331 vec!["v [project-a]", " Hello *",]
5332 );
5333
5334 // The "New Thread" button should NOT be in "active/draft" state
5335 // because the panel has a thread with messages.
5336 sidebar.read_with(cx, |sidebar, _cx| {
5337 assert!(
5338 !sidebar.active_thread_is_draft,
5339 "Panel has a thread with messages, so it should not be a draft"
5340 );
5341 });
5342
5343 // Now add a second folder to the workspace, changing the path_list.
5344 fs.as_fake()
5345 .insert_tree("/project-b", serde_json::json!({ "src": {} }))
5346 .await;
5347 project
5348 .update(cx, |project, cx| {
5349 project.find_or_create_worktree("/project-b", true, cx)
5350 })
5351 .await
5352 .expect("should add worktree");
5353 cx.run_until_parked();
5354
5355 // The workspace path_list is now [project-a, project-b]. The old
5356 // thread was stored under [project-a], so it no longer appears in
5357 // the sidebar list for this workspace.
5358 let entries = visible_entries_as_strings(&sidebar, cx);
5359 assert!(
5360 !entries.iter().any(|e| e.contains("Hello")),
5361 "Thread stored under the old path_list should not appear: {:?}",
5362 entries
5363 );
5364
5365 // The "New Thread" button must still be clickable (not stuck in
5366 // "active/draft" state). Verify that `active_thread_is_draft` is
5367 // false — the panel still has the old thread with messages.
5368 sidebar.read_with(cx, |sidebar, _cx| {
5369 assert!(
5370 !sidebar.active_thread_is_draft,
5371 "After adding a folder the panel still has a thread with messages, \
5372 so active_thread_is_draft should be false"
5373 );
5374 });
5375
5376 // Actually click "New Thread" by calling create_new_thread and
5377 // verify a new draft is created.
5378 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
5379 sidebar.update_in(cx, |sidebar, window, cx| {
5380 sidebar.create_new_thread(&workspace, window, cx);
5381 });
5382 cx.run_until_parked();
5383
5384 // After creating a new thread, the panel should now be in draft
5385 // state (no messages on the new thread).
5386 sidebar.read_with(cx, |sidebar, _cx| {
5387 assert!(
5388 sidebar.active_thread_is_draft,
5389 "After creating a new thread the panel should be in draft state"
5390 );
5391 });
5392 }
5393
5394 #[gpui::test]
5395 async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
5396 // When the user presses Cmd-N (NewThread action) while viewing a
5397 // non-empty thread, the sidebar should show the "New Thread" entry.
5398 // This exercises the same code path as the workspace action handler
5399 // (which bypasses the sidebar's create_new_thread method).
5400 let project = init_test_project_with_agent_panel("/my-project", cx).await;
5401 let (multi_workspace, cx) =
5402 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5403 let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5404
5405 let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5406
5407 // Create a non-empty thread (has messages).
5408 let connection = StubAgentConnection::new();
5409 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5410 acp::ContentChunk::new("Done".into()),
5411 )]);
5412 open_thread_with_connection(&panel, connection, cx);
5413 send_message(&panel, cx);
5414
5415 let session_id = active_session_id(&panel, cx);
5416 save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
5417 cx.run_until_parked();
5418
5419 assert_eq!(
5420 visible_entries_as_strings(&sidebar, cx),
5421 vec!["v [my-project]", " Hello *"]
5422 );
5423
5424 // Simulate cmd-n
5425 let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
5426 panel.update_in(cx, |panel, window, cx| {
5427 panel.new_thread(&NewThread, window, cx);
5428 });
5429 workspace.update_in(cx, |workspace, window, cx| {
5430 workspace.focus_panel::<AgentPanel>(window, cx);
5431 });
5432 cx.run_until_parked();
5433
5434 assert_eq!(
5435 visible_entries_as_strings(&sidebar, cx),
5436 vec!["v [my-project]", " [+ New Thread]", " Hello *"],
5437 "After Cmd-N the sidebar should show a highlighted New Thread entry"
5438 );
5439
5440 sidebar.read_with(cx, |sidebar, _cx| {
5441 assert!(
5442 sidebar.focused_thread.is_none(),
5443 "focused_thread should be cleared after Cmd-N"
5444 );
5445 assert!(
5446 sidebar.active_thread_is_draft,
5447 "the new blank thread should be a draft"
5448 );
5449 });
5450 }
5451
5452 #[gpui::test]
5453 async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
5454 // When the active workspace is an absorbed git worktree, cmd-n
5455 // should still show the "New Thread" entry under the main repo's
5456 // header and highlight it as active.
5457 agent_ui::test_support::init_test(cx);
5458 cx.update(|cx| {
5459 cx.update_flags(false, vec!["agent-v2".into()]);
5460 ThreadStore::init_global(cx);
5461 SidebarThreadMetadataStore::init_global(cx);
5462 language_model::LanguageModelRegistry::test(cx);
5463 prompt_store::init(cx);
5464 });
5465
5466 let fs = FakeFs::new(cx.executor());
5467
5468 // Main repo with a linked worktree.
5469 fs.insert_tree(
5470 "/project",
5471 serde_json::json!({
5472 ".git": {
5473 "worktrees": {
5474 "feature-a": {
5475 "commondir": "../../",
5476 "HEAD": "ref: refs/heads/feature-a",
5477 },
5478 },
5479 },
5480 "src": {},
5481 }),
5482 )
5483 .await;
5484
5485 // Worktree checkout pointing back to the main repo.
5486 fs.insert_tree(
5487 "/wt-feature-a",
5488 serde_json::json!({
5489 ".git": "gitdir: /project/.git/worktrees/feature-a",
5490 "src": {},
5491 }),
5492 )
5493 .await;
5494
5495 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5496 state.worktrees.push(git::repository::Worktree {
5497 path: std::path::PathBuf::from("/wt-feature-a"),
5498 ref_name: Some("refs/heads/feature-a".into()),
5499 sha: "aaa".into(),
5500 });
5501 })
5502 .unwrap();
5503
5504 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5505
5506 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5507 let worktree_project =
5508 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5509
5510 main_project
5511 .update(cx, |p, cx| p.git_scans_complete(cx))
5512 .await;
5513 worktree_project
5514 .update(cx, |p, cx| p.git_scans_complete(cx))
5515 .await;
5516
5517 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5518 MultiWorkspace::test_new(main_project.clone(), window, cx)
5519 });
5520
5521 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5522 mw.test_add_workspace(worktree_project.clone(), window, cx)
5523 });
5524
5525 let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
5526
5527 // Switch to the worktree workspace.
5528 multi_workspace.update_in(cx, |mw, window, cx| {
5529 mw.activate_index(1, window, cx);
5530 });
5531
5532 let sidebar = setup_sidebar(&multi_workspace, cx);
5533
5534 // Create a non-empty thread in the worktree workspace.
5535 let connection = StubAgentConnection::new();
5536 connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5537 acp::ContentChunk::new("Done".into()),
5538 )]);
5539 open_thread_with_connection(&worktree_panel, connection, cx);
5540 send_message(&worktree_panel, cx);
5541
5542 let session_id = active_session_id(&worktree_panel, cx);
5543 let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5544 save_test_thread_metadata(&session_id, wt_path_list, cx).await;
5545 cx.run_until_parked();
5546
5547 assert_eq!(
5548 visible_entries_as_strings(&sidebar, cx),
5549 vec!["v [project]", " Hello {wt-feature-a} *"]
5550 );
5551
5552 // Simulate Cmd-N in the worktree workspace.
5553 worktree_panel.update_in(cx, |panel, window, cx| {
5554 panel.new_thread(&NewThread, window, cx);
5555 });
5556 worktree_workspace.update_in(cx, |workspace, window, cx| {
5557 workspace.focus_panel::<AgentPanel>(window, cx);
5558 });
5559 cx.run_until_parked();
5560
5561 assert_eq!(
5562 visible_entries_as_strings(&sidebar, cx),
5563 vec![
5564 "v [project]",
5565 " [+ New Thread]",
5566 " Hello {wt-feature-a} *"
5567 ],
5568 "After Cmd-N in an absorbed worktree, the sidebar should show \
5569 a highlighted New Thread entry under the main repo header"
5570 );
5571
5572 sidebar.read_with(cx, |sidebar, _cx| {
5573 assert!(
5574 sidebar.focused_thread.is_none(),
5575 "focused_thread should be cleared after Cmd-N"
5576 );
5577 assert!(
5578 sidebar.active_thread_is_draft,
5579 "the new blank thread should be a draft"
5580 );
5581 });
5582 }
5583
5584 async fn init_test_project_with_git(
5585 worktree_path: &str,
5586 cx: &mut TestAppContext,
5587 ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
5588 init_test(cx);
5589 let fs = FakeFs::new(cx.executor());
5590 fs.insert_tree(
5591 worktree_path,
5592 serde_json::json!({
5593 ".git": {},
5594 "src": {},
5595 }),
5596 )
5597 .await;
5598 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5599 let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
5600 (project, fs)
5601 }
5602
5603 #[gpui::test]
5604 async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
5605 let (project, fs) = init_test_project_with_git("/project", cx).await;
5606
5607 fs.as_fake()
5608 .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5609 state.worktrees.push(git::repository::Worktree {
5610 path: std::path::PathBuf::from("/wt/rosewood"),
5611 ref_name: Some("refs/heads/rosewood".into()),
5612 sha: "abc".into(),
5613 });
5614 })
5615 .unwrap();
5616
5617 project
5618 .update(cx, |project, cx| project.git_scans_complete(cx))
5619 .await;
5620
5621 let (multi_workspace, cx) =
5622 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5623 let sidebar = setup_sidebar(&multi_workspace, cx);
5624
5625 let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
5626 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
5627 save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await;
5628 save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await;
5629
5630 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5631 cx.run_until_parked();
5632
5633 // Search for "rosewood" — should match the worktree name, not the title.
5634 type_in_search(&sidebar, "rosewood", cx);
5635
5636 assert_eq!(
5637 visible_entries_as_strings(&sidebar, cx),
5638 vec!["v [project]", " Fix Bug {rosewood} <== selected"],
5639 );
5640 }
5641
5642 #[gpui::test]
5643 async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
5644 let (project, fs) = init_test_project_with_git("/project", cx).await;
5645
5646 project
5647 .update(cx, |project, cx| project.git_scans_complete(cx))
5648 .await;
5649
5650 let (multi_workspace, cx) =
5651 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5652 let sidebar = setup_sidebar(&multi_workspace, cx);
5653
5654 // Save a thread against a worktree path that doesn't exist yet.
5655 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
5656 save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
5657
5658 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5659 cx.run_until_parked();
5660
5661 // Thread is not visible yet — no worktree knows about this path.
5662 assert_eq!(
5663 visible_entries_as_strings(&sidebar, cx),
5664 vec!["v [project]", " [+ New Thread]"]
5665 );
5666
5667 // Now add the worktree to the git state and trigger a rescan.
5668 fs.as_fake()
5669 .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
5670 state.worktrees.push(git::repository::Worktree {
5671 path: std::path::PathBuf::from("/wt/rosewood"),
5672 ref_name: Some("refs/heads/rosewood".into()),
5673 sha: "abc".into(),
5674 });
5675 })
5676 .unwrap();
5677
5678 cx.run_until_parked();
5679
5680 assert_eq!(
5681 visible_entries_as_strings(&sidebar, cx),
5682 vec!["v [project]", " Worktree Thread {rosewood}",]
5683 );
5684 }
5685
5686 #[gpui::test]
5687 async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
5688 init_test(cx);
5689 let fs = FakeFs::new(cx.executor());
5690
5691 // Create the main repo directory (not opened as a workspace yet).
5692 fs.insert_tree(
5693 "/project",
5694 serde_json::json!({
5695 ".git": {
5696 "worktrees": {
5697 "feature-a": {
5698 "commondir": "../../",
5699 "HEAD": "ref: refs/heads/feature-a",
5700 },
5701 "feature-b": {
5702 "commondir": "../../",
5703 "HEAD": "ref: refs/heads/feature-b",
5704 },
5705 },
5706 },
5707 "src": {},
5708 }),
5709 )
5710 .await;
5711
5712 // Two worktree checkouts whose .git files point back to the main repo.
5713 fs.insert_tree(
5714 "/wt-feature-a",
5715 serde_json::json!({
5716 ".git": "gitdir: /project/.git/worktrees/feature-a",
5717 "src": {},
5718 }),
5719 )
5720 .await;
5721 fs.insert_tree(
5722 "/wt-feature-b",
5723 serde_json::json!({
5724 ".git": "gitdir: /project/.git/worktrees/feature-b",
5725 "src": {},
5726 }),
5727 )
5728 .await;
5729
5730 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5731
5732 let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5733 let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
5734
5735 project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5736 project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5737
5738 // Open both worktrees as workspaces — no main repo yet.
5739 let (multi_workspace, cx) = cx
5740 .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5741 multi_workspace.update_in(cx, |mw, window, cx| {
5742 mw.test_add_workspace(project_b.clone(), window, cx);
5743 });
5744 let sidebar = setup_sidebar(&multi_workspace, cx);
5745
5746 let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5747 let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
5748 save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
5749 save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await;
5750
5751 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5752 cx.run_until_parked();
5753
5754 // Without the main repo, each worktree has its own header.
5755 assert_eq!(
5756 visible_entries_as_strings(&sidebar, cx),
5757 vec![
5758 "v [project]",
5759 " Thread A {wt-feature-a}",
5760 " Thread B {wt-feature-b}",
5761 ]
5762 );
5763
5764 // Configure the main repo to list both worktrees before opening
5765 // it so the initial git scan picks them up.
5766 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5767 state.worktrees.push(git::repository::Worktree {
5768 path: std::path::PathBuf::from("/wt-feature-a"),
5769 ref_name: Some("refs/heads/feature-a".into()),
5770 sha: "aaa".into(),
5771 });
5772 state.worktrees.push(git::repository::Worktree {
5773 path: std::path::PathBuf::from("/wt-feature-b"),
5774 ref_name: Some("refs/heads/feature-b".into()),
5775 sha: "bbb".into(),
5776 });
5777 })
5778 .unwrap();
5779
5780 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5781 main_project
5782 .update(cx, |p, cx| p.git_scans_complete(cx))
5783 .await;
5784
5785 multi_workspace.update_in(cx, |mw, window, cx| {
5786 mw.test_add_workspace(main_project.clone(), window, cx);
5787 });
5788 cx.run_until_parked();
5789
5790 // Both worktree workspaces should now be absorbed under the main
5791 // repo header, with worktree chips.
5792 assert_eq!(
5793 visible_entries_as_strings(&sidebar, cx),
5794 vec![
5795 "v [project]",
5796 " Thread A {wt-feature-a}",
5797 " Thread B {wt-feature-b}",
5798 ]
5799 );
5800
5801 // Remove feature-b from the main repo's linked worktrees.
5802 // The feature-b workspace should be pruned automatically.
5803 fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
5804 state
5805 .worktrees
5806 .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
5807 })
5808 .unwrap();
5809
5810 cx.run_until_parked();
5811
5812 // feature-b's workspace is pruned; feature-a remains absorbed
5813 // under the main repo.
5814 assert_eq!(
5815 visible_entries_as_strings(&sidebar, cx),
5816 vec!["v [project]", " Thread A {wt-feature-a}",]
5817 );
5818 }
5819
5820 #[gpui::test]
5821 async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
5822 // When a worktree workspace is absorbed under the main repo, a
5823 // running thread in the worktree's agent panel should still show
5824 // live status (spinner + "(running)") in the sidebar.
5825 agent_ui::test_support::init_test(cx);
5826 cx.update(|cx| {
5827 cx.update_flags(false, vec!["agent-v2".into()]);
5828 ThreadStore::init_global(cx);
5829 SidebarThreadMetadataStore::init_global(cx);
5830 language_model::LanguageModelRegistry::test(cx);
5831 prompt_store::init(cx);
5832 });
5833
5834 let fs = FakeFs::new(cx.executor());
5835
5836 // Main repo with a linked worktree.
5837 fs.insert_tree(
5838 "/project",
5839 serde_json::json!({
5840 ".git": {
5841 "worktrees": {
5842 "feature-a": {
5843 "commondir": "../../",
5844 "HEAD": "ref: refs/heads/feature-a",
5845 },
5846 },
5847 },
5848 "src": {},
5849 }),
5850 )
5851 .await;
5852
5853 // Worktree checkout pointing back to the main repo.
5854 fs.insert_tree(
5855 "/wt-feature-a",
5856 serde_json::json!({
5857 ".git": "gitdir: /project/.git/worktrees/feature-a",
5858 "src": {},
5859 }),
5860 )
5861 .await;
5862
5863 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5864 state.worktrees.push(git::repository::Worktree {
5865 path: std::path::PathBuf::from("/wt-feature-a"),
5866 ref_name: Some("refs/heads/feature-a".into()),
5867 sha: "aaa".into(),
5868 });
5869 })
5870 .unwrap();
5871
5872 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5873
5874 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5875 let worktree_project =
5876 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5877
5878 main_project
5879 .update(cx, |p, cx| p.git_scans_complete(cx))
5880 .await;
5881 worktree_project
5882 .update(cx, |p, cx| p.git_scans_complete(cx))
5883 .await;
5884
5885 // Create the MultiWorkspace with both projects.
5886 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5887 MultiWorkspace::test_new(main_project.clone(), window, cx)
5888 });
5889
5890 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5891 mw.test_add_workspace(worktree_project.clone(), window, cx)
5892 });
5893
5894 // Add an agent panel to the worktree workspace so we can run a
5895 // thread inside it.
5896 let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
5897
5898 // Switch back to the main workspace before setting up the sidebar.
5899 multi_workspace.update_in(cx, |mw, window, cx| {
5900 mw.activate_index(0, window, cx);
5901 });
5902
5903 let sidebar = setup_sidebar(&multi_workspace, cx);
5904
5905 // Start a thread in the worktree workspace's panel and keep it
5906 // generating (don't resolve it).
5907 let connection = StubAgentConnection::new();
5908 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
5909 send_message(&worktree_panel, cx);
5910
5911 let session_id = active_session_id(&worktree_panel, cx);
5912
5913 // Save metadata so the sidebar knows about this thread.
5914 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5915 save_test_thread_metadata(&session_id, wt_paths, cx).await;
5916
5917 // Keep the thread generating by sending a chunk without ending
5918 // the turn.
5919 cx.update(|_, cx| {
5920 connection.send_update(
5921 session_id.clone(),
5922 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
5923 cx,
5924 );
5925 });
5926 cx.run_until_parked();
5927
5928 // The worktree thread should be absorbed under the main project
5929 // and show live running status.
5930 let entries = visible_entries_as_strings(&sidebar, cx);
5931 assert_eq!(
5932 entries,
5933 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
5934 );
5935 }
5936
5937 #[gpui::test]
5938 async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
5939 agent_ui::test_support::init_test(cx);
5940 cx.update(|cx| {
5941 cx.update_flags(false, vec!["agent-v2".into()]);
5942 ThreadStore::init_global(cx);
5943 SidebarThreadMetadataStore::init_global(cx);
5944 language_model::LanguageModelRegistry::test(cx);
5945 prompt_store::init(cx);
5946 });
5947
5948 let fs = FakeFs::new(cx.executor());
5949
5950 fs.insert_tree(
5951 "/project",
5952 serde_json::json!({
5953 ".git": {
5954 "worktrees": {
5955 "feature-a": {
5956 "commondir": "../../",
5957 "HEAD": "ref: refs/heads/feature-a",
5958 },
5959 },
5960 },
5961 "src": {},
5962 }),
5963 )
5964 .await;
5965
5966 fs.insert_tree(
5967 "/wt-feature-a",
5968 serde_json::json!({
5969 ".git": "gitdir: /project/.git/worktrees/feature-a",
5970 "src": {},
5971 }),
5972 )
5973 .await;
5974
5975 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5976 state.worktrees.push(git::repository::Worktree {
5977 path: std::path::PathBuf::from("/wt-feature-a"),
5978 ref_name: Some("refs/heads/feature-a".into()),
5979 sha: "aaa".into(),
5980 });
5981 })
5982 .unwrap();
5983
5984 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5985
5986 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5987 let worktree_project =
5988 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5989
5990 main_project
5991 .update(cx, |p, cx| p.git_scans_complete(cx))
5992 .await;
5993 worktree_project
5994 .update(cx, |p, cx| p.git_scans_complete(cx))
5995 .await;
5996
5997 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5998 MultiWorkspace::test_new(main_project.clone(), window, cx)
5999 });
6000
6001 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6002 mw.test_add_workspace(worktree_project.clone(), window, cx)
6003 });
6004
6005 let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
6006
6007 multi_workspace.update_in(cx, |mw, window, cx| {
6008 mw.activate_index(0, window, cx);
6009 });
6010
6011 let sidebar = setup_sidebar(&multi_workspace, cx);
6012
6013 let connection = StubAgentConnection::new();
6014 open_thread_with_connection(&worktree_panel, connection.clone(), cx);
6015 send_message(&worktree_panel, cx);
6016
6017 let session_id = active_session_id(&worktree_panel, cx);
6018 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6019 save_test_thread_metadata(&session_id, wt_paths, cx).await;
6020
6021 cx.update(|_, cx| {
6022 connection.send_update(
6023 session_id.clone(),
6024 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
6025 cx,
6026 );
6027 });
6028 cx.run_until_parked();
6029
6030 assert_eq!(
6031 visible_entries_as_strings(&sidebar, cx),
6032 vec!["v [project]", " Hello {wt-feature-a} * (running)",]
6033 );
6034
6035 connection.end_turn(session_id, acp::StopReason::EndTurn);
6036 cx.run_until_parked();
6037
6038 assert_eq!(
6039 visible_entries_as_strings(&sidebar, cx),
6040 vec!["v [project]", " Hello {wt-feature-a} * (!)",]
6041 );
6042 }
6043
6044 #[gpui::test]
6045 async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
6046 cx: &mut TestAppContext,
6047 ) {
6048 init_test(cx);
6049 let fs = FakeFs::new(cx.executor());
6050
6051 fs.insert_tree(
6052 "/project",
6053 serde_json::json!({
6054 ".git": {
6055 "worktrees": {
6056 "feature-a": {
6057 "commondir": "../../",
6058 "HEAD": "ref: refs/heads/feature-a",
6059 },
6060 },
6061 },
6062 "src": {},
6063 }),
6064 )
6065 .await;
6066
6067 fs.insert_tree(
6068 "/wt-feature-a",
6069 serde_json::json!({
6070 ".git": "gitdir: /project/.git/worktrees/feature-a",
6071 "src": {},
6072 }),
6073 )
6074 .await;
6075
6076 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6077 state.worktrees.push(git::repository::Worktree {
6078 path: std::path::PathBuf::from("/wt-feature-a"),
6079 ref_name: Some("refs/heads/feature-a".into()),
6080 sha: "aaa".into(),
6081 });
6082 })
6083 .unwrap();
6084
6085 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6086
6087 // Only open the main repo — no workspace for the worktree.
6088 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6089 main_project
6090 .update(cx, |p, cx| p.git_scans_complete(cx))
6091 .await;
6092
6093 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6094 MultiWorkspace::test_new(main_project.clone(), window, cx)
6095 });
6096 let sidebar = setup_sidebar(&multi_workspace, cx);
6097
6098 // Save a thread for the worktree path (no workspace for it).
6099 let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6100 save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6101
6102 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6103 cx.run_until_parked();
6104
6105 // Thread should appear under the main repo with a worktree chip.
6106 assert_eq!(
6107 visible_entries_as_strings(&sidebar, cx),
6108 vec!["v [project]", " WT Thread {wt-feature-a}"],
6109 );
6110
6111 // Only 1 workspace should exist.
6112 assert_eq!(
6113 multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6114 1,
6115 );
6116
6117 // Focus the sidebar and select the worktree thread.
6118 open_and_focus_sidebar(&sidebar, cx);
6119 sidebar.update_in(cx, |sidebar, _window, _cx| {
6120 sidebar.selection = Some(1); // index 0 is header, 1 is the thread
6121 });
6122
6123 // Confirm to open the worktree thread.
6124 cx.dispatch_action(Confirm);
6125 cx.run_until_parked();
6126
6127 // A new workspace should have been created for the worktree path.
6128 let new_workspace = multi_workspace.read_with(cx, |mw, _| {
6129 assert_eq!(
6130 mw.workspaces().len(),
6131 2,
6132 "confirming a worktree thread without a workspace should open one",
6133 );
6134 mw.workspaces()[1].clone()
6135 });
6136
6137 let new_path_list =
6138 new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
6139 assert_eq!(
6140 new_path_list,
6141 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
6142 "the new workspace should have been opened for the worktree path",
6143 );
6144 }
6145
6146 #[gpui::test]
6147 async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
6148 cx: &mut TestAppContext,
6149 ) {
6150 init_test(cx);
6151 let fs = FakeFs::new(cx.executor());
6152
6153 fs.insert_tree(
6154 "/project",
6155 serde_json::json!({
6156 ".git": {
6157 "worktrees": {
6158 "feature-a": {
6159 "commondir": "../../",
6160 "HEAD": "ref: refs/heads/feature-a",
6161 },
6162 },
6163 },
6164 "src": {},
6165 }),
6166 )
6167 .await;
6168
6169 fs.insert_tree(
6170 "/wt-feature-a",
6171 serde_json::json!({
6172 ".git": "gitdir: /project/.git/worktrees/feature-a",
6173 "src": {},
6174 }),
6175 )
6176 .await;
6177
6178 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6179 state.worktrees.push(git::repository::Worktree {
6180 path: std::path::PathBuf::from("/wt-feature-a"),
6181 ref_name: Some("refs/heads/feature-a".into()),
6182 sha: "aaa".into(),
6183 });
6184 })
6185 .unwrap();
6186
6187 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6188
6189 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6190 main_project
6191 .update(cx, |p, cx| p.git_scans_complete(cx))
6192 .await;
6193
6194 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6195 MultiWorkspace::test_new(main_project.clone(), window, cx)
6196 });
6197 let sidebar = setup_sidebar(&multi_workspace, cx);
6198
6199 let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6200 save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6201
6202 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6203 cx.run_until_parked();
6204
6205 assert_eq!(
6206 visible_entries_as_strings(&sidebar, cx),
6207 vec!["v [project]", " WT Thread {wt-feature-a}"],
6208 );
6209
6210 open_and_focus_sidebar(&sidebar, cx);
6211 sidebar.update_in(cx, |sidebar, _window, _cx| {
6212 sidebar.selection = Some(1);
6213 });
6214
6215 let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
6216 let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
6217 if let ListEntry::ProjectHeader { label, .. } = entry {
6218 Some(label.as_ref())
6219 } else {
6220 None
6221 }
6222 });
6223
6224 let Some(project_header) = project_headers.next() else {
6225 panic!("expected exactly one sidebar project header named `project`, found none");
6226 };
6227 assert_eq!(
6228 project_header, "project",
6229 "expected the only sidebar project header to be `project`"
6230 );
6231 if let Some(unexpected_header) = project_headers.next() {
6232 panic!(
6233 "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
6234 );
6235 }
6236
6237 let mut saw_expected_thread = false;
6238 for entry in &sidebar.contents.entries {
6239 match entry {
6240 ListEntry::ProjectHeader { label, .. } => {
6241 assert_eq!(
6242 label.as_ref(),
6243 "project",
6244 "expected the only sidebar project header to be `project`"
6245 );
6246 }
6247 ListEntry::Thread(thread)
6248 if thread
6249 .session_info
6250 .title
6251 .as_ref()
6252 .map(|title| title.as_ref())
6253 == Some("WT Thread")
6254 && thread.worktree_name.as_ref().map(|name| name.as_ref())
6255 == Some("wt-feature-a") =>
6256 {
6257 saw_expected_thread = true;
6258 }
6259 ListEntry::Thread(thread) => {
6260 let title = thread
6261 .session_info
6262 .title
6263 .as_ref()
6264 .map(|title| title.as_ref())
6265 .unwrap_or("Untitled");
6266 let worktree_name = thread
6267 .worktree_name
6268 .as_ref()
6269 .map(|name| name.as_ref())
6270 .unwrap_or("<none>");
6271 panic!(
6272 "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`"
6273 );
6274 }
6275 ListEntry::ViewMore { .. } => {
6276 panic!("unexpected `View More` entry while opening linked worktree thread");
6277 }
6278 ListEntry::NewThread { .. } => {
6279 panic!(
6280 "unexpected `New Thread` entry while opening linked worktree thread"
6281 );
6282 }
6283 }
6284 }
6285
6286 assert!(
6287 saw_expected_thread,
6288 "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
6289 );
6290 };
6291
6292 sidebar
6293 .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
6294 .detach();
6295
6296 let window = cx.windows()[0];
6297 cx.update_window(window, |_, window, cx| {
6298 window.dispatch_action(Confirm.boxed_clone(), cx);
6299 })
6300 .unwrap();
6301
6302 cx.run_until_parked();
6303
6304 sidebar.update(cx, assert_sidebar_state);
6305 }
6306
6307 #[gpui::test]
6308 async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
6309 cx: &mut TestAppContext,
6310 ) {
6311 init_test(cx);
6312 let fs = FakeFs::new(cx.executor());
6313
6314 fs.insert_tree(
6315 "/project",
6316 serde_json::json!({
6317 ".git": {
6318 "worktrees": {
6319 "feature-a": {
6320 "commondir": "../../",
6321 "HEAD": "ref: refs/heads/feature-a",
6322 },
6323 },
6324 },
6325 "src": {},
6326 }),
6327 )
6328 .await;
6329
6330 fs.insert_tree(
6331 "/wt-feature-a",
6332 serde_json::json!({
6333 ".git": "gitdir: /project/.git/worktrees/feature-a",
6334 "src": {},
6335 }),
6336 )
6337 .await;
6338
6339 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6340 state.worktrees.push(git::repository::Worktree {
6341 path: std::path::PathBuf::from("/wt-feature-a"),
6342 ref_name: Some("refs/heads/feature-a".into()),
6343 sha: "aaa".into(),
6344 });
6345 })
6346 .unwrap();
6347
6348 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6349
6350 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6351 let worktree_project =
6352 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6353
6354 main_project
6355 .update(cx, |p, cx| p.git_scans_complete(cx))
6356 .await;
6357 worktree_project
6358 .update(cx, |p, cx| p.git_scans_complete(cx))
6359 .await;
6360
6361 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6362 MultiWorkspace::test_new(main_project.clone(), window, cx)
6363 });
6364
6365 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6366 mw.test_add_workspace(worktree_project.clone(), window, cx)
6367 });
6368
6369 // Activate the main workspace before setting up the sidebar.
6370 multi_workspace.update_in(cx, |mw, window, cx| {
6371 mw.activate_index(0, window, cx);
6372 });
6373
6374 let sidebar = setup_sidebar(&multi_workspace, cx);
6375
6376 let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
6377 let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6378 save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await;
6379 save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6380
6381 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6382 cx.run_until_parked();
6383
6384 // The worktree workspace should be absorbed under the main repo.
6385 let entries = visible_entries_as_strings(&sidebar, cx);
6386 assert_eq!(entries.len(), 3);
6387 assert_eq!(entries[0], "v [project]");
6388 assert!(entries.contains(&" Main Thread".to_string()));
6389 assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string()));
6390
6391 let wt_thread_index = entries
6392 .iter()
6393 .position(|e| e.contains("WT Thread"))
6394 .expect("should find the worktree thread entry");
6395
6396 assert_eq!(
6397 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6398 0,
6399 "main workspace should be active initially"
6400 );
6401
6402 // Focus the sidebar and select the absorbed worktree thread.
6403 open_and_focus_sidebar(&sidebar, cx);
6404 sidebar.update_in(cx, |sidebar, _window, _cx| {
6405 sidebar.selection = Some(wt_thread_index);
6406 });
6407
6408 // Confirm to activate the worktree thread.
6409 cx.dispatch_action(Confirm);
6410 cx.run_until_parked();
6411
6412 // The worktree workspace should now be active, not the main one.
6413 let active_workspace = multi_workspace.read_with(cx, |mw, _| {
6414 mw.workspaces()[mw.active_workspace_index()].clone()
6415 });
6416 assert_eq!(
6417 active_workspace, worktree_workspace,
6418 "clicking an absorbed worktree thread should activate the worktree workspace"
6419 );
6420 }
6421
6422 #[gpui::test]
6423 async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
6424 cx: &mut TestAppContext,
6425 ) {
6426 // Thread has saved metadata in ThreadStore. A matching workspace is
6427 // already open. Expected: activates the matching workspace.
6428 init_test(cx);
6429 let fs = FakeFs::new(cx.executor());
6430 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6431 .await;
6432 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6433 .await;
6434 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6435
6436 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6437 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6438
6439 let (multi_workspace, cx) =
6440 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6441
6442 multi_workspace.update_in(cx, |mw, window, cx| {
6443 mw.test_add_workspace(project_b, window, cx);
6444 });
6445
6446 let sidebar = setup_sidebar(&multi_workspace, cx);
6447
6448 // Save a thread with path_list pointing to project-b.
6449 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6450 let session_id = acp::SessionId::new(Arc::from("archived-1"));
6451 save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await;
6452
6453 // Ensure workspace A is active.
6454 multi_workspace.update_in(cx, |mw, window, cx| {
6455 mw.activate_index(0, window, cx);
6456 });
6457 cx.run_until_parked();
6458 assert_eq!(
6459 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6460 0
6461 );
6462
6463 // Call activate_archived_thread – should resolve saved paths and
6464 // switch to the workspace for project-b.
6465 sidebar.update_in(cx, |sidebar, window, cx| {
6466 sidebar.activate_archived_thread(
6467 Agent::NativeAgent,
6468 acp_thread::AgentSessionInfo {
6469 session_id: session_id.clone(),
6470 work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6471 title: Some("Archived Thread".into()),
6472 updated_at: None,
6473 created_at: None,
6474 meta: None,
6475 },
6476 window,
6477 cx,
6478 );
6479 });
6480 cx.run_until_parked();
6481
6482 assert_eq!(
6483 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6484 1,
6485 "should have activated the workspace matching the saved path_list"
6486 );
6487 }
6488
6489 #[gpui::test]
6490 async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
6491 cx: &mut TestAppContext,
6492 ) {
6493 // Thread has no saved metadata but session_info has cwd. A matching
6494 // workspace is open. Expected: uses cwd to find and activate it.
6495 init_test(cx);
6496 let fs = FakeFs::new(cx.executor());
6497 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6498 .await;
6499 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6500 .await;
6501 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6502
6503 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6504 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6505
6506 let (multi_workspace, cx) =
6507 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6508
6509 multi_workspace.update_in(cx, |mw, window, cx| {
6510 mw.test_add_workspace(project_b, window, cx);
6511 });
6512
6513 let sidebar = setup_sidebar(&multi_workspace, cx);
6514
6515 // Start with workspace A active.
6516 multi_workspace.update_in(cx, |mw, window, cx| {
6517 mw.activate_index(0, window, cx);
6518 });
6519 cx.run_until_parked();
6520 assert_eq!(
6521 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6522 0
6523 );
6524
6525 // No thread saved to the store – cwd is the only path hint.
6526 sidebar.update_in(cx, |sidebar, window, cx| {
6527 sidebar.activate_archived_thread(
6528 Agent::NativeAgent,
6529 acp_thread::AgentSessionInfo {
6530 session_id: acp::SessionId::new(Arc::from("unknown-session")),
6531 work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])),
6532 title: Some("CWD Thread".into()),
6533 updated_at: None,
6534 created_at: None,
6535 meta: None,
6536 },
6537 window,
6538 cx,
6539 );
6540 });
6541 cx.run_until_parked();
6542
6543 assert_eq!(
6544 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6545 1,
6546 "should have activated the workspace matching the cwd"
6547 );
6548 }
6549
6550 #[gpui::test]
6551 async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
6552 cx: &mut TestAppContext,
6553 ) {
6554 // Thread has no saved metadata and no cwd. Expected: falls back to
6555 // the currently active workspace.
6556 init_test(cx);
6557 let fs = FakeFs::new(cx.executor());
6558 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6559 .await;
6560 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6561 .await;
6562 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6563
6564 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6565 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6566
6567 let (multi_workspace, cx) =
6568 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6569
6570 multi_workspace.update_in(cx, |mw, window, cx| {
6571 mw.test_add_workspace(project_b, window, cx);
6572 });
6573
6574 let sidebar = setup_sidebar(&multi_workspace, cx);
6575
6576 // Activate workspace B (index 1) to make it the active one.
6577 multi_workspace.update_in(cx, |mw, window, cx| {
6578 mw.activate_index(1, window, cx);
6579 });
6580 cx.run_until_parked();
6581 assert_eq!(
6582 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6583 1
6584 );
6585
6586 // No saved thread, no cwd – should fall back to the active workspace.
6587 sidebar.update_in(cx, |sidebar, window, cx| {
6588 sidebar.activate_archived_thread(
6589 Agent::NativeAgent,
6590 acp_thread::AgentSessionInfo {
6591 session_id: acp::SessionId::new(Arc::from("no-context-session")),
6592 work_dirs: None,
6593 title: Some("Contextless Thread".into()),
6594 updated_at: None,
6595 created_at: None,
6596 meta: None,
6597 },
6598 window,
6599 cx,
6600 );
6601 });
6602 cx.run_until_parked();
6603
6604 assert_eq!(
6605 multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6606 1,
6607 "should have stayed on the active workspace when no path info is available"
6608 );
6609 }
6610
6611 #[gpui::test]
6612 async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
6613 cx: &mut TestAppContext,
6614 ) {
6615 // Thread has saved metadata pointing to a path with no open workspace.
6616 // Expected: opens a new workspace for that path.
6617 init_test(cx);
6618 let fs = FakeFs::new(cx.executor());
6619 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6620 .await;
6621 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6622 .await;
6623 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6624
6625 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6626
6627 let (multi_workspace, cx) =
6628 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6629
6630 let sidebar = setup_sidebar(&multi_workspace, cx);
6631
6632 // Save a thread with path_list pointing to project-b – which has no
6633 // open workspace.
6634 let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6635 let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
6636
6637 assert_eq!(
6638 multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6639 1,
6640 "should start with one workspace"
6641 );
6642
6643 sidebar.update_in(cx, |sidebar, window, cx| {
6644 sidebar.activate_archived_thread(
6645 Agent::NativeAgent,
6646 acp_thread::AgentSessionInfo {
6647 session_id: session_id.clone(),
6648 work_dirs: Some(path_list_b),
6649 title: Some("New WS Thread".into()),
6650 updated_at: None,
6651 created_at: None,
6652 meta: None,
6653 },
6654 window,
6655 cx,
6656 );
6657 });
6658 cx.run_until_parked();
6659
6660 assert_eq!(
6661 multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6662 2,
6663 "should have opened a second workspace for the archived thread's saved paths"
6664 );
6665 }
6666
6667 #[gpui::test]
6668 async fn test_activate_archived_thread_reuses_workspace_in_another_window(
6669 cx: &mut TestAppContext,
6670 ) {
6671 init_test(cx);
6672 let fs = FakeFs::new(cx.executor());
6673 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6674 .await;
6675 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6676 .await;
6677 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6678
6679 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6680 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6681
6682 let multi_workspace_a =
6683 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6684 let multi_workspace_b =
6685 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
6686
6687 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6688
6689 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6690 let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
6691
6692 let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
6693
6694 sidebar.update_in(cx_a, |sidebar, window, cx| {
6695 sidebar.activate_archived_thread(
6696 Agent::NativeAgent,
6697 acp_thread::AgentSessionInfo {
6698 session_id: session_id.clone(),
6699 work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6700 title: Some("Cross Window Thread".into()),
6701 updated_at: None,
6702 created_at: None,
6703 meta: None,
6704 },
6705 window,
6706 cx,
6707 );
6708 });
6709 cx_a.run_until_parked();
6710
6711 assert_eq!(
6712 multi_workspace_a
6713 .read_with(cx_a, |mw, _| mw.workspaces().len())
6714 .unwrap(),
6715 1,
6716 "should not add the other window's workspace into the current window"
6717 );
6718 assert_eq!(
6719 multi_workspace_b
6720 .read_with(cx_a, |mw, _| mw.workspaces().len())
6721 .unwrap(),
6722 1,
6723 "should reuse the existing workspace in the other window"
6724 );
6725 assert!(
6726 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
6727 "should activate the window that already owns the matching workspace"
6728 );
6729 sidebar.read_with(cx_a, |sidebar, _| {
6730 assert_eq!(
6731 sidebar.focused_thread, None,
6732 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
6733 );
6734 });
6735 }
6736
6737 #[gpui::test]
6738 async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
6739 cx: &mut TestAppContext,
6740 ) {
6741 init_test(cx);
6742 let fs = FakeFs::new(cx.executor());
6743 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6744 .await;
6745 fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6746 .await;
6747 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6748
6749 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6750 let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6751
6752 let multi_workspace_a =
6753 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6754 let multi_workspace_b =
6755 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
6756
6757 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6758 let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
6759
6760 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6761 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
6762
6763 let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
6764 let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
6765 let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
6766 let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b);
6767
6768 let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
6769
6770 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
6771 sidebar.activate_archived_thread(
6772 Agent::NativeAgent,
6773 acp_thread::AgentSessionInfo {
6774 session_id: session_id.clone(),
6775 work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6776 title: Some("Cross Window Thread".into()),
6777 updated_at: None,
6778 created_at: None,
6779 meta: None,
6780 },
6781 window,
6782 cx,
6783 );
6784 });
6785 cx_a.run_until_parked();
6786
6787 assert_eq!(
6788 multi_workspace_a
6789 .read_with(cx_a, |mw, _| mw.workspaces().len())
6790 .unwrap(),
6791 1,
6792 "should not add the other window's workspace into the current window"
6793 );
6794 assert_eq!(
6795 multi_workspace_b
6796 .read_with(cx_a, |mw, _| mw.workspaces().len())
6797 .unwrap(),
6798 1,
6799 "should reuse the existing workspace in the other window"
6800 );
6801 assert!(
6802 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
6803 "should activate the window that already owns the matching workspace"
6804 );
6805 sidebar_a.read_with(cx_a, |sidebar, _| {
6806 assert_eq!(
6807 sidebar.focused_thread, None,
6808 "source window's sidebar should not eagerly claim focus for a thread opened in another window"
6809 );
6810 });
6811 sidebar_b.read_with(cx_b, |sidebar, _| {
6812 assert_eq!(
6813 sidebar.focused_thread.as_ref(),
6814 Some(&session_id),
6815 "target window's sidebar should eagerly focus the activated archived thread"
6816 );
6817 });
6818 }
6819
6820 #[gpui::test]
6821 async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
6822 cx: &mut TestAppContext,
6823 ) {
6824 init_test(cx);
6825 let fs = FakeFs::new(cx.executor());
6826 fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6827 .await;
6828 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6829
6830 let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6831 let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6832
6833 let multi_workspace_b =
6834 cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
6835 let multi_workspace_a =
6836 cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6837
6838 let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6839
6840 let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6841 let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
6842
6843 let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
6844
6845 sidebar_a.update_in(cx_a, |sidebar, window, cx| {
6846 sidebar.activate_archived_thread(
6847 Agent::NativeAgent,
6848 acp_thread::AgentSessionInfo {
6849 session_id: session_id.clone(),
6850 work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])),
6851 title: Some("Current Window Thread".into()),
6852 updated_at: None,
6853 created_at: None,
6854 meta: None,
6855 },
6856 window,
6857 cx,
6858 );
6859 });
6860 cx_a.run_until_parked();
6861
6862 assert!(
6863 cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
6864 "should keep activation in the current window when it already has a matching workspace"
6865 );
6866 sidebar_a.read_with(cx_a, |sidebar, _| {
6867 assert_eq!(
6868 sidebar.focused_thread.as_ref(),
6869 Some(&session_id),
6870 "current window's sidebar should eagerly focus the activated archived thread"
6871 );
6872 });
6873 assert_eq!(
6874 multi_workspace_a
6875 .read_with(cx_a, |mw, _| mw.workspaces().len())
6876 .unwrap(),
6877 1,
6878 "current window should continue reusing its existing workspace"
6879 );
6880 assert_eq!(
6881 multi_workspace_b
6882 .read_with(cx_a, |mw, _| mw.workspaces().len())
6883 .unwrap(),
6884 1,
6885 "other windows should not be activated just because they also match the saved paths"
6886 );
6887 }
6888
6889 #[gpui::test]
6890 async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
6891 // Regression test: archive_thread previously always loaded the next thread
6892 // through group_workspace (the main workspace's ProjectHeader), even when
6893 // the next thread belonged to an absorbed linked-worktree workspace. That
6894 // caused the worktree thread to be loaded in the main panel, which bound it
6895 // to the main project and corrupted its stored folder_paths.
6896 //
6897 // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
6898 // falling back to group_workspace only for Closed workspaces.
6899 agent_ui::test_support::init_test(cx);
6900 cx.update(|cx| {
6901 cx.update_flags(false, vec!["agent-v2".into()]);
6902 ThreadStore::init_global(cx);
6903 SidebarThreadMetadataStore::init_global(cx);
6904 language_model::LanguageModelRegistry::test(cx);
6905 prompt_store::init(cx);
6906 });
6907
6908 let fs = FakeFs::new(cx.executor());
6909
6910 fs.insert_tree(
6911 "/project",
6912 serde_json::json!({
6913 ".git": {
6914 "worktrees": {
6915 "feature-a": {
6916 "commondir": "../../",
6917 "HEAD": "ref: refs/heads/feature-a",
6918 },
6919 },
6920 },
6921 "src": {},
6922 }),
6923 )
6924 .await;
6925
6926 fs.insert_tree(
6927 "/wt-feature-a",
6928 serde_json::json!({
6929 ".git": "gitdir: /project/.git/worktrees/feature-a",
6930 "src": {},
6931 }),
6932 )
6933 .await;
6934
6935 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6936 state.worktrees.push(git::repository::Worktree {
6937 path: std::path::PathBuf::from("/wt-feature-a"),
6938 ref_name: Some("refs/heads/feature-a".into()),
6939 sha: "aaa".into(),
6940 });
6941 })
6942 .unwrap();
6943
6944 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6945
6946 let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6947 let worktree_project =
6948 project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6949
6950 main_project
6951 .update(cx, |p, cx| p.git_scans_complete(cx))
6952 .await;
6953 worktree_project
6954 .update(cx, |p, cx| p.git_scans_complete(cx))
6955 .await;
6956
6957 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6958 MultiWorkspace::test_new(main_project.clone(), window, cx)
6959 });
6960
6961 let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6962 mw.test_add_workspace(worktree_project.clone(), window, cx)
6963 });
6964
6965 // Activate main workspace so the sidebar tracks the main panel.
6966 multi_workspace.update_in(cx, |mw, window, cx| {
6967 mw.activate_index(0, window, cx);
6968 });
6969
6970 let sidebar = setup_sidebar(&multi_workspace, cx);
6971
6972 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone());
6973 let main_panel = add_agent_panel(&main_workspace, &main_project, cx);
6974 let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
6975
6976 // Open Thread 2 in the main panel and keep it running.
6977 let connection = StubAgentConnection::new();
6978 open_thread_with_connection(&main_panel, connection.clone(), cx);
6979 send_message(&main_panel, cx);
6980
6981 let thread2_session_id = active_session_id(&main_panel, cx);
6982
6983 cx.update(|_, cx| {
6984 connection.send_update(
6985 thread2_session_id.clone(),
6986 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
6987 cx,
6988 );
6989 });
6990
6991 // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
6992 save_thread_metadata(
6993 thread2_session_id.clone(),
6994 "Thread 2".into(),
6995 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
6996 PathList::new(&[std::path::PathBuf::from("/project")]),
6997 cx,
6998 )
6999 .await;
7000
7001 // Save thread 1's metadata with the worktree path and an older timestamp so
7002 // it sorts below thread 2. archive_thread will find it as the "next" candidate.
7003 let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
7004 save_thread_metadata(
7005 thread1_session_id.clone(),
7006 "Thread 1".into(),
7007 chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7008 PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
7009 cx,
7010 )
7011 .await;
7012
7013 cx.run_until_parked();
7014
7015 // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
7016 let entries_before = visible_entries_as_strings(&sidebar, cx);
7017 assert!(
7018 entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
7019 "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
7020 entries_before
7021 );
7022
7023 // The sidebar should track T2 as the focused thread (derived from the
7024 // main panel's active view).
7025 let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone());
7026 assert_eq!(
7027 focused,
7028 Some(thread2_session_id.clone()),
7029 "focused thread should be Thread 2 before archiving: {:?}",
7030 focused
7031 );
7032
7033 // Archive thread 2.
7034 sidebar.update_in(cx, |sidebar, window, cx| {
7035 sidebar.archive_thread(&thread2_session_id, window, cx);
7036 });
7037
7038 cx.run_until_parked();
7039
7040 // The main panel's active thread must still be thread 2.
7041 let main_active = main_panel.read_with(cx, |panel, cx| {
7042 panel
7043 .active_agent_thread(cx)
7044 .map(|t| t.read(cx).session_id().clone())
7045 });
7046 assert_eq!(
7047 main_active,
7048 Some(thread2_session_id.clone()),
7049 "main panel should not have been taken over by loading the linked-worktree thread T1; \
7050 before the fix, archive_thread used group_workspace instead of next.workspace, \
7051 causing T1 to be loaded in the wrong panel"
7052 );
7053
7054 // Thread 1 should still appear in the sidebar with its worktree chip
7055 // (Thread 2 was archived so it is gone from the list).
7056 let entries_after = visible_entries_as_strings(&sidebar, cx);
7057 assert!(
7058 entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
7059 "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
7060 entries_after
7061 );
7062 }
7063
7064 #[gpui::test]
7065 async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
7066 // When a multi-root workspace (e.g. [/other, /project]) shares a
7067 // repo with a single-root workspace (e.g. [/project]), linked
7068 // worktree threads from the shared repo should only appear under
7069 // the dedicated group [project], not under [other, project].
7070 init_test(cx);
7071 let fs = FakeFs::new(cx.executor());
7072
7073 fs.insert_tree(
7074 "/project",
7075 serde_json::json!({
7076 ".git": {
7077 "worktrees": {
7078 "feature-a": {
7079 "commondir": "../../",
7080 "HEAD": "ref: refs/heads/feature-a",
7081 },
7082 },
7083 },
7084 "src": {},
7085 }),
7086 )
7087 .await;
7088 fs.insert_tree(
7089 "/wt-feature-a",
7090 serde_json::json!({
7091 ".git": "gitdir: /project/.git/worktrees/feature-a",
7092 "src": {},
7093 }),
7094 )
7095 .await;
7096 fs.insert_tree(
7097 "/other",
7098 serde_json::json!({
7099 ".git": {},
7100 "src": {},
7101 }),
7102 )
7103 .await;
7104
7105 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
7106 state.worktrees.push(git::repository::Worktree {
7107 path: std::path::PathBuf::from("/wt-feature-a"),
7108 ref_name: Some("refs/heads/feature-a".into()),
7109 sha: "aaa".into(),
7110 });
7111 })
7112 .unwrap();
7113
7114 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
7115
7116 let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
7117 project_only
7118 .update(cx, |p, cx| p.git_scans_complete(cx))
7119 .await;
7120
7121 let multi_root =
7122 project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
7123 multi_root
7124 .update(cx, |p, cx| p.git_scans_complete(cx))
7125 .await;
7126
7127 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
7128 MultiWorkspace::test_new(project_only.clone(), window, cx)
7129 });
7130 multi_workspace.update_in(cx, |mw, window, cx| {
7131 mw.test_add_workspace(multi_root.clone(), window, cx);
7132 });
7133 let sidebar = setup_sidebar(&multi_workspace, cx);
7134
7135 let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
7136 save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
7137
7138 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
7139 cx.run_until_parked();
7140
7141 assert_eq!(
7142 visible_entries_as_strings(&sidebar, cx),
7143 vec![
7144 "v [project]",
7145 " Worktree Thread {wt-feature-a}",
7146 "v [other, project]",
7147 " [+ New Thread]",
7148 ]
7149 );
7150 }
7151}