1mod thread_switcher;
2
3use acp_thread::ThreadStatus;
4use action_log::DiffStats;
5use agent_client_protocol::{self as acp};
6use agent_settings::AgentSettings;
7use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
8use agent_ui::threads_archive_view::{
9 ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
10};
11use agent_ui::{
12 Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
13};
14use chrono::{DateTime, Utc};
15use editor::Editor;
16use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
17use gpui::{
18 Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState,
19 Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px,
20};
21use menu::{
22 Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
23};
24use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name};
25use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
26use remote::RemoteConnectionOptions;
27use ui::utils::platform_title_bar_height;
28
29use settings::Settings as _;
30use std::collections::{HashMap, HashSet};
31use std::mem;
32use std::rc::Rc;
33use theme::ActiveTheme;
34use ui::{
35 AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
36 PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip,
37 WithScrollbar, prelude::*,
38};
39use util::ResultExt as _;
40use util::path_list::PathList;
41use workspace::{
42 AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
43 Open, Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
44 sidebar_side_context_menu,
45};
46
47use zed_actions::OpenRecent;
48use zed_actions::editor::{MoveDown, MoveUp};
49
50use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
51
52use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
53
54use crate::project_group_builder::ProjectGroupBuilder;
55
56mod project_group_builder;
57
58#[cfg(test)]
59mod sidebar_tests;
60
61gpui::actions!(
62 agents_sidebar,
63 [
64 /// Creates a new thread in the currently selected or active project group.
65 NewThreadInGroup,
66 /// Toggles between the thread list and the archive view.
67 ToggleArchive,
68 ]
69);
70
71gpui::actions!(
72 dev,
73 [
74 /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer.
75 DumpWorkspaceInfo,
76 ]
77);
78
79const DEFAULT_WIDTH: Pixels = px(300.0);
80const MIN_WIDTH: Pixels = px(200.0);
81const MAX_WIDTH: Pixels = px(800.0);
82const DEFAULT_THREADS_SHOWN: usize = 5;
83
84#[derive(Debug, Default)]
85enum SidebarView {
86 #[default]
87 ThreadList,
88 Archive(Entity<ThreadsArchiveView>),
89}
90
91#[derive(Clone, Debug)]
92struct ActiveThreadInfo {
93 session_id: acp::SessionId,
94 title: SharedString,
95 status: AgentThreadStatus,
96 icon: IconName,
97 icon_from_external_svg: Option<SharedString>,
98 is_background: bool,
99 is_title_generating: bool,
100 diff_stats: DiffStats,
101}
102
103#[derive(Clone)]
104enum ThreadEntryWorkspace {
105 Open(Entity<Workspace>),
106 Closed(PathList),
107}
108
109#[derive(Clone)]
110struct WorktreeInfo {
111 name: SharedString,
112 full_path: SharedString,
113 highlight_positions: Vec<usize>,
114}
115
116#[derive(Clone)]
117struct ThreadEntry {
118 metadata: ThreadMetadata,
119 icon: IconName,
120 icon_from_external_svg: Option<SharedString>,
121 status: AgentThreadStatus,
122 workspace: ThreadEntryWorkspace,
123 is_live: bool,
124 is_background: bool,
125 is_title_generating: bool,
126 highlight_positions: Vec<usize>,
127 worktrees: Vec<WorktreeInfo>,
128 diff_stats: DiffStats,
129}
130
131impl ThreadEntry {
132 /// Updates this thread entry with active thread information.
133 ///
134 /// The existing [`ThreadEntry`] was likely deserialized from the database
135 /// but if we have a correspond thread already loaded we want to apply the
136 /// live information.
137 fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
138 self.metadata.title = info.title.clone();
139 self.status = info.status;
140 self.icon = info.icon;
141 self.icon_from_external_svg = info.icon_from_external_svg.clone();
142 self.is_live = true;
143 self.is_background = info.is_background;
144 self.is_title_generating = info.is_title_generating;
145 self.diff_stats = info.diff_stats;
146 }
147}
148
149#[derive(Clone)]
150enum ListEntry {
151 ProjectHeader {
152 path_list: PathList,
153 label: SharedString,
154 workspace: Entity<Workspace>,
155 highlight_positions: Vec<usize>,
156 has_running_threads: bool,
157 waiting_thread_count: usize,
158 is_active: bool,
159 },
160 Thread(ThreadEntry),
161 ViewMore {
162 path_list: PathList,
163 is_fully_expanded: bool,
164 },
165 NewThread {
166 path_list: PathList,
167 workspace: Entity<Workspace>,
168 is_active_draft: bool,
169 worktrees: Vec<WorktreeInfo>,
170 },
171}
172
173#[cfg(test)]
174impl ListEntry {
175 fn workspace(&self) -> Option<Entity<Workspace>> {
176 match self {
177 ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
178 ListEntry::Thread(thread_entry) => match &thread_entry.workspace {
179 ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
180 ThreadEntryWorkspace::Closed(_) => None,
181 },
182 ListEntry::ViewMore { .. } => None,
183 ListEntry::NewThread { workspace, .. } => Some(workspace.clone()),
184 }
185 }
186
187 fn session_id(&self) -> Option<&acp::SessionId> {
188 match self {
189 ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id),
190 _ => None,
191 }
192 }
193}
194
195impl From<ThreadEntry> for ListEntry {
196 fn from(thread: ThreadEntry) -> Self {
197 ListEntry::Thread(thread)
198 }
199}
200
201#[derive(Default)]
202struct SidebarContents {
203 entries: Vec<ListEntry>,
204 notified_threads: HashSet<acp::SessionId>,
205 project_header_indices: Vec<usize>,
206 has_open_projects: bool,
207}
208
209impl SidebarContents {
210 fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
211 self.notified_threads.contains(session_id)
212 }
213}
214
215fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
216 let mut positions = Vec::new();
217 let mut query_chars = query.chars().peekable();
218
219 for (byte_idx, candidate_char) in candidate.char_indices() {
220 if let Some(&query_char) = query_chars.peek() {
221 if candidate_char.eq_ignore_ascii_case(&query_char) {
222 positions.push(byte_idx);
223 query_chars.next();
224 }
225 } else {
226 break;
227 }
228 }
229
230 if query_chars.peek().is_none() {
231 Some(positions)
232 } else {
233 None
234 }
235}
236
237// TODO: The mapping from workspace root paths to git repositories needs a
238// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
239// thread persistence (which PathList is saved to the database), and thread
240// querying (which PathList is used to read threads back). All of these need
241// to agree on how repos are resolved for a given workspace, especially in
242// multi-root and nested-repo configurations.
243fn root_repository_snapshots(
244 workspace: &Entity<Workspace>,
245 cx: &App,
246) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
247 let path_list = workspace_path_list(workspace, cx);
248 let project = workspace.read(cx).project().read(cx);
249 project.repositories(cx).values().filter_map(move |repo| {
250 let snapshot = repo.read(cx).snapshot();
251 let is_root = path_list
252 .paths()
253 .iter()
254 .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
255 is_root.then_some(snapshot)
256 })
257}
258
259fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
260 PathList::new(&workspace.read(cx).root_paths(cx))
261}
262
263/// Derives worktree display info from a thread's stored path list.
264///
265/// For each path in the thread's `folder_paths` that canonicalizes to a
266/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`]
267/// with the short worktree name and full path.
268fn worktree_info_from_thread_paths(
269 folder_paths: &PathList,
270 project_groups: &ProjectGroupBuilder,
271) -> Vec<WorktreeInfo> {
272 folder_paths
273 .paths()
274 .iter()
275 .filter_map(|path| {
276 let canonical = project_groups.canonicalize_path(path);
277 if canonical != path.as_path() {
278 Some(WorktreeInfo {
279 name: linked_worktree_short_name(canonical, path).unwrap_or_default(),
280 full_path: SharedString::from(path.display().to_string()),
281 highlight_positions: Vec::new(),
282 })
283 } else {
284 None
285 }
286 })
287 .collect()
288}
289
290/// The sidebar re-derives its entire entry list from scratch on every
291/// change via `update_entries` → `rebuild_contents`. Avoid adding
292/// incremental or inter-event coordination state — if something can
293/// be computed from the current world state, compute it in the rebuild.
294pub struct Sidebar {
295 multi_workspace: WeakEntity<MultiWorkspace>,
296 width: Pixels,
297 focus_handle: FocusHandle,
298 filter_editor: Entity<Editor>,
299 list_state: ListState,
300 contents: SidebarContents,
301 /// The index of the list item that currently has the keyboard focus
302 ///
303 /// Note: This is NOT the same as the active item.
304 selection: Option<usize>,
305 /// Derived from the active panel's thread in `rebuild_contents`.
306 /// Only updated when the panel returns `Some` — never cleared by
307 /// derivation, since the panel may transiently return `None` while
308 /// loading. User actions may write directly for immediate feedback.
309 focused_thread: Option<acp::SessionId>,
310 hovered_thread_index: Option<usize>,
311 collapsed_groups: HashSet<PathList>,
312 expanded_groups: HashMap<PathList, usize>,
313 /// Updated only in response to explicit user actions (clicking a
314 /// thread, confirming in the thread switcher, etc.) — never from
315 /// background data changes. Used to sort the thread switcher popup.
316 thread_last_accessed: HashMap<acp::SessionId, DateTime<Utc>>,
317 /// Updated when the user presses a key to send or queue a message.
318 /// Used for sorting threads in the sidebar and as a secondary sort
319 /// key in the thread switcher.
320 thread_last_message_sent_or_queued: HashMap<acp::SessionId, DateTime<Utc>>,
321 thread_switcher: Option<Entity<ThreadSwitcher>>,
322 _thread_switcher_subscriptions: Vec<gpui::Subscription>,
323 view: SidebarView,
324 recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
325 project_header_menu_ix: Option<usize>,
326 _subscriptions: Vec<gpui::Subscription>,
327 _draft_observation: Option<gpui::Subscription>,
328}
329
330impl Sidebar {
331 pub fn new(
332 multi_workspace: Entity<MultiWorkspace>,
333 window: &mut Window,
334 cx: &mut Context<Self>,
335 ) -> Self {
336 let focus_handle = cx.focus_handle();
337 cx.on_focus_in(&focus_handle, window, Self::focus_in)
338 .detach();
339
340 let filter_editor = cx.new(|cx| {
341 let mut editor = Editor::single_line(window, cx);
342 editor.set_use_modal_editing(true);
343 editor.set_placeholder_text("Search…", window, cx);
344 editor
345 });
346
347 cx.subscribe_in(
348 &multi_workspace,
349 window,
350 |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
351 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
352 this.observe_draft_editor(cx);
353 this.update_entries(cx);
354 }
355 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
356 this.subscribe_to_workspace(workspace, window, cx);
357 this.update_entries(cx);
358 }
359 MultiWorkspaceEvent::WorkspaceRemoved(_) => {
360 this.update_entries(cx);
361 }
362 },
363 )
364 .detach();
365
366 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
367 if let editor::EditorEvent::BufferEdited = event {
368 let query = this.filter_editor.read(cx).text(cx);
369 if !query.is_empty() {
370 this.selection.take();
371 }
372 this.update_entries(cx);
373 if !query.is_empty() {
374 this.select_first_entry();
375 }
376 }
377 })
378 .detach();
379
380 cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| {
381 this.update_entries(cx);
382 })
383 .detach();
384
385 cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
386 this.update_entries(cx);
387 })
388 .detach();
389
390 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
391 cx.defer_in(window, move |this, window, cx| {
392 for workspace in &workspaces {
393 this.subscribe_to_workspace(workspace, window, cx);
394 }
395 this.update_entries(cx);
396 });
397
398 Self {
399 multi_workspace: multi_workspace.downgrade(),
400 width: DEFAULT_WIDTH,
401 focus_handle,
402 filter_editor,
403 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
404 contents: SidebarContents::default(),
405 selection: None,
406 focused_thread: None,
407 hovered_thread_index: None,
408 collapsed_groups: HashSet::new(),
409 expanded_groups: HashMap::new(),
410 thread_last_accessed: HashMap::new(),
411 thread_last_message_sent_or_queued: HashMap::new(),
412 thread_switcher: None,
413 _thread_switcher_subscriptions: Vec::new(),
414 view: SidebarView::default(),
415 recent_projects_popover_handle: PopoverMenuHandle::default(),
416 project_header_menu_ix: None,
417 _subscriptions: Vec::new(),
418 _draft_observation: None,
419 }
420 }
421
422 fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
423 self.multi_workspace
424 .upgrade()
425 .map_or(false, |mw| mw.read(cx).workspace() == workspace)
426 }
427
428 fn agent_panel_visible(&self, cx: &App) -> bool {
429 self.multi_workspace.upgrade().map_or(false, |mw| {
430 let workspace = mw.read(cx).workspace();
431 AgentPanel::is_visible(&workspace, cx)
432 })
433 }
434
435 fn active_thread_is_draft(&self, cx: &App) -> bool {
436 self.multi_workspace
437 .upgrade()
438 .and_then(|mw| {
439 let workspace = mw.read(cx).workspace();
440 workspace.read(cx).panel::<AgentPanel>(cx)
441 })
442 .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx))
443 }
444
445 fn subscribe_to_workspace(
446 &mut self,
447 workspace: &Entity<Workspace>,
448 window: &mut Window,
449 cx: &mut Context<Self>,
450 ) {
451 let project = workspace.read(cx).project().clone();
452 cx.subscribe_in(
453 &project,
454 window,
455 |this, _project, event, _window, cx| match event {
456 ProjectEvent::WorktreeAdded(_)
457 | ProjectEvent::WorktreeRemoved(_)
458 | ProjectEvent::WorktreeOrderChanged => {
459 this.update_entries(cx);
460 }
461 _ => {}
462 },
463 )
464 .detach();
465
466 let git_store = workspace.read(cx).project().read(cx).git_store().clone();
467 cx.subscribe_in(
468 &git_store,
469 window,
470 |this, _, event: &project::git_store::GitStoreEvent, _window, cx| {
471 if matches!(
472 event,
473 project::git_store::GitStoreEvent::RepositoryUpdated(
474 _,
475 project::git_store::RepositoryEvent::GitWorktreeListChanged,
476 _,
477 )
478 ) {
479 this.update_entries(cx);
480 }
481 },
482 )
483 .detach();
484
485 cx.subscribe_in(
486 workspace,
487 window,
488 |this, _workspace, event: &workspace::Event, window, cx| {
489 if let workspace::Event::PanelAdded(view) = event {
490 if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
491 this.subscribe_to_agent_panel(&agent_panel, window, cx);
492 }
493 }
494 },
495 )
496 .detach();
497
498 self.observe_docks(workspace, cx);
499
500 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
501 self.subscribe_to_agent_panel(&agent_panel, window, cx);
502 self.observe_draft_editor(cx);
503 }
504 }
505
506 fn subscribe_to_agent_panel(
507 &mut self,
508 agent_panel: &Entity<AgentPanel>,
509 window: &mut Window,
510 cx: &mut Context<Self>,
511 ) {
512 cx.subscribe_in(
513 agent_panel,
514 window,
515 |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
516 AgentPanelEvent::ActiveViewChanged => {
517 let is_new_draft = agent_panel
518 .read(cx)
519 .active_conversation_view()
520 .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
521 if is_new_draft {
522 this.focused_thread = None;
523 }
524 this.observe_draft_editor(cx);
525 this.update_entries(cx);
526 }
527 AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => {
528 this.update_entries(cx);
529 }
530 AgentPanelEvent::MessageSentOrQueued { session_id } => {
531 this.record_thread_message_sent(session_id);
532 this.update_entries(cx);
533 }
534 },
535 )
536 .detach();
537 }
538
539 fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
540 let docks: Vec<_> = workspace
541 .read(cx)
542 .all_docks()
543 .into_iter()
544 .cloned()
545 .collect();
546 let workspace = workspace.downgrade();
547 for dock in docks {
548 let workspace = workspace.clone();
549 cx.observe(&dock, move |this, _dock, cx| {
550 let Some(workspace) = workspace.upgrade() else {
551 return;
552 };
553 if !this.is_active_workspace(&workspace, cx) {
554 return;
555 }
556
557 cx.notify();
558 })
559 .detach();
560 }
561 }
562
563 fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
564 self._draft_observation = self
565 .multi_workspace
566 .upgrade()
567 .and_then(|mw| {
568 let ws = mw.read(cx).workspace();
569 ws.read(cx).panel::<AgentPanel>(cx)
570 })
571 .and_then(|panel| {
572 let cv = panel.read(cx).active_conversation_view()?;
573 let tv = cv.read(cx).active_thread()?;
574 Some(tv.read(cx).message_editor.clone())
575 })
576 .map(|editor| {
577 cx.observe(&editor, |_this, _editor, cx| {
578 cx.notify();
579 })
580 });
581 }
582
583 fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
584 let mw = self.multi_workspace.upgrade()?;
585 let workspace = mw.read(cx).workspace();
586 let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
587 let conversation_view = panel.read(cx).active_conversation_view()?;
588 let thread_view = conversation_view.read(cx).active_thread()?;
589 let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
590 let cleaned = Self::clean_mention_links(&raw);
591 let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
592 if text.is_empty() {
593 None
594 } else {
595 const MAX_CHARS: usize = 250;
596 if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
597 text.truncate(truncate_at);
598 }
599 Some(text.into())
600 }
601 }
602
603 fn clean_mention_links(input: &str) -> String {
604 let mut result = String::with_capacity(input.len());
605 let mut remaining = input;
606
607 while let Some(start) = remaining.find("[@") {
608 result.push_str(&remaining[..start]);
609 let after_bracket = &remaining[start + 1..]; // skip '['
610 if let Some(close_bracket) = after_bracket.find("](") {
611 let mention = &after_bracket[..close_bracket]; // "@something"
612 let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
613 if let Some(close_paren) = after_link_start.find(')') {
614 result.push_str(mention);
615 remaining = &after_link_start[close_paren + 1..];
616 continue;
617 }
618 }
619 // Couldn't parse full link syntax — emit the literal "[@" and move on.
620 result.push_str("[@");
621 remaining = &remaining[start + 2..];
622 }
623 result.push_str(remaining);
624 result
625 }
626
627 /// Rebuilds the sidebar contents from current workspace and thread state.
628 ///
629 /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
630 /// repository, then populates thread entries from the metadata store and
631 /// merges live thread info from active agent panels.
632 ///
633 /// Aim for a single forward pass over workspaces and threads plus an
634 /// O(T log T) sort. Avoid adding extra scans over the data.
635 ///
636 /// Properties:
637 ///
638 /// - Should always show every workspace in the multiworkspace
639 /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
640 /// - Should always show every thread, associated with each workspace in the multiworkspace
641 /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
642 fn rebuild_contents(&mut self, cx: &App) {
643 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
644 return;
645 };
646 let mw = multi_workspace.read(cx);
647 let workspaces = mw.workspaces().to_vec();
648 let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
649
650 let agent_server_store = workspaces
651 .first()
652 .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
653
654 let query = self.filter_editor.read(cx).text(cx);
655
656 let agent_panel_visible = self.agent_panel_visible(cx);
657 let active_thread_is_draft = self.active_thread_is_draft(cx);
658
659 // Derive focused_thread from the active workspace's agent panel.
660 // Only update when the panel gives us a positive signal — if the
661 // panel returns None (e.g. still loading after a thread activation),
662 // keep the previous value so eager writes from user actions survive.
663 let panel_focused = active_workspace
664 .as_ref()
665 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
666 .and_then(|panel| {
667 panel
668 .read(cx)
669 .active_conversation_view()
670 .and_then(|cv| cv.read(cx).parent_id(cx))
671 });
672 if panel_focused.is_some() && !active_thread_is_draft {
673 self.focused_thread = panel_focused;
674 }
675
676 let previous = mem::take(&mut self.contents);
677
678 let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
679 .entries
680 .iter()
681 .filter_map(|entry| match entry {
682 ListEntry::Thread(thread) if thread.is_live => {
683 Some((thread.metadata.session_id.clone(), thread.status))
684 }
685 _ => None,
686 })
687 .collect();
688
689 let mut entries = Vec::new();
690 let mut notified_threads = previous.notified_threads;
691 let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
692 let mut project_header_indices: Vec<usize> = Vec::new();
693
694 // Use ProjectGroupBuilder to canonically group workspaces by their
695 // main git repository. This replaces the manual absorbed-workspace
696 // detection that was here before.
697 let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
698
699 let has_open_projects = workspaces
700 .iter()
701 .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
702
703 let resolve_agent_icon = |agent_id: &AgentId| -> (IconName, Option<SharedString>) {
704 let agent = Agent::from(agent_id.clone());
705 let icon = match agent {
706 Agent::NativeAgent => IconName::ZedAgent,
707 Agent::Custom { .. } => IconName::Terminal,
708 };
709 let icon_from_external_svg = agent_server_store
710 .as_ref()
711 .and_then(|store| store.read(cx).agent_icon(&agent_id));
712 (icon, icon_from_external_svg)
713 };
714
715 for (group_name, group) in project_groups.groups() {
716 let path_list = group_name.path_list().clone();
717 if path_list.paths().is_empty() {
718 continue;
719 }
720
721 let label = group_name.display_name();
722
723 let is_collapsed = self.collapsed_groups.contains(&path_list);
724 let should_load_threads = !is_collapsed || !query.is_empty();
725
726 let is_active = active_workspace
727 .as_ref()
728 .is_some_and(|active| group.workspaces.contains(active));
729
730 // Pick a representative workspace for the group: prefer the active
731 // workspace if it belongs to this group, otherwise use the main
732 // repo workspace (not a linked worktree).
733 let representative_workspace = active_workspace
734 .as_ref()
735 .filter(|_| is_active)
736 .unwrap_or_else(|| group.main_workspace(cx));
737
738 // Collect live thread infos from all workspaces in this group.
739 let live_infos: Vec<_> = group
740 .workspaces
741 .iter()
742 .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
743 .collect();
744
745 let mut threads: Vec<ThreadEntry> = Vec::new();
746 let mut threadless_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeInfo>)> = Vec::new();
747 let mut has_running_threads = false;
748 let mut waiting_thread_count: usize = 0;
749
750 if should_load_threads {
751 let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
752 let thread_store = ThreadMetadataStore::global(cx);
753
754 // Load threads from each workspace in the group.
755 for workspace in &group.workspaces {
756 let ws_path_list = workspace_path_list(workspace, cx);
757 let mut workspace_rows = thread_store
758 .read(cx)
759 .entries_for_path(&ws_path_list)
760 .peekable();
761 if workspace_rows.peek().is_none() {
762 let worktrees =
763 worktree_info_from_thread_paths(&ws_path_list, &project_groups);
764 threadless_workspaces.push((workspace.clone(), worktrees));
765 }
766 for row in workspace_rows {
767 if !seen_session_ids.insert(row.session_id.clone()) {
768 continue;
769 }
770 let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
771 let worktrees =
772 worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
773 threads.push(ThreadEntry {
774 metadata: row,
775 icon,
776 icon_from_external_svg,
777 status: AgentThreadStatus::default(),
778 workspace: ThreadEntryWorkspace::Open(workspace.clone()),
779 is_live: false,
780 is_background: false,
781 is_title_generating: false,
782 highlight_positions: Vec::new(),
783 worktrees,
784 diff_stats: DiffStats::default(),
785 });
786 }
787 }
788
789 // Load threads from linked git worktrees whose
790 // canonical paths belong to this group.
791 let linked_worktree_queries = group
792 .workspaces
793 .iter()
794 .flat_map(|ws| root_repository_snapshots(ws, cx))
795 .filter(|snapshot| !snapshot.is_linked_worktree())
796 .flat_map(|snapshot| {
797 snapshot
798 .linked_worktrees()
799 .iter()
800 .filter(|wt| {
801 project_groups.group_owns_worktree(group, &path_list, &wt.path)
802 })
803 .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
804 .collect::<Vec<_>>()
805 });
806
807 for worktree_path_list in linked_worktree_queries {
808 for row in thread_store.read(cx).entries_for_path(&worktree_path_list) {
809 if !seen_session_ids.insert(row.session_id.clone()) {
810 continue;
811 }
812 let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
813 let worktrees =
814 worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
815 threads.push(ThreadEntry {
816 metadata: row,
817 icon,
818 icon_from_external_svg,
819 status: AgentThreadStatus::default(),
820 workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
821 is_live: false,
822 is_background: false,
823 is_title_generating: false,
824 highlight_positions: Vec::new(),
825 worktrees,
826 diff_stats: DiffStats::default(),
827 });
828 }
829 }
830
831 // Build a lookup from live_infos and compute running/waiting
832 // counts in a single pass.
833 let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
834 HashMap::new();
835 for info in &live_infos {
836 live_info_by_session.insert(&info.session_id, info);
837 if info.status == AgentThreadStatus::Running {
838 has_running_threads = true;
839 }
840 if info.status == AgentThreadStatus::WaitingForConfirmation {
841 waiting_thread_count += 1;
842 }
843 }
844
845 // Merge live info into threads and update notification state
846 // in a single pass.
847 for thread in &mut threads {
848 if let Some(info) = live_info_by_session.get(&thread.metadata.session_id) {
849 thread.apply_active_info(info);
850 }
851
852 let session_id = &thread.metadata.session_id;
853
854 let is_thread_workspace_active = match &thread.workspace {
855 ThreadEntryWorkspace::Open(thread_workspace) => active_workspace
856 .as_ref()
857 .is_some_and(|active| active == thread_workspace),
858 ThreadEntryWorkspace::Closed(_) => false,
859 };
860
861 if thread.status == AgentThreadStatus::Completed
862 && !is_thread_workspace_active
863 && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
864 {
865 notified_threads.insert(session_id.clone());
866 }
867
868 if is_thread_workspace_active && !thread.is_background {
869 notified_threads.remove(session_id);
870 }
871 }
872
873 threads.sort_by(|a, b| {
874 let a_time = self
875 .thread_last_message_sent_or_queued
876 .get(&a.metadata.session_id)
877 .copied()
878 .or(a.metadata.created_at)
879 .or(Some(a.metadata.updated_at));
880 let b_time = self
881 .thread_last_message_sent_or_queued
882 .get(&b.metadata.session_id)
883 .copied()
884 .or(b.metadata.created_at)
885 .or(Some(b.metadata.updated_at));
886 b_time.cmp(&a_time)
887 });
888 } else {
889 for info in live_infos {
890 if info.status == AgentThreadStatus::Running {
891 has_running_threads = true;
892 }
893 if info.status == AgentThreadStatus::WaitingForConfirmation {
894 waiting_thread_count += 1;
895 }
896 }
897 }
898
899 if !query.is_empty() {
900 let workspace_highlight_positions =
901 fuzzy_match_positions(&query, &label).unwrap_or_default();
902 let workspace_matched = !workspace_highlight_positions.is_empty();
903
904 let mut matched_threads: Vec<ThreadEntry> = Vec::new();
905 for mut thread in threads {
906 let title: &str = &thread.metadata.title;
907 if let Some(positions) = fuzzy_match_positions(&query, title) {
908 thread.highlight_positions = positions;
909 }
910 let mut worktree_matched = false;
911 for worktree in &mut thread.worktrees {
912 if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) {
913 worktree.highlight_positions = positions;
914 worktree_matched = true;
915 }
916 }
917 if workspace_matched
918 || !thread.highlight_positions.is_empty()
919 || worktree_matched
920 {
921 matched_threads.push(thread);
922 }
923 }
924
925 if matched_threads.is_empty() && !workspace_matched {
926 continue;
927 }
928
929 project_header_indices.push(entries.len());
930 entries.push(ListEntry::ProjectHeader {
931 path_list: path_list.clone(),
932 label,
933 workspace: representative_workspace.clone(),
934 highlight_positions: workspace_highlight_positions,
935 has_running_threads,
936 waiting_thread_count,
937 is_active,
938 });
939
940 for thread in matched_threads {
941 current_session_ids.insert(thread.metadata.session_id.clone());
942 entries.push(thread.into());
943 }
944 } else {
945 let is_draft_for_workspace = agent_panel_visible
946 && active_thread_is_draft
947 && self.focused_thread.is_none()
948 && is_active;
949
950 project_header_indices.push(entries.len());
951 entries.push(ListEntry::ProjectHeader {
952 path_list: path_list.clone(),
953 label,
954 workspace: representative_workspace.clone(),
955 highlight_positions: Vec::new(),
956 has_running_threads,
957 waiting_thread_count,
958 is_active,
959 });
960
961 if is_collapsed {
962 continue;
963 }
964
965 // Emit "New Thread" entries for threadless workspaces
966 // and active drafts, right after the header.
967 for (workspace, worktrees) in &threadless_workspaces {
968 let is_draft = is_draft_for_workspace && workspace == representative_workspace;
969 entries.push(ListEntry::NewThread {
970 path_list: path_list.clone(),
971 workspace: workspace.clone(),
972 is_active_draft: is_draft,
973 worktrees: worktrees.clone(),
974 });
975 }
976 if is_draft_for_workspace
977 && !threadless_workspaces
978 .iter()
979 .any(|(ws, _)| ws == representative_workspace)
980 {
981 let ws_path_list = workspace_path_list(representative_workspace, cx);
982 let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups);
983 entries.push(ListEntry::NewThread {
984 path_list: path_list.clone(),
985 workspace: representative_workspace.clone(),
986 is_active_draft: true,
987 worktrees,
988 });
989 }
990
991 let total = threads.len();
992
993 let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
994 let threads_to_show =
995 DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
996 let count = threads_to_show.min(total);
997
998 let mut promoted_threads: HashSet<acp::SessionId> = HashSet::new();
999
1000 // Build visible entries in a single pass. Threads within
1001 // the cutoff are always shown. Threads beyond it are shown
1002 // only if they should be promoted (running, waiting, or
1003 // focused)
1004 for (index, thread) in threads.into_iter().enumerate() {
1005 let is_hidden = index >= count;
1006
1007 let session_id = &thread.metadata.session_id;
1008 if is_hidden {
1009 let is_promoted = thread.status == AgentThreadStatus::Running
1010 || thread.status == AgentThreadStatus::WaitingForConfirmation
1011 || notified_threads.contains(session_id)
1012 || self
1013 .focused_thread
1014 .as_ref()
1015 .is_some_and(|id| id == session_id);
1016 if is_promoted {
1017 promoted_threads.insert(session_id.clone());
1018 }
1019 if !promoted_threads.contains(session_id) {
1020 continue;
1021 }
1022 }
1023
1024 current_session_ids.insert(session_id.clone());
1025 entries.push(thread.into());
1026 }
1027
1028 let visible = count + promoted_threads.len();
1029 let is_fully_expanded = visible >= total;
1030
1031 if total > DEFAULT_THREADS_SHOWN {
1032 entries.push(ListEntry::ViewMore {
1033 path_list: path_list.clone(),
1034 is_fully_expanded,
1035 });
1036 }
1037 }
1038 }
1039
1040 // Prune stale notifications using the session IDs we collected during
1041 // the build pass (no extra scan needed).
1042 notified_threads.retain(|id| current_session_ids.contains(id));
1043
1044 self.thread_last_accessed
1045 .retain(|id, _| current_session_ids.contains(id));
1046 self.thread_last_message_sent_or_queued
1047 .retain(|id, _| current_session_ids.contains(id));
1048
1049 self.contents = SidebarContents {
1050 entries,
1051 notified_threads,
1052 project_header_indices,
1053 has_open_projects,
1054 };
1055 }
1056
1057 /// Rebuilds the sidebar's visible entries from already-cached state.
1058 fn update_entries(&mut self, cx: &mut Context<Self>) {
1059 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1060 return;
1061 };
1062 if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1063 return;
1064 }
1065
1066 let had_notifications = self.has_notifications(cx);
1067 let scroll_position = self.list_state.logical_scroll_top();
1068
1069 self.rebuild_contents(cx);
1070
1071 self.list_state.reset(self.contents.entries.len());
1072 self.list_state.scroll_to(scroll_position);
1073
1074 if had_notifications != self.has_notifications(cx) {
1075 multi_workspace.update(cx, |_, cx| {
1076 cx.notify();
1077 });
1078 }
1079
1080 cx.notify();
1081 }
1082
1083 fn select_first_entry(&mut self) {
1084 self.selection = self
1085 .contents
1086 .entries
1087 .iter()
1088 .position(|entry| matches!(entry, ListEntry::Thread(_)))
1089 .or_else(|| {
1090 if self.contents.entries.is_empty() {
1091 None
1092 } else {
1093 Some(0)
1094 }
1095 });
1096 }
1097
1098 fn render_list_entry(
1099 &mut self,
1100 ix: usize,
1101 window: &mut Window,
1102 cx: &mut Context<Self>,
1103 ) -> AnyElement {
1104 let Some(entry) = self.contents.entries.get(ix) else {
1105 return div().into_any_element();
1106 };
1107 let is_focused = self.focus_handle.is_focused(window);
1108 // is_selected means the keyboard selector is here.
1109 let is_selected = is_focused && self.selection == Some(ix);
1110
1111 let is_group_header_after_first =
1112 ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1113
1114 let rendered = match entry {
1115 ListEntry::ProjectHeader {
1116 path_list,
1117 label,
1118 workspace,
1119 highlight_positions,
1120 has_running_threads,
1121 waiting_thread_count,
1122 is_active,
1123 } => self.render_project_header(
1124 ix,
1125 false,
1126 path_list,
1127 label,
1128 workspace,
1129 highlight_positions,
1130 *has_running_threads,
1131 *waiting_thread_count,
1132 *is_active,
1133 is_selected,
1134 cx,
1135 ),
1136 ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
1137 ListEntry::ViewMore {
1138 path_list,
1139 is_fully_expanded,
1140 } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
1141 ListEntry::NewThread {
1142 path_list,
1143 workspace,
1144 is_active_draft,
1145 worktrees,
1146 } => self.render_new_thread(
1147 ix,
1148 path_list,
1149 workspace,
1150 *is_active_draft,
1151 worktrees,
1152 is_selected,
1153 cx,
1154 ),
1155 };
1156
1157 if is_group_header_after_first {
1158 v_flex()
1159 .w_full()
1160 .border_t_1()
1161 .border_color(cx.theme().colors().border.opacity(0.5))
1162 .child(rendered)
1163 .into_any_element()
1164 } else {
1165 rendered
1166 }
1167 }
1168
1169 fn render_remote_project_icon(
1170 &self,
1171 ix: usize,
1172 workspace: &Entity<Workspace>,
1173 cx: &mut Context<Self>,
1174 ) -> Option<AnyElement> {
1175 let project = workspace.read(cx).project().read(cx);
1176 let remote_connection_options = project.remote_connection_options(cx)?;
1177
1178 let remote_icon_per_type = match remote_connection_options {
1179 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1180 RemoteConnectionOptions::Docker(_) => IconName::Box,
1181 _ => IconName::Server,
1182 };
1183
1184 Some(
1185 div()
1186 .id(format!("remote-project-icon-{}", ix))
1187 .child(
1188 Icon::new(remote_icon_per_type)
1189 .size(IconSize::XSmall)
1190 .color(Color::Muted),
1191 )
1192 .tooltip(Tooltip::text("Remote Project"))
1193 .into_any_element(),
1194 )
1195 }
1196
1197 fn render_project_header(
1198 &self,
1199 ix: usize,
1200 is_sticky: bool,
1201 path_list: &PathList,
1202 label: &SharedString,
1203 workspace: &Entity<Workspace>,
1204 highlight_positions: &[usize],
1205 has_running_threads: bool,
1206 waiting_thread_count: usize,
1207 is_active: bool,
1208 is_selected: bool,
1209 cx: &mut Context<Self>,
1210 ) -> AnyElement {
1211 let id_prefix = if is_sticky { "sticky-" } else { "" };
1212 let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1213 let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1214
1215 let is_collapsed = self.collapsed_groups.contains(path_list);
1216 let disclosure_icon = if is_collapsed {
1217 IconName::ChevronRight
1218 } else {
1219 IconName::ChevronDown
1220 };
1221
1222 let has_new_thread_entry = self
1223 .contents
1224 .entries
1225 .get(ix + 1)
1226 .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
1227 let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
1228
1229 let workspace_for_remove = workspace.clone();
1230 let workspace_for_menu = workspace.clone();
1231 let workspace_for_open = workspace.clone();
1232
1233 let path_list_for_toggle = path_list.clone();
1234 let path_list_for_collapse = path_list.clone();
1235 let view_more_expanded = self.expanded_groups.contains_key(path_list);
1236
1237 let label = if highlight_positions.is_empty() {
1238 Label::new(label.clone())
1239 .color(Color::Muted)
1240 .into_any_element()
1241 } else {
1242 HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1243 .color(Color::Muted)
1244 .into_any_element()
1245 };
1246
1247 let color = cx.theme().colors();
1248 let hover_color = color
1249 .element_active
1250 .blend(color.element_background.opacity(0.2));
1251
1252 h_flex()
1253 .id(id)
1254 .group(&group_name)
1255 .h(Tab::content_height(cx))
1256 .w_full()
1257 .pl_1p5()
1258 .pr_1()
1259 .border_1()
1260 .map(|this| {
1261 if is_selected {
1262 this.border_color(color.border_focused)
1263 } else {
1264 this.border_color(gpui::transparent_black())
1265 }
1266 })
1267 .justify_between()
1268 .hover(|s| s.bg(hover_color))
1269 .child(
1270 h_flex()
1271 .relative()
1272 .min_w_0()
1273 .w_full()
1274 .gap_1p5()
1275 .child(
1276 h_flex().size_4().flex_none().justify_center().child(
1277 Icon::new(disclosure_icon)
1278 .size(IconSize::Small)
1279 .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))),
1280 ),
1281 )
1282 .child(label)
1283 .when_some(
1284 self.render_remote_project_icon(ix, workspace, cx),
1285 |this, icon| this.child(icon),
1286 )
1287 .when(is_collapsed, |this| {
1288 this.when(has_running_threads, |this| {
1289 this.child(
1290 Icon::new(IconName::LoadCircle)
1291 .size(IconSize::XSmall)
1292 .color(Color::Muted)
1293 .with_rotate_animation(2),
1294 )
1295 })
1296 .when(waiting_thread_count > 0, |this| {
1297 let tooltip_text = if waiting_thread_count == 1 {
1298 "1 thread is waiting for confirmation".to_string()
1299 } else {
1300 format!(
1301 "{waiting_thread_count} threads are waiting for confirmation",
1302 )
1303 };
1304 this.child(
1305 div()
1306 .id(format!("{id_prefix}waiting-indicator-{ix}"))
1307 .child(
1308 Icon::new(IconName::Warning)
1309 .size(IconSize::XSmall)
1310 .color(Color::Warning),
1311 )
1312 .tooltip(Tooltip::text(tooltip_text)),
1313 )
1314 })
1315 }),
1316 )
1317 .child({
1318 let workspace_for_new_thread = workspace.clone();
1319 let path_list_for_new_thread = path_list.clone();
1320
1321 h_flex()
1322 .when(self.project_header_menu_ix != Some(ix), |this| {
1323 this.visible_on_hover(group_name)
1324 })
1325 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1326 cx.stop_propagation();
1327 })
1328 .child(self.render_project_header_menu(
1329 ix,
1330 id_prefix,
1331 &workspace_for_menu,
1332 &workspace_for_remove,
1333 cx,
1334 ))
1335 .when(view_more_expanded && !is_collapsed, |this| {
1336 this.child(
1337 IconButton::new(
1338 SharedString::from(format!(
1339 "{id_prefix}project-header-collapse-{ix}",
1340 )),
1341 IconName::ListCollapse,
1342 )
1343 .icon_size(IconSize::Small)
1344 .icon_color(Color::Muted)
1345 .tooltip(Tooltip::text("Collapse Displayed Threads"))
1346 .on_click(cx.listener({
1347 let path_list_for_collapse = path_list_for_collapse.clone();
1348 move |this, _, _window, cx| {
1349 this.selection = None;
1350 this.expanded_groups.remove(&path_list_for_collapse);
1351 this.update_entries(cx);
1352 }
1353 })),
1354 )
1355 })
1356 .when(!is_active, |this| {
1357 this.child(
1358 IconButton::new(
1359 SharedString::from(format!(
1360 "{id_prefix}project-header-open-workspace-{ix}",
1361 )),
1362 IconName::Focus,
1363 )
1364 .icon_size(IconSize::Small)
1365 .icon_color(Color::Muted)
1366 .tooltip(Tooltip::text("Activate Workspace"))
1367 .on_click(cx.listener({
1368 move |this, _, window, cx| {
1369 this.focused_thread = None;
1370 if let Some(multi_workspace) = this.multi_workspace.upgrade() {
1371 multi_workspace.update(cx, |multi_workspace, cx| {
1372 multi_workspace.activate(
1373 workspace_for_open.clone(),
1374 window,
1375 cx,
1376 );
1377 });
1378 }
1379 if AgentPanel::is_visible(&workspace_for_open, cx) {
1380 workspace_for_open.update(cx, |workspace, cx| {
1381 workspace.focus_panel::<AgentPanel>(window, cx);
1382 });
1383 }
1384 }
1385 })),
1386 )
1387 })
1388 .when(show_new_thread_button, |this| {
1389 this.child(
1390 IconButton::new(
1391 SharedString::from(format!(
1392 "{id_prefix}project-header-new-thread-{ix}",
1393 )),
1394 IconName::Plus,
1395 )
1396 .icon_size(IconSize::Small)
1397 .icon_color(Color::Muted)
1398 .tooltip(Tooltip::text("New Thread"))
1399 .on_click(cx.listener({
1400 let workspace_for_new_thread = workspace_for_new_thread.clone();
1401 let path_list_for_new_thread = path_list_for_new_thread.clone();
1402 move |this, _, window, cx| {
1403 // Uncollapse the group if collapsed so
1404 // the new-thread entry becomes visible.
1405 this.collapsed_groups.remove(&path_list_for_new_thread);
1406 this.selection = None;
1407 this.create_new_thread(&workspace_for_new_thread, window, cx);
1408 }
1409 })),
1410 )
1411 })
1412 })
1413 .on_click(cx.listener(move |this, _, window, cx| {
1414 this.selection = None;
1415 this.toggle_collapse(&path_list_for_toggle, window, cx);
1416 }))
1417 .into_any_element()
1418 }
1419
1420 fn render_project_header_menu(
1421 &self,
1422 ix: usize,
1423 id_prefix: &str,
1424 workspace: &Entity<Workspace>,
1425 workspace_for_remove: &Entity<Workspace>,
1426 cx: &mut Context<Self>,
1427 ) -> impl IntoElement {
1428 let workspace_for_menu = workspace.clone();
1429 let workspace_for_remove = workspace_for_remove.clone();
1430 let multi_workspace = self.multi_workspace.clone();
1431 let this = cx.weak_entity();
1432
1433 PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1434 .on_open(Rc::new({
1435 let this = this.clone();
1436 move |_window, cx| {
1437 this.update(cx, |sidebar, cx| {
1438 sidebar.project_header_menu_ix = Some(ix);
1439 cx.notify();
1440 })
1441 .ok();
1442 }
1443 }))
1444 .menu(move |window, cx| {
1445 let workspace = workspace_for_menu.clone();
1446 let workspace_for_remove = workspace_for_remove.clone();
1447 let multi_workspace = multi_workspace.clone();
1448
1449 let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
1450 let worktrees: Vec<_> = workspace
1451 .read(cx)
1452 .visible_worktrees(cx)
1453 .map(|worktree| {
1454 let worktree_read = worktree.read(cx);
1455 let id = worktree_read.id();
1456 let name: SharedString =
1457 worktree_read.root_name().as_unix_str().to_string().into();
1458 (id, name)
1459 })
1460 .collect();
1461
1462 let worktree_count = worktrees.len();
1463
1464 let mut menu = menu
1465 .header("Project Folders")
1466 .end_slot_action(Box::new(menu::EndSlot));
1467
1468 for (worktree_id, name) in &worktrees {
1469 let worktree_id = *worktree_id;
1470 let workspace_for_worktree = workspace.clone();
1471 let workspace_for_remove_worktree = workspace_for_remove.clone();
1472 let multi_workspace_for_worktree = multi_workspace.clone();
1473
1474 let remove_handler = move |window: &mut Window, cx: &mut App| {
1475 if worktree_count <= 1 {
1476 if let Some(mw) = multi_workspace_for_worktree.upgrade() {
1477 let ws = workspace_for_remove_worktree.clone();
1478 mw.update(cx, |multi_workspace, cx| {
1479 multi_workspace.remove(&ws, window, cx);
1480 });
1481 }
1482 } else {
1483 workspace_for_worktree.update(cx, |workspace, cx| {
1484 workspace.project().update(cx, |project, cx| {
1485 project.remove_worktree(worktree_id, cx);
1486 });
1487 });
1488 }
1489 };
1490
1491 menu = menu.entry_with_end_slot_on_hover(
1492 name.clone(),
1493 None,
1494 |_, _| {},
1495 IconName::Close,
1496 "Remove Folder".into(),
1497 remove_handler,
1498 );
1499 }
1500
1501 let workspace_for_add = workspace.clone();
1502 let multi_workspace_for_add = multi_workspace.clone();
1503 let menu = menu.separator().entry(
1504 "Add Folder to Project",
1505 Some(Box::new(AddFolderToProject)),
1506 move |window, cx| {
1507 if let Some(mw) = multi_workspace_for_add.upgrade() {
1508 mw.update(cx, |mw, cx| {
1509 mw.activate(workspace_for_add.clone(), window, cx);
1510 });
1511 }
1512 workspace_for_add.update(cx, |workspace, cx| {
1513 workspace.add_folder_to_project(&AddFolderToProject, window, cx);
1514 });
1515 },
1516 );
1517
1518 let workspace_count = multi_workspace
1519 .upgrade()
1520 .map_or(0, |mw| mw.read(cx).workspaces().len());
1521 let menu = if workspace_count > 1 {
1522 let workspace_for_move = workspace.clone();
1523 let multi_workspace_for_move = multi_workspace.clone();
1524 menu.entry(
1525 "Move to New Window",
1526 Some(Box::new(
1527 zed_actions::agents_sidebar::MoveWorkspaceToNewWindow,
1528 )),
1529 move |window, cx| {
1530 if let Some(mw) = multi_workspace_for_move.upgrade() {
1531 mw.update(cx, |multi_workspace, cx| {
1532 multi_workspace.move_workspace_to_new_window(
1533 &workspace_for_move,
1534 window,
1535 cx,
1536 );
1537 });
1538 }
1539 },
1540 )
1541 } else {
1542 menu
1543 };
1544
1545 let workspace_for_remove = workspace_for_remove.clone();
1546 let multi_workspace_for_remove = multi_workspace.clone();
1547 menu.separator()
1548 .entry("Remove Project", None, move |window, cx| {
1549 if let Some(mw) = multi_workspace_for_remove.upgrade() {
1550 let ws = workspace_for_remove.clone();
1551 mw.update(cx, |multi_workspace, cx| {
1552 multi_workspace.remove(&ws, window, cx);
1553 });
1554 }
1555 })
1556 });
1557
1558 let this = this.clone();
1559 window
1560 .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1561 this.update(cx, |sidebar, cx| {
1562 sidebar.project_header_menu_ix = None;
1563 cx.notify();
1564 })
1565 .ok();
1566 })
1567 .detach();
1568
1569 Some(menu)
1570 })
1571 .trigger(
1572 IconButton::new(
1573 SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1574 IconName::Ellipsis,
1575 )
1576 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1577 .icon_size(IconSize::Small)
1578 .icon_color(Color::Muted),
1579 )
1580 .anchor(gpui::Corner::TopRight)
1581 .offset(gpui::Point {
1582 x: px(0.),
1583 y: px(1.),
1584 })
1585 }
1586
1587 fn render_sticky_header(
1588 &self,
1589 window: &mut Window,
1590 cx: &mut Context<Self>,
1591 ) -> Option<AnyElement> {
1592 let scroll_top = self.list_state.logical_scroll_top();
1593
1594 let &header_idx = self
1595 .contents
1596 .project_header_indices
1597 .iter()
1598 .rev()
1599 .find(|&&idx| idx <= scroll_top.item_ix)?;
1600
1601 let needs_sticky = header_idx < scroll_top.item_ix
1602 || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1603
1604 if !needs_sticky {
1605 return None;
1606 }
1607
1608 let ListEntry::ProjectHeader {
1609 path_list,
1610 label,
1611 workspace,
1612 highlight_positions,
1613 has_running_threads,
1614 waiting_thread_count,
1615 is_active,
1616 } = self.contents.entries.get(header_idx)?
1617 else {
1618 return None;
1619 };
1620
1621 let is_focused = self.focus_handle.is_focused(window);
1622 let is_selected = is_focused && self.selection == Some(header_idx);
1623
1624 let header_element = self.render_project_header(
1625 header_idx,
1626 true,
1627 &path_list,
1628 &label,
1629 workspace,
1630 &highlight_positions,
1631 *has_running_threads,
1632 *waiting_thread_count,
1633 *is_active,
1634 is_selected,
1635 cx,
1636 );
1637
1638 let top_offset = self
1639 .contents
1640 .project_header_indices
1641 .iter()
1642 .find(|&&idx| idx > header_idx)
1643 .and_then(|&next_idx| {
1644 let bounds = self.list_state.bounds_for_item(next_idx)?;
1645 let viewport = self.list_state.viewport_bounds();
1646 let y_in_viewport = bounds.origin.y - viewport.origin.y;
1647 let header_height = bounds.size.height;
1648 (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1649 })
1650 .unwrap_or(px(0.));
1651
1652 let color = cx.theme().colors();
1653 let background = color
1654 .title_bar_background
1655 .blend(color.panel_background.opacity(0.2));
1656
1657 let element = v_flex()
1658 .absolute()
1659 .top(top_offset)
1660 .left_0()
1661 .w_full()
1662 .bg(background)
1663 .border_b_1()
1664 .border_color(color.border.opacity(0.5))
1665 .child(header_element)
1666 .shadow_xs()
1667 .into_any_element();
1668
1669 Some(element)
1670 }
1671
1672 fn toggle_collapse(
1673 &mut self,
1674 path_list: &PathList,
1675 _window: &mut Window,
1676 cx: &mut Context<Self>,
1677 ) {
1678 if self.collapsed_groups.contains(path_list) {
1679 self.collapsed_groups.remove(path_list);
1680 } else {
1681 self.collapsed_groups.insert(path_list.clone());
1682 }
1683 self.update_entries(cx);
1684 }
1685
1686 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
1687 let mut dispatch_context = KeyContext::new_with_defaults();
1688 dispatch_context.add("ThreadsSidebar");
1689 dispatch_context.add("menu");
1690
1691 let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) {
1692 "searching"
1693 } else {
1694 "not_searching"
1695 };
1696
1697 dispatch_context.add(identifier);
1698 dispatch_context
1699 }
1700
1701 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1702 if !self.focus_handle.is_focused(window) {
1703 return;
1704 }
1705
1706 if let SidebarView::Archive(archive) = &self.view {
1707 let has_selection = archive.read(cx).has_selection();
1708 if !has_selection {
1709 archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1710 }
1711 } else if self.selection.is_none() {
1712 self.filter_editor.focus_handle(cx).focus(window, cx);
1713 }
1714 }
1715
1716 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1717 if self.reset_filter_editor_text(window, cx) {
1718 self.update_entries(cx);
1719 } else {
1720 self.selection = None;
1721 self.filter_editor.focus_handle(cx).focus(window, cx);
1722 cx.notify();
1723 }
1724 }
1725
1726 fn focus_sidebar_filter(
1727 &mut self,
1728 _: &FocusSidebarFilter,
1729 window: &mut Window,
1730 cx: &mut Context<Self>,
1731 ) {
1732 self.selection = None;
1733 if let SidebarView::Archive(archive) = &self.view {
1734 archive.update(cx, |view, cx| {
1735 view.clear_selection();
1736 view.focus_filter_editor(window, cx);
1737 });
1738 } else {
1739 self.filter_editor.focus_handle(cx).focus(window, cx);
1740 }
1741
1742 // When vim mode is active, the editor defaults to normal mode which
1743 // blocks text input. Switch to insert mode so the user can type
1744 // immediately.
1745 if vim_mode_setting::VimModeSetting::get_global(cx).0 {
1746 if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
1747 window.dispatch_action(action, cx);
1748 }
1749 }
1750
1751 cx.notify();
1752 }
1753
1754 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1755 self.filter_editor.update(cx, |editor, cx| {
1756 if editor.buffer().read(cx).len(cx).0 > 0 {
1757 editor.set_text("", window, cx);
1758 true
1759 } else {
1760 false
1761 }
1762 })
1763 }
1764
1765 fn has_filter_query(&self, cx: &App) -> bool {
1766 !self.filter_editor.read(cx).text(cx).is_empty()
1767 }
1768
1769 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1770 self.select_next(&SelectNext, window, cx);
1771 if self.selection.is_some() {
1772 self.focus_handle.focus(window, cx);
1773 }
1774 }
1775
1776 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1777 self.select_previous(&SelectPrevious, window, cx);
1778 if self.selection.is_some() {
1779 self.focus_handle.focus(window, cx);
1780 }
1781 }
1782
1783 fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1784 if self.selection.is_none() {
1785 self.select_next(&SelectNext, window, cx);
1786 }
1787 if self.selection.is_some() {
1788 self.focus_handle.focus(window, cx);
1789 }
1790 }
1791
1792 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1793 let next = match self.selection {
1794 Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1795 Some(_) if !self.contents.entries.is_empty() => 0,
1796 None if !self.contents.entries.is_empty() => 0,
1797 _ => return,
1798 };
1799 self.selection = Some(next);
1800 self.list_state.scroll_to_reveal_item(next);
1801 cx.notify();
1802 }
1803
1804 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1805 match self.selection {
1806 Some(0) => {
1807 self.selection = None;
1808 self.filter_editor.focus_handle(cx).focus(window, cx);
1809 cx.notify();
1810 }
1811 Some(ix) => {
1812 self.selection = Some(ix - 1);
1813 self.list_state.scroll_to_reveal_item(ix - 1);
1814 cx.notify();
1815 }
1816 None if !self.contents.entries.is_empty() => {
1817 let last = self.contents.entries.len() - 1;
1818 self.selection = Some(last);
1819 self.list_state.scroll_to_reveal_item(last);
1820 cx.notify();
1821 }
1822 None => {}
1823 }
1824 }
1825
1826 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1827 if !self.contents.entries.is_empty() {
1828 self.selection = Some(0);
1829 self.list_state.scroll_to_reveal_item(0);
1830 cx.notify();
1831 }
1832 }
1833
1834 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1835 if let Some(last) = self.contents.entries.len().checked_sub(1) {
1836 self.selection = Some(last);
1837 self.list_state.scroll_to_reveal_item(last);
1838 cx.notify();
1839 }
1840 }
1841
1842 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1843 let Some(ix) = self.selection else { return };
1844 let Some(entry) = self.contents.entries.get(ix) else {
1845 return;
1846 };
1847
1848 match entry {
1849 ListEntry::ProjectHeader { path_list, .. } => {
1850 let path_list = path_list.clone();
1851 self.toggle_collapse(&path_list, window, cx);
1852 }
1853 ListEntry::Thread(thread) => {
1854 let metadata = thread.metadata.clone();
1855 match &thread.workspace {
1856 ThreadEntryWorkspace::Open(workspace) => {
1857 let workspace = workspace.clone();
1858 self.activate_thread(metadata, &workspace, window, cx);
1859 }
1860 ThreadEntryWorkspace::Closed(path_list) => {
1861 self.open_workspace_and_activate_thread(
1862 metadata,
1863 path_list.clone(),
1864 window,
1865 cx,
1866 );
1867 }
1868 }
1869 }
1870 ListEntry::ViewMore {
1871 path_list,
1872 is_fully_expanded,
1873 ..
1874 } => {
1875 let path_list = path_list.clone();
1876 if *is_fully_expanded {
1877 self.expanded_groups.remove(&path_list);
1878 } else {
1879 let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1880 self.expanded_groups.insert(path_list, current + 1);
1881 }
1882 self.update_entries(cx);
1883 }
1884 ListEntry::NewThread { workspace, .. } => {
1885 let workspace = workspace.clone();
1886 self.create_new_thread(&workspace, window, cx);
1887 }
1888 }
1889 }
1890
1891 fn find_workspace_across_windows(
1892 &self,
1893 cx: &App,
1894 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1895 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
1896 cx.windows()
1897 .into_iter()
1898 .filter_map(|window| window.downcast::<MultiWorkspace>())
1899 .find_map(|window| {
1900 let workspace = window.read(cx).ok().and_then(|multi_workspace| {
1901 multi_workspace
1902 .workspaces()
1903 .iter()
1904 .find(|workspace| predicate(workspace, cx))
1905 .cloned()
1906 })?;
1907 Some((window, workspace))
1908 })
1909 }
1910
1911 fn find_workspace_in_current_window(
1912 &self,
1913 cx: &App,
1914 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1915 ) -> Option<Entity<Workspace>> {
1916 self.multi_workspace.upgrade().and_then(|multi_workspace| {
1917 multi_workspace
1918 .read(cx)
1919 .workspaces()
1920 .iter()
1921 .find(|workspace| predicate(workspace, cx))
1922 .cloned()
1923 })
1924 }
1925
1926 fn load_agent_thread_in_workspace(
1927 workspace: &Entity<Workspace>,
1928 metadata: &ThreadMetadata,
1929 focus: bool,
1930 window: &mut Window,
1931 cx: &mut App,
1932 ) {
1933 workspace.update(cx, |workspace, cx| {
1934 workspace.open_panel::<AgentPanel>(window, cx);
1935 });
1936
1937 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1938 agent_panel.update(cx, |panel, cx| {
1939 panel.load_agent_thread(
1940 Agent::from(metadata.agent_id.clone()),
1941 metadata.session_id.clone(),
1942 Some(metadata.folder_paths.clone()),
1943 Some(metadata.title.clone()),
1944 focus,
1945 window,
1946 cx,
1947 );
1948 });
1949 }
1950 }
1951
1952 fn activate_thread_locally(
1953 &mut self,
1954 metadata: &ThreadMetadata,
1955 workspace: &Entity<Workspace>,
1956 window: &mut Window,
1957 cx: &mut Context<Self>,
1958 ) {
1959 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1960 return;
1961 };
1962
1963 // Set focused_thread eagerly so the sidebar highlight updates
1964 // immediately, rather than waiting for a deferred AgentPanel
1965 // event which can race with ActiveWorkspaceChanged clearing it.
1966 self.focused_thread = Some(metadata.session_id.clone());
1967 self.record_thread_access(&metadata.session_id);
1968
1969 multi_workspace.update(cx, |multi_workspace, cx| {
1970 multi_workspace.activate(workspace.clone(), window, cx);
1971 });
1972
1973 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
1974
1975 self.update_entries(cx);
1976 }
1977
1978 fn activate_thread_in_other_window(
1979 &self,
1980 metadata: ThreadMetadata,
1981 workspace: Entity<Workspace>,
1982 target_window: WindowHandle<MultiWorkspace>,
1983 cx: &mut Context<Self>,
1984 ) {
1985 let target_session_id = metadata.session_id.clone();
1986
1987 let activated = target_window
1988 .update(cx, |multi_workspace, window, cx| {
1989 window.activate_window();
1990 multi_workspace.activate(workspace.clone(), window, cx);
1991 Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
1992 })
1993 .log_err()
1994 .is_some();
1995
1996 if activated {
1997 if let Some(target_sidebar) = target_window
1998 .read(cx)
1999 .ok()
2000 .and_then(|multi_workspace| {
2001 multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2002 })
2003 .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2004 {
2005 target_sidebar.update(cx, |sidebar, cx| {
2006 sidebar.focused_thread = Some(target_session_id.clone());
2007 sidebar.record_thread_access(&target_session_id);
2008 sidebar.update_entries(cx);
2009 });
2010 }
2011 }
2012 }
2013
2014 fn activate_thread(
2015 &mut self,
2016 metadata: ThreadMetadata,
2017 workspace: &Entity<Workspace>,
2018 window: &mut Window,
2019 cx: &mut Context<Self>,
2020 ) {
2021 if self
2022 .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2023 .is_some()
2024 {
2025 self.activate_thread_locally(&metadata, &workspace, window, cx);
2026 return;
2027 }
2028
2029 let Some((target_window, workspace)) =
2030 self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2031 else {
2032 return;
2033 };
2034
2035 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2036 }
2037
2038 fn open_workspace_and_activate_thread(
2039 &mut self,
2040 metadata: ThreadMetadata,
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| {
2053 mw.open_project(paths, workspace::OpenMode::Activate, window, cx)
2054 });
2055
2056 cx.spawn_in(window, async move |this, cx| {
2057 let workspace = open_task.await?;
2058
2059 this.update_in(cx, |this, window, cx| {
2060 this.activate_thread(metadata, &workspace, window, cx);
2061 })?;
2062 anyhow::Ok(())
2063 })
2064 .detach_and_log_err(cx);
2065 }
2066
2067 fn find_current_workspace_for_path_list(
2068 &self,
2069 path_list: &PathList,
2070 cx: &App,
2071 ) -> Option<Entity<Workspace>> {
2072 self.find_workspace_in_current_window(cx, |workspace, cx| {
2073 workspace_path_list(workspace, cx).paths() == path_list.paths()
2074 })
2075 }
2076
2077 fn find_open_workspace_for_path_list(
2078 &self,
2079 path_list: &PathList,
2080 cx: &App,
2081 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2082 self.find_workspace_across_windows(cx, |workspace, cx| {
2083 workspace_path_list(workspace, cx).paths() == path_list.paths()
2084 })
2085 }
2086
2087 fn activate_archived_thread(
2088 &mut self,
2089 metadata: ThreadMetadata,
2090 window: &mut Window,
2091 cx: &mut Context<Self>,
2092 ) {
2093 ThreadMetadataStore::global(cx)
2094 .update(cx, |store, cx| store.unarchive(&metadata.session_id, cx));
2095
2096 if !metadata.folder_paths.paths().is_empty() {
2097 let path_list = metadata.folder_paths.clone();
2098 if let Some(workspace) = self.find_current_workspace_for_path_list(&path_list, cx) {
2099 self.activate_thread_locally(&metadata, &workspace, window, cx);
2100 } else if let Some((target_window, workspace)) =
2101 self.find_open_workspace_for_path_list(&path_list, cx)
2102 {
2103 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2104 } else {
2105 self.open_workspace_and_activate_thread(metadata, path_list, window, cx);
2106 }
2107 return;
2108 }
2109
2110 let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
2111 w.read(cx)
2112 .workspaces()
2113 .get(w.read(cx).active_workspace_index())
2114 .cloned()
2115 });
2116
2117 if let Some(workspace) = active_workspace {
2118 self.activate_thread_locally(&metadata, &workspace, window, cx);
2119 }
2120 }
2121
2122 fn expand_selected_entry(
2123 &mut self,
2124 _: &SelectChild,
2125 _window: &mut Window,
2126 cx: &mut Context<Self>,
2127 ) {
2128 let Some(ix) = self.selection else { return };
2129
2130 match self.contents.entries.get(ix) {
2131 Some(ListEntry::ProjectHeader { path_list, .. }) => {
2132 if self.collapsed_groups.contains(path_list) {
2133 let path_list = path_list.clone();
2134 self.collapsed_groups.remove(&path_list);
2135 self.update_entries(cx);
2136 } else if ix + 1 < self.contents.entries.len() {
2137 self.selection = Some(ix + 1);
2138 self.list_state.scroll_to_reveal_item(ix + 1);
2139 cx.notify();
2140 }
2141 }
2142 _ => {}
2143 }
2144 }
2145
2146 fn collapse_selected_entry(
2147 &mut self,
2148 _: &SelectParent,
2149 _window: &mut Window,
2150 cx: &mut Context<Self>,
2151 ) {
2152 let Some(ix) = self.selection else { return };
2153
2154 match self.contents.entries.get(ix) {
2155 Some(ListEntry::ProjectHeader { path_list, .. }) => {
2156 if !self.collapsed_groups.contains(path_list) {
2157 let path_list = path_list.clone();
2158 self.collapsed_groups.insert(path_list);
2159 self.update_entries(cx);
2160 }
2161 }
2162 Some(
2163 ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2164 ) => {
2165 for i in (0..ix).rev() {
2166 if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2167 self.contents.entries.get(i)
2168 {
2169 let path_list = path_list.clone();
2170 self.selection = Some(i);
2171 self.collapsed_groups.insert(path_list);
2172 self.update_entries(cx);
2173 break;
2174 }
2175 }
2176 }
2177 None => {}
2178 }
2179 }
2180
2181 fn toggle_selected_fold(
2182 &mut self,
2183 _: &editor::actions::ToggleFold,
2184 _window: &mut Window,
2185 cx: &mut Context<Self>,
2186 ) {
2187 let Some(ix) = self.selection else { return };
2188
2189 // Find the group header for the current selection.
2190 let header_ix = match self.contents.entries.get(ix) {
2191 Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2192 Some(
2193 ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2194 ) => (0..ix).rev().find(|&i| {
2195 matches!(
2196 self.contents.entries.get(i),
2197 Some(ListEntry::ProjectHeader { .. })
2198 )
2199 }),
2200 None => None,
2201 };
2202
2203 if let Some(header_ix) = header_ix {
2204 if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2205 self.contents.entries.get(header_ix)
2206 {
2207 let path_list = path_list.clone();
2208 if self.collapsed_groups.contains(&path_list) {
2209 self.collapsed_groups.remove(&path_list);
2210 } else {
2211 self.selection = Some(header_ix);
2212 self.collapsed_groups.insert(path_list);
2213 }
2214 self.update_entries(cx);
2215 }
2216 }
2217 }
2218
2219 fn fold_all(
2220 &mut self,
2221 _: &editor::actions::FoldAll,
2222 _window: &mut Window,
2223 cx: &mut Context<Self>,
2224 ) {
2225 for entry in &self.contents.entries {
2226 if let ListEntry::ProjectHeader { path_list, .. } = entry {
2227 self.collapsed_groups.insert(path_list.clone());
2228 }
2229 }
2230 self.update_entries(cx);
2231 }
2232
2233 fn unfold_all(
2234 &mut self,
2235 _: &editor::actions::UnfoldAll,
2236 _window: &mut Window,
2237 cx: &mut Context<Self>,
2238 ) {
2239 self.collapsed_groups.clear();
2240 self.update_entries(cx);
2241 }
2242
2243 fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2244 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2245 return;
2246 };
2247
2248 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
2249 for workspace in workspaces {
2250 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2251 let cancelled =
2252 agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2253 if cancelled {
2254 return;
2255 }
2256 }
2257 }
2258 }
2259
2260 fn archive_thread(
2261 &mut self,
2262 session_id: &acp::SessionId,
2263 window: &mut Window,
2264 cx: &mut Context<Self>,
2265 ) {
2266 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx));
2267
2268 // If we're archiving the currently focused thread, move focus to the
2269 // nearest thread within the same project group. We never cross group
2270 // boundaries — if the group has no other threads, clear focus and open
2271 // a blank new thread in the panel instead.
2272 if self.focused_thread.as_ref() == Some(session_id) {
2273 let current_pos = self.contents.entries.iter().position(|entry| {
2274 matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id)
2275 });
2276
2277 // Find the workspace that owns this thread's project group by
2278 // walking backwards to the nearest ProjectHeader. We must use
2279 // *this* workspace (not the active workspace) because the user
2280 // might be archiving a thread in a non-active group.
2281 let group_workspace = current_pos.and_then(|pos| {
2282 self.contents.entries[..pos]
2283 .iter()
2284 .rev()
2285 .find_map(|e| match e {
2286 ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2287 _ => None,
2288 })
2289 });
2290
2291 let next_thread = current_pos.and_then(|pos| {
2292 let group_start = self.contents.entries[..pos]
2293 .iter()
2294 .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2295 .map_or(0, |i| i + 1);
2296 let group_end = self.contents.entries[pos + 1..]
2297 .iter()
2298 .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2299 .map_or(self.contents.entries.len(), |i| pos + 1 + i);
2300
2301 let above = self.contents.entries[group_start..pos]
2302 .iter()
2303 .rev()
2304 .find_map(|entry| {
2305 if let ListEntry::Thread(t) = entry {
2306 Some(t)
2307 } else {
2308 None
2309 }
2310 });
2311
2312 above.or_else(|| {
2313 self.contents.entries[pos + 1..group_end]
2314 .iter()
2315 .find_map(|entry| {
2316 if let ListEntry::Thread(t) = entry {
2317 Some(t)
2318 } else {
2319 None
2320 }
2321 })
2322 })
2323 });
2324
2325 if let Some(next) = next_thread {
2326 let next_metadata = next.metadata.clone();
2327 // Use the thread's own workspace when it has one open (e.g. an absorbed
2328 // linked worktree thread that appears under the main workspace's header
2329 // but belongs to its own workspace). Loading into the wrong panel binds
2330 // the thread to the wrong project, which corrupts its stored folder_paths
2331 // when metadata is saved via ThreadMetadata::from_thread.
2332 let target_workspace = match &next.workspace {
2333 ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
2334 ThreadEntryWorkspace::Closed(_) => group_workspace,
2335 };
2336 self.focused_thread = Some(next_metadata.session_id.clone());
2337 self.record_thread_access(&next_metadata.session_id);
2338
2339 if let Some(workspace) = target_workspace {
2340 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2341 agent_panel.update(cx, |panel, cx| {
2342 panel.load_agent_thread(
2343 Agent::from(next_metadata.agent_id.clone()),
2344 next_metadata.session_id.clone(),
2345 Some(next_metadata.folder_paths.clone()),
2346 Some(next_metadata.title.clone()),
2347 true,
2348 window,
2349 cx,
2350 );
2351 });
2352 }
2353 }
2354 } else {
2355 self.focused_thread = None;
2356 if let Some(workspace) = &group_workspace {
2357 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2358 agent_panel.update(cx, |panel, cx| {
2359 panel.new_thread(&NewThread, window, cx);
2360 });
2361 }
2362 }
2363 }
2364 }
2365 }
2366
2367 fn remove_selected_thread(
2368 &mut self,
2369 _: &RemoveSelectedThread,
2370 window: &mut Window,
2371 cx: &mut Context<Self>,
2372 ) {
2373 let Some(ix) = self.selection else {
2374 return;
2375 };
2376 let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
2377 return;
2378 };
2379 match thread.status {
2380 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => return,
2381 AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
2382 }
2383
2384 let session_id = thread.metadata.session_id.clone();
2385 self.archive_thread(&session_id, window, cx)
2386 }
2387
2388 fn record_thread_access(&mut self, session_id: &acp::SessionId) {
2389 self.thread_last_accessed
2390 .insert(session_id.clone(), Utc::now());
2391 }
2392
2393 fn record_thread_message_sent(&mut self, session_id: &acp::SessionId) {
2394 self.thread_last_message_sent_or_queued
2395 .insert(session_id.clone(), Utc::now());
2396 }
2397
2398 fn mru_threads_for_switcher(&self, _cx: &App) -> Vec<ThreadSwitcherEntry> {
2399 let mut current_header_workspace: Option<Entity<Workspace>> = None;
2400 let mut entries: Vec<ThreadSwitcherEntry> = self
2401 .contents
2402 .entries
2403 .iter()
2404 .filter_map(|entry| match entry {
2405 ListEntry::ProjectHeader { workspace, .. } => {
2406 current_header_workspace = Some(workspace.clone());
2407 None
2408 }
2409 ListEntry::Thread(thread) => {
2410 let workspace = match &thread.workspace {
2411 ThreadEntryWorkspace::Open(workspace) => workspace.clone(),
2412 ThreadEntryWorkspace::Closed(_) => {
2413 current_header_workspace.as_ref()?.clone()
2414 }
2415 };
2416 let notified = self
2417 .contents
2418 .is_thread_notified(&thread.metadata.session_id);
2419 let timestamp: SharedString = format_history_entry_timestamp(
2420 self.thread_last_message_sent_or_queued
2421 .get(&thread.metadata.session_id)
2422 .copied()
2423 .or(thread.metadata.created_at)
2424 .unwrap_or(thread.metadata.updated_at),
2425 )
2426 .into();
2427 Some(ThreadSwitcherEntry {
2428 session_id: thread.metadata.session_id.clone(),
2429 title: thread.metadata.title.clone(),
2430 icon: thread.icon,
2431 icon_from_external_svg: thread.icon_from_external_svg.clone(),
2432 status: thread.status,
2433 metadata: thread.metadata.clone(),
2434 workspace,
2435 worktree_name: thread.worktrees.first().map(|wt| wt.name.clone()),
2436
2437 diff_stats: thread.diff_stats,
2438 is_title_generating: thread.is_title_generating,
2439 notified,
2440 timestamp,
2441 })
2442 }
2443 _ => None,
2444 })
2445 .collect();
2446
2447 entries.sort_by(|a, b| {
2448 let a_accessed = self.thread_last_accessed.get(&a.session_id);
2449 let b_accessed = self.thread_last_accessed.get(&b.session_id);
2450
2451 match (a_accessed, b_accessed) {
2452 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
2453 (Some(_), None) => std::cmp::Ordering::Less,
2454 (None, Some(_)) => std::cmp::Ordering::Greater,
2455 (None, None) => {
2456 let a_sent = self.thread_last_message_sent_or_queued.get(&a.session_id);
2457 let b_sent = self.thread_last_message_sent_or_queued.get(&b.session_id);
2458
2459 match (a_sent, b_sent) {
2460 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
2461 (Some(_), None) => std::cmp::Ordering::Less,
2462 (None, Some(_)) => std::cmp::Ordering::Greater,
2463 (None, None) => {
2464 let a_time = a.metadata.created_at.or(Some(a.metadata.updated_at));
2465 let b_time = b.metadata.created_at.or(Some(b.metadata.updated_at));
2466 b_time.cmp(&a_time)
2467 }
2468 }
2469 }
2470 }
2471 });
2472
2473 entries
2474 }
2475
2476 fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
2477 self.thread_switcher = None;
2478 self._thread_switcher_subscriptions.clear();
2479 if let Some(mw) = self.multi_workspace.upgrade() {
2480 mw.update(cx, |mw, cx| {
2481 mw.set_sidebar_overlay(None, cx);
2482 });
2483 }
2484 }
2485
2486 fn on_toggle_thread_switcher(
2487 &mut self,
2488 action: &ToggleThreadSwitcher,
2489 window: &mut Window,
2490 cx: &mut Context<Self>,
2491 ) {
2492 self.toggle_thread_switcher_impl(action.select_last, window, cx);
2493 }
2494
2495 fn toggle_thread_switcher_impl(
2496 &mut self,
2497 select_last: bool,
2498 window: &mut Window,
2499 cx: &mut Context<Self>,
2500 ) {
2501 if let Some(thread_switcher) = &self.thread_switcher {
2502 thread_switcher.update(cx, |switcher, cx| {
2503 if select_last {
2504 switcher.select_last(cx);
2505 } else {
2506 switcher.cycle_selection(cx);
2507 }
2508 });
2509 return;
2510 }
2511
2512 let entries = self.mru_threads_for_switcher(cx);
2513 if entries.len() < 2 {
2514 return;
2515 }
2516
2517 let weak_multi_workspace = self.multi_workspace.clone();
2518
2519 let original_metadata = self
2520 .focused_thread
2521 .as_ref()
2522 .and_then(|focused_id| entries.iter().find(|e| &e.session_id == focused_id))
2523 .map(|e| e.metadata.clone());
2524 let original_workspace = self
2525 .multi_workspace
2526 .upgrade()
2527 .map(|mw| mw.read(cx).workspace().clone());
2528
2529 let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
2530
2531 let mut subscriptions = Vec::new();
2532
2533 subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
2534 let thread_switcher = thread_switcher.clone();
2535 move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
2536 ThreadSwitcherEvent::Preview {
2537 metadata,
2538 workspace,
2539 } => {
2540 if let Some(mw) = weak_multi_workspace.upgrade() {
2541 mw.update(cx, |mw, cx| {
2542 mw.activate(workspace.clone(), window, cx);
2543 });
2544 }
2545 this.focused_thread = Some(metadata.session_id.clone());
2546 this.update_entries(cx);
2547 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
2548 let focus = thread_switcher.focus_handle(cx);
2549 window.focus(&focus, cx);
2550 }
2551 ThreadSwitcherEvent::Confirmed {
2552 metadata,
2553 workspace,
2554 } => {
2555 if let Some(mw) = weak_multi_workspace.upgrade() {
2556 mw.update(cx, |mw, cx| {
2557 mw.activate(workspace.clone(), window, cx);
2558 });
2559 }
2560 this.record_thread_access(&metadata.session_id);
2561 this.focused_thread = Some(metadata.session_id.clone());
2562 this.update_entries(cx);
2563 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
2564 this.dismiss_thread_switcher(cx);
2565 workspace.update(cx, |workspace, cx| {
2566 workspace.focus_panel::<AgentPanel>(window, cx);
2567 });
2568 }
2569 ThreadSwitcherEvent::Dismissed => {
2570 if let Some(mw) = weak_multi_workspace.upgrade() {
2571 if let Some(original_ws) = &original_workspace {
2572 mw.update(cx, |mw, cx| {
2573 mw.activate(original_ws.clone(), window, cx);
2574 });
2575 }
2576 }
2577 if let Some(metadata) = &original_metadata {
2578 this.focused_thread = Some(metadata.session_id.clone());
2579 this.update_entries(cx);
2580 if let Some(original_ws) = &original_workspace {
2581 Self::load_agent_thread_in_workspace(
2582 original_ws,
2583 metadata,
2584 false,
2585 window,
2586 cx,
2587 );
2588 }
2589 }
2590 this.dismiss_thread_switcher(cx);
2591 }
2592 }
2593 }));
2594
2595 subscriptions.push(cx.subscribe_in(
2596 &thread_switcher,
2597 window,
2598 |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
2599 this.dismiss_thread_switcher(cx);
2600 },
2601 ));
2602
2603 let focus = thread_switcher.focus_handle(cx);
2604 let overlay_view = gpui::AnyView::from(thread_switcher.clone());
2605
2606 // Replay the initial preview that was emitted during construction
2607 // before subscriptions were wired up.
2608 let initial_preview = thread_switcher
2609 .read(cx)
2610 .selected_entry()
2611 .map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
2612
2613 self.thread_switcher = Some(thread_switcher);
2614 self._thread_switcher_subscriptions = subscriptions;
2615 if let Some(mw) = self.multi_workspace.upgrade() {
2616 mw.update(cx, |mw, cx| {
2617 mw.set_sidebar_overlay(Some(overlay_view), cx);
2618 });
2619 }
2620
2621 if let Some((metadata, workspace)) = initial_preview {
2622 if let Some(mw) = self.multi_workspace.upgrade() {
2623 mw.update(cx, |mw, cx| {
2624 mw.activate(workspace.clone(), window, cx);
2625 });
2626 }
2627 self.focused_thread = Some(metadata.session_id.clone());
2628 self.update_entries(cx);
2629 Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
2630 }
2631
2632 window.focus(&focus, cx);
2633 }
2634
2635 fn render_thread(
2636 &self,
2637 ix: usize,
2638 thread: &ThreadEntry,
2639 is_focused: bool,
2640 cx: &mut Context<Self>,
2641 ) -> AnyElement {
2642 let has_notification = self
2643 .contents
2644 .is_thread_notified(&thread.metadata.session_id);
2645
2646 let title: SharedString = thread.metadata.title.clone();
2647 let metadata = thread.metadata.clone();
2648 let thread_workspace = thread.workspace.clone();
2649
2650 let is_hovered = self.hovered_thread_index == Some(ix);
2651 let is_selected = self.agent_panel_visible(cx)
2652 && self.focused_thread.as_ref() == Some(&metadata.session_id);
2653 let is_running = matches!(
2654 thread.status,
2655 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
2656 );
2657
2658 let session_id_for_delete = thread.metadata.session_id.clone();
2659 let focus_handle = self.focus_handle.clone();
2660
2661 let id = SharedString::from(format!("thread-entry-{}", ix));
2662
2663 let timestamp = format_history_entry_timestamp(
2664 self.thread_last_message_sent_or_queued
2665 .get(&thread.metadata.session_id)
2666 .copied()
2667 .or(thread.metadata.created_at)
2668 .unwrap_or(thread.metadata.updated_at),
2669 );
2670
2671 ThreadItem::new(id, title)
2672 .icon(thread.icon)
2673 .status(thread.status)
2674 .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
2675 this.custom_icon_from_external_svg(svg)
2676 })
2677 .worktrees(
2678 thread
2679 .worktrees
2680 .iter()
2681 .map(|wt| ThreadItemWorktreeInfo {
2682 name: wt.name.clone(),
2683 full_path: wt.full_path.clone(),
2684 highlight_positions: wt.highlight_positions.clone(),
2685 })
2686 .collect(),
2687 )
2688 .timestamp(timestamp)
2689 .highlight_positions(thread.highlight_positions.to_vec())
2690 .title_generating(thread.is_title_generating)
2691 .notified(has_notification)
2692 .when(thread.diff_stats.lines_added > 0, |this| {
2693 this.added(thread.diff_stats.lines_added as usize)
2694 })
2695 .when(thread.diff_stats.lines_removed > 0, |this| {
2696 this.removed(thread.diff_stats.lines_removed as usize)
2697 })
2698 .selected(is_selected)
2699 .focused(is_focused)
2700 .hovered(is_hovered)
2701 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
2702 if *is_hovered {
2703 this.hovered_thread_index = Some(ix);
2704 } else if this.hovered_thread_index == Some(ix) {
2705 this.hovered_thread_index = None;
2706 }
2707 cx.notify();
2708 }))
2709 .when(is_hovered && is_running, |this| {
2710 this.action_slot(
2711 IconButton::new("stop-thread", IconName::Stop)
2712 .icon_size(IconSize::Small)
2713 .icon_color(Color::Error)
2714 .style(ButtonStyle::Tinted(TintColor::Error))
2715 .tooltip(Tooltip::text("Stop Generation"))
2716 .on_click({
2717 let session_id = session_id_for_delete.clone();
2718 cx.listener(move |this, _, _window, cx| {
2719 this.stop_thread(&session_id, cx);
2720 })
2721 }),
2722 )
2723 })
2724 .when(is_hovered && !is_running, |this| {
2725 this.action_slot(
2726 IconButton::new("archive-thread", IconName::Archive)
2727 .icon_size(IconSize::Small)
2728 .icon_color(Color::Muted)
2729 .tooltip({
2730 let focus_handle = focus_handle.clone();
2731 move |_window, cx| {
2732 Tooltip::for_action_in(
2733 "Archive Thread",
2734 &RemoveSelectedThread,
2735 &focus_handle,
2736 cx,
2737 )
2738 }
2739 })
2740 .on_click({
2741 let session_id = session_id_for_delete.clone();
2742 cx.listener(move |this, _, window, cx| {
2743 this.archive_thread(&session_id, window, cx);
2744 })
2745 }),
2746 )
2747 })
2748 .on_click({
2749 cx.listener(move |this, _, window, cx| {
2750 this.selection = None;
2751 match &thread_workspace {
2752 ThreadEntryWorkspace::Open(workspace) => {
2753 this.activate_thread(metadata.clone(), workspace, window, cx);
2754 }
2755 ThreadEntryWorkspace::Closed(path_list) => {
2756 this.open_workspace_and_activate_thread(
2757 metadata.clone(),
2758 path_list.clone(),
2759 window,
2760 cx,
2761 );
2762 }
2763 }
2764 })
2765 })
2766 .into_any_element()
2767 }
2768
2769 fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
2770 div()
2771 .min_w_0()
2772 .flex_1()
2773 .capture_action(
2774 cx.listener(|this, _: &editor::actions::Newline, window, cx| {
2775 this.editor_confirm(window, cx);
2776 }),
2777 )
2778 .child(self.filter_editor.clone())
2779 }
2780
2781 fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
2782 let multi_workspace = self.multi_workspace.upgrade();
2783
2784 let workspace = multi_workspace
2785 .as_ref()
2786 .map(|mw| mw.read(cx).workspace().downgrade());
2787
2788 let focus_handle = workspace
2789 .as_ref()
2790 .and_then(|ws| ws.upgrade())
2791 .map(|w| w.read(cx).focus_handle(cx))
2792 .unwrap_or_else(|| cx.focus_handle());
2793
2794 let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
2795 .as_ref()
2796 .map(|mw| {
2797 mw.read(cx)
2798 .workspaces()
2799 .iter()
2800 .filter_map(|ws| ws.read(cx).database_id())
2801 .collect()
2802 })
2803 .unwrap_or_default();
2804
2805 let popover_handle = self.recent_projects_popover_handle.clone();
2806
2807 PopoverMenu::new("sidebar-recent-projects-menu")
2808 .with_handle(popover_handle)
2809 .menu(move |window, cx| {
2810 workspace.as_ref().map(|ws| {
2811 SidebarRecentProjects::popover(
2812 ws.clone(),
2813 sibling_workspace_ids.clone(),
2814 focus_handle.clone(),
2815 window,
2816 cx,
2817 )
2818 })
2819 })
2820 .trigger_with_tooltip(
2821 IconButton::new("open-project", IconName::OpenFolder)
2822 .icon_size(IconSize::Small)
2823 .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
2824 |_window, cx| {
2825 Tooltip::for_action(
2826 "Add Project",
2827 &OpenRecent {
2828 create_new_window: false,
2829 },
2830 cx,
2831 )
2832 },
2833 )
2834 .offset(gpui::Point {
2835 x: px(-2.0),
2836 y: px(-2.0),
2837 })
2838 .anchor(gpui::Corner::BottomRight)
2839 }
2840
2841 fn render_view_more(
2842 &self,
2843 ix: usize,
2844 path_list: &PathList,
2845 is_fully_expanded: bool,
2846 is_selected: bool,
2847 cx: &mut Context<Self>,
2848 ) -> AnyElement {
2849 let path_list = path_list.clone();
2850 let id = SharedString::from(format!("view-more-{}", ix));
2851
2852 let label: SharedString = if is_fully_expanded {
2853 "Collapse".into()
2854 } else {
2855 "View More".into()
2856 };
2857
2858 ThreadItem::new(id, label)
2859 .focused(is_selected)
2860 .icon_visible(false)
2861 .title_label_color(Color::Muted)
2862 .on_click(cx.listener(move |this, _, _window, cx| {
2863 this.selection = None;
2864 if is_fully_expanded {
2865 this.expanded_groups.remove(&path_list);
2866 } else {
2867 let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
2868 this.expanded_groups.insert(path_list.clone(), current + 1);
2869 }
2870 this.update_entries(cx);
2871 }))
2872 .into_any_element()
2873 }
2874
2875 fn new_thread_in_group(
2876 &mut self,
2877 _: &NewThreadInGroup,
2878 window: &mut Window,
2879 cx: &mut Context<Self>,
2880 ) {
2881 // If there is a keyboard selection, walk backwards through
2882 // `project_header_indices` to find the header that owns the selected
2883 // row. Otherwise fall back to the active workspace.
2884 let workspace = if let Some(selected_ix) = self.selection {
2885 self.contents
2886 .project_header_indices
2887 .iter()
2888 .rev()
2889 .find(|&&header_ix| header_ix <= selected_ix)
2890 .and_then(|&header_ix| match &self.contents.entries[header_ix] {
2891 ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2892 _ => None,
2893 })
2894 } else {
2895 // Use the currently active workspace.
2896 self.multi_workspace
2897 .upgrade()
2898 .map(|mw| mw.read(cx).workspace().clone())
2899 };
2900
2901 let Some(workspace) = workspace else {
2902 return;
2903 };
2904
2905 self.create_new_thread(&workspace, window, cx);
2906 }
2907
2908 fn create_new_thread(
2909 &mut self,
2910 workspace: &Entity<Workspace>,
2911 window: &mut Window,
2912 cx: &mut Context<Self>,
2913 ) {
2914 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2915 return;
2916 };
2917
2918 // Clear focused_thread immediately so no existing thread stays
2919 // highlighted while the new blank thread is being shown. Without this,
2920 // if the target workspace is already active (so ActiveWorkspaceChanged
2921 // never fires), the previous thread's highlight would linger.
2922 self.focused_thread = None;
2923
2924 multi_workspace.update(cx, |multi_workspace, cx| {
2925 multi_workspace.activate(workspace.clone(), window, cx);
2926 });
2927
2928 workspace.update(cx, |workspace, cx| {
2929 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
2930 agent_panel.update(cx, |panel, cx| {
2931 panel.new_thread(&NewThread, window, cx);
2932 });
2933 }
2934 workspace.focus_panel::<AgentPanel>(window, cx);
2935 });
2936 }
2937
2938 fn render_new_thread(
2939 &self,
2940 ix: usize,
2941 _path_list: &PathList,
2942 workspace: &Entity<Workspace>,
2943 is_active_draft: bool,
2944 worktrees: &[WorktreeInfo],
2945 is_selected: bool,
2946 cx: &mut Context<Self>,
2947 ) -> AnyElement {
2948 let is_active =
2949 is_active_draft && self.agent_panel_visible(cx) && self.active_thread_is_draft(cx);
2950
2951 let label: SharedString = if is_active {
2952 self.active_draft_text(cx)
2953 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
2954 } else {
2955 DEFAULT_THREAD_TITLE.into()
2956 };
2957
2958 let workspace = workspace.clone();
2959 let id = SharedString::from(format!("new-thread-btn-{}", ix));
2960
2961 let thread_item = ThreadItem::new(id, label)
2962 .icon(IconName::Plus)
2963 .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
2964 .worktrees(
2965 worktrees
2966 .iter()
2967 .map(|wt| ThreadItemWorktreeInfo {
2968 name: wt.name.clone(),
2969 full_path: wt.full_path.clone(),
2970 highlight_positions: wt.highlight_positions.clone(),
2971 })
2972 .collect(),
2973 )
2974 .selected(is_active)
2975 .focused(is_selected)
2976 .when(!is_active, |this| {
2977 this.on_click(cx.listener(move |this, _, window, cx| {
2978 this.selection = None;
2979 this.create_new_thread(&workspace, window, cx);
2980 }))
2981 });
2982
2983 if is_active {
2984 div()
2985 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
2986 cx.stop_propagation();
2987 })
2988 .child(thread_item)
2989 .into_any_element()
2990 } else {
2991 thread_item.into_any_element()
2992 }
2993 }
2994
2995 fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
2996 let has_query = self.has_filter_query(cx);
2997 let message = if has_query {
2998 "No threads match your search."
2999 } else {
3000 "No threads yet"
3001 };
3002
3003 v_flex()
3004 .id("sidebar-no-results")
3005 .p_4()
3006 .size_full()
3007 .items_center()
3008 .justify_center()
3009 .child(
3010 Label::new(message)
3011 .size(LabelSize::Small)
3012 .color(Color::Muted),
3013 )
3014 }
3015
3016 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
3017 v_flex()
3018 .id("sidebar-empty-state")
3019 .p_4()
3020 .size_full()
3021 .items_center()
3022 .justify_center()
3023 .gap_1()
3024 .track_focus(&self.focus_handle(cx))
3025 .child(
3026 Button::new("open_project", "Open Project")
3027 .full_width()
3028 .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
3029 .on_click(|_, window, cx| {
3030 window.dispatch_action(
3031 Open {
3032 create_new_window: false,
3033 }
3034 .boxed_clone(),
3035 cx,
3036 );
3037 }),
3038 )
3039 .child(
3040 h_flex()
3041 .w_1_2()
3042 .gap_2()
3043 .child(Divider::horizontal())
3044 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
3045 .child(Divider::horizontal()),
3046 )
3047 .child(
3048 Button::new("clone_repo", "Clone Repository")
3049 .full_width()
3050 .on_click(|_, window, cx| {
3051 window.dispatch_action(git::Clone.boxed_clone(), cx);
3052 }),
3053 )
3054 }
3055
3056 fn render_sidebar_header(
3057 &self,
3058 no_open_projects: bool,
3059 window: &Window,
3060 cx: &mut Context<Self>,
3061 ) -> impl IntoElement {
3062 let has_query = self.has_filter_query(cx);
3063 let sidebar_on_left = self.side(cx) == SidebarSide::Left;
3064 let sidebar_on_right = self.side(cx) == SidebarSide::Right;
3065 let not_fullscreen = !window.is_fullscreen();
3066 let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
3067 let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
3068 let right_window_controls =
3069 !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
3070 let header_height = platform_title_bar_height(window);
3071
3072 h_flex()
3073 .h(header_height)
3074 .mt_px()
3075 .pb_px()
3076 .when(left_window_controls, |this| {
3077 this.children(Self::render_left_window_controls(window, cx))
3078 })
3079 .map(|this| {
3080 if traffic_lights {
3081 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
3082 } else if !left_window_controls {
3083 this.pl_1p5()
3084 } else {
3085 this
3086 }
3087 })
3088 .when(!right_window_controls, |this| this.pr_1p5())
3089 .gap_1()
3090 .when(!no_open_projects, |this| {
3091 this.border_b_1()
3092 .border_color(cx.theme().colors().border)
3093 .when(traffic_lights, |this| {
3094 this.child(Divider::vertical().color(ui::DividerColor::Border))
3095 })
3096 .child(
3097 div().ml_1().child(
3098 Icon::new(IconName::MagnifyingGlass)
3099 .size(IconSize::Small)
3100 .color(Color::Muted),
3101 ),
3102 )
3103 .child(self.render_filter_input(cx))
3104 .child(
3105 h_flex()
3106 .gap_1()
3107 .when(
3108 self.selection.is_some()
3109 && !self.filter_editor.focus_handle(cx).is_focused(window),
3110 |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
3111 )
3112 .when(has_query, |this| {
3113 this.child(
3114 IconButton::new("clear_filter", IconName::Close)
3115 .icon_size(IconSize::Small)
3116 .tooltip(Tooltip::text("Clear Search"))
3117 .on_click(cx.listener(|this, _, window, cx| {
3118 this.reset_filter_editor_text(window, cx);
3119 this.update_entries(cx);
3120 })),
3121 )
3122 }),
3123 )
3124 })
3125 .when(right_window_controls, |this| {
3126 this.children(Self::render_right_window_controls(window, cx))
3127 })
3128 }
3129
3130 fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
3131 platform_title_bar::render_left_window_controls(
3132 cx.button_layout(),
3133 Box::new(CloseWindow),
3134 window,
3135 )
3136 }
3137
3138 fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
3139 platform_title_bar::render_right_window_controls(
3140 cx.button_layout(),
3141 Box::new(CloseWindow),
3142 window,
3143 )
3144 }
3145
3146 fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
3147 let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
3148
3149 sidebar_side_context_menu("sidebar-toggle-menu", _cx)
3150 .anchor(if on_right {
3151 gpui::Corner::BottomRight
3152 } else {
3153 gpui::Corner::BottomLeft
3154 })
3155 .attach(if on_right {
3156 gpui::Corner::TopRight
3157 } else {
3158 gpui::Corner::TopLeft
3159 })
3160 .trigger(move |_is_active, _window, _cx| {
3161 let icon = if on_right {
3162 IconName::ThreadsSidebarRightOpen
3163 } else {
3164 IconName::ThreadsSidebarLeftOpen
3165 };
3166 IconButton::new("sidebar-close-toggle", icon)
3167 .icon_size(IconSize::Small)
3168 .tooltip(Tooltip::element(move |_window, cx| {
3169 v_flex()
3170 .gap_1()
3171 .child(
3172 h_flex()
3173 .gap_2()
3174 .justify_between()
3175 .child(Label::new("Toggle Sidebar"))
3176 .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
3177 )
3178 .child(
3179 h_flex()
3180 .pt_1()
3181 .gap_2()
3182 .border_t_1()
3183 .border_color(cx.theme().colors().border_variant)
3184 .justify_between()
3185 .child(Label::new("Focus Sidebar"))
3186 .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
3187 )
3188 .into_any_element()
3189 }))
3190 .on_click(|_, window, cx| {
3191 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
3192 multi_workspace.update(cx, |multi_workspace, cx| {
3193 multi_workspace.close_sidebar(window, cx);
3194 });
3195 }
3196 })
3197 })
3198 }
3199
3200 fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
3201 let on_right = self.side(cx) == SidebarSide::Right;
3202 let is_archive = matches!(self.view, SidebarView::Archive(..));
3203 let action_buttons = h_flex()
3204 .gap_1()
3205 .child(
3206 IconButton::new("archive", IconName::Archive)
3207 .icon_size(IconSize::Small)
3208 .toggle_state(is_archive)
3209 .tooltip(move |_, cx| {
3210 Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
3211 })
3212 .on_click(cx.listener(|this, _, window, cx| {
3213 this.toggle_archive(&ToggleArchive, window, cx);
3214 })),
3215 )
3216 .child(self.render_recent_projects_button(cx));
3217 let border_color = cx.theme().colors().border;
3218 let toggle_button = self.render_sidebar_toggle_button(cx);
3219
3220 let bar = h_flex()
3221 .p_1()
3222 .gap_1()
3223 .justify_between()
3224 .border_t_1()
3225 .border_color(border_color);
3226
3227 if on_right {
3228 bar.child(action_buttons).child(toggle_button)
3229 } else {
3230 bar.child(toggle_button).child(action_buttons)
3231 }
3232 }
3233
3234 fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
3235 match &self.view {
3236 SidebarView::ThreadList => self.show_archive(window, cx),
3237 SidebarView::Archive(_) => self.show_thread_list(window, cx),
3238 }
3239 }
3240
3241 fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3242 let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
3243 w.read(cx)
3244 .workspaces()
3245 .get(w.read(cx).active_workspace_index())
3246 .cloned()
3247 }) else {
3248 return;
3249 };
3250 let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
3251 return;
3252 };
3253 let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
3254 return;
3255 };
3256
3257 let agent_server_store = active_workspace
3258 .read(cx)
3259 .project()
3260 .read(cx)
3261 .agent_server_store()
3262 .downgrade();
3263
3264 let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
3265
3266 let archive_view = cx.new(|cx| {
3267 ThreadsArchiveView::new(
3268 agent_connection_store.clone(),
3269 agent_server_store.clone(),
3270 agent_registry_store.downgrade(),
3271 active_workspace.downgrade(),
3272 self.multi_workspace.clone(),
3273 window,
3274 cx,
3275 )
3276 });
3277
3278 let subscription = cx.subscribe_in(
3279 &archive_view,
3280 window,
3281 |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
3282 ThreadsArchiveViewEvent::Close => {
3283 this.show_thread_list(window, cx);
3284 }
3285 ThreadsArchiveViewEvent::Unarchive { thread } => {
3286 this.show_thread_list(window, cx);
3287 this.activate_archived_thread(thread.clone(), window, cx);
3288 }
3289 },
3290 );
3291
3292 self._subscriptions.push(subscription);
3293 self.view = SidebarView::Archive(archive_view.clone());
3294 archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
3295 cx.notify();
3296 }
3297
3298 fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3299 self.view = SidebarView::ThreadList;
3300 self._subscriptions.clear();
3301 let handle = self.filter_editor.read(cx).focus_handle(cx);
3302 handle.focus(window, cx);
3303 cx.notify();
3304 }
3305}
3306
3307impl WorkspaceSidebar for Sidebar {
3308 fn width(&self, _cx: &App) -> Pixels {
3309 self.width
3310 }
3311
3312 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
3313 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
3314 cx.notify();
3315 }
3316
3317 fn has_notifications(&self, _cx: &App) -> bool {
3318 !self.contents.notified_threads.is_empty()
3319 }
3320
3321 fn is_threads_list_view_active(&self) -> bool {
3322 matches!(self.view, SidebarView::ThreadList)
3323 }
3324
3325 fn side(&self, cx: &App) -> SidebarSide {
3326 AgentSettings::get_global(cx).sidebar_side()
3327 }
3328
3329 fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3330 self.selection = None;
3331 cx.notify();
3332 }
3333
3334 fn toggle_thread_switcher(
3335 &mut self,
3336 select_last: bool,
3337 window: &mut Window,
3338 cx: &mut Context<Self>,
3339 ) {
3340 self.toggle_thread_switcher_impl(select_last, window, cx);
3341 }
3342}
3343
3344impl Focusable for Sidebar {
3345 fn focus_handle(&self, _cx: &App) -> FocusHandle {
3346 self.focus_handle.clone()
3347 }
3348}
3349
3350impl Render for Sidebar {
3351 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3352 let _titlebar_height = ui::utils::platform_title_bar_height(window);
3353 let ui_font = theme_settings::setup_ui_font(window, cx);
3354 let sticky_header = self.render_sticky_header(window, cx);
3355
3356 let color = cx.theme().colors();
3357 let bg = color
3358 .title_bar_background
3359 .blend(color.panel_background.opacity(0.32));
3360
3361 let no_open_projects = !self.contents.has_open_projects;
3362 let no_search_results = self.contents.entries.is_empty();
3363
3364 v_flex()
3365 .id("workspace-sidebar")
3366 .key_context(self.dispatch_context(window, cx))
3367 .track_focus(&self.focus_handle)
3368 .on_action(cx.listener(Self::select_next))
3369 .on_action(cx.listener(Self::select_previous))
3370 .on_action(cx.listener(Self::editor_move_down))
3371 .on_action(cx.listener(Self::editor_move_up))
3372 .on_action(cx.listener(Self::select_first))
3373 .on_action(cx.listener(Self::select_last))
3374 .on_action(cx.listener(Self::confirm))
3375 .on_action(cx.listener(Self::expand_selected_entry))
3376 .on_action(cx.listener(Self::collapse_selected_entry))
3377 .on_action(cx.listener(Self::toggle_selected_fold))
3378 .on_action(cx.listener(Self::fold_all))
3379 .on_action(cx.listener(Self::unfold_all))
3380 .on_action(cx.listener(Self::cancel))
3381 .on_action(cx.listener(Self::remove_selected_thread))
3382 .on_action(cx.listener(Self::new_thread_in_group))
3383 .on_action(cx.listener(Self::toggle_archive))
3384 .on_action(cx.listener(Self::focus_sidebar_filter))
3385 .on_action(cx.listener(Self::on_toggle_thread_switcher))
3386 .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
3387 this.recent_projects_popover_handle.toggle(window, cx);
3388 }))
3389 .font(ui_font)
3390 .h_full()
3391 .w(self.width)
3392 .bg(bg)
3393 .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
3394 .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
3395 .border_color(color.border)
3396 .map(|this| match &self.view {
3397 SidebarView::ThreadList => this
3398 .child(self.render_sidebar_header(no_open_projects, window, cx))
3399 .map(|this| {
3400 if no_open_projects {
3401 this.child(self.render_empty_state(cx))
3402 } else {
3403 this.child(
3404 v_flex()
3405 .relative()
3406 .flex_1()
3407 .overflow_hidden()
3408 .child(
3409 list(
3410 self.list_state.clone(),
3411 cx.processor(Self::render_list_entry),
3412 )
3413 .flex_1()
3414 .size_full(),
3415 )
3416 .when(no_search_results, |this| {
3417 this.child(self.render_no_results(cx))
3418 })
3419 .when_some(sticky_header, |this, header| this.child(header))
3420 .vertical_scrollbar_for(&self.list_state, window, cx),
3421 )
3422 }
3423 }),
3424 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
3425 })
3426 .child(self.render_sidebar_bottom_bar(cx))
3427 }
3428}
3429
3430fn all_thread_infos_for_workspace(
3431 workspace: &Entity<Workspace>,
3432 cx: &App,
3433) -> impl Iterator<Item = ActiveThreadInfo> {
3434 let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
3435 return None.into_iter().flatten();
3436 };
3437 let agent_panel = agent_panel.read(cx);
3438
3439 let threads = agent_panel
3440 .parent_threads(cx)
3441 .into_iter()
3442 .map(|thread_view| {
3443 let thread_view_ref = thread_view.read(cx);
3444 let thread = thread_view_ref.thread.read(cx);
3445
3446 let icon = thread_view_ref.agent_icon;
3447 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
3448 let title = thread
3449 .title()
3450 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
3451 let is_native = thread_view_ref.as_native_thread(cx).is_some();
3452 let is_title_generating = is_native && thread.has_provisional_title();
3453 let session_id = thread.session_id().clone();
3454 let is_background = agent_panel.is_background_thread(&session_id);
3455
3456 let status = if thread.is_waiting_for_confirmation() {
3457 AgentThreadStatus::WaitingForConfirmation
3458 } else if thread.had_error() {
3459 AgentThreadStatus::Error
3460 } else {
3461 match thread.status() {
3462 ThreadStatus::Generating => AgentThreadStatus::Running,
3463 ThreadStatus::Idle => AgentThreadStatus::Completed,
3464 }
3465 };
3466
3467 let diff_stats = thread.action_log().read(cx).diff_stats(cx);
3468
3469 ActiveThreadInfo {
3470 session_id,
3471 title,
3472 status,
3473 icon,
3474 icon_from_external_svg,
3475 is_background,
3476 is_title_generating,
3477 diff_stats,
3478 }
3479 });
3480
3481 Some(threads).into_iter().flatten()
3482}
3483
3484pub fn dump_workspace_info(
3485 workspace: &mut Workspace,
3486 _: &DumpWorkspaceInfo,
3487 window: &mut gpui::Window,
3488 cx: &mut gpui::Context<Workspace>,
3489) {
3490 use std::fmt::Write;
3491
3492 let mut output = String::new();
3493 let this_entity = cx.entity();
3494
3495 let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
3496 let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
3497 Some(mw) => mw.read(cx).workspaces().to_vec(),
3498 None => vec![this_entity.clone()],
3499 };
3500 let active_index = multi_workspace
3501 .as_ref()
3502 .map(|mw| mw.read(cx).active_workspace_index());
3503
3504 writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
3505 if let Some(index) = active_index {
3506 writeln!(output, "Active workspace index: {index}").ok();
3507 }
3508 writeln!(output).ok();
3509
3510 for (index, ws) in workspaces.iter().enumerate() {
3511 let is_active = active_index == Some(index);
3512 writeln!(
3513 output,
3514 "--- Workspace {index}{} ---",
3515 if is_active { " (active)" } else { "" }
3516 )
3517 .ok();
3518
3519 // The action handler is already inside an update on `this_entity`,
3520 // so we must avoid a nested read/update on that same entity.
3521 if *ws == this_entity {
3522 dump_single_workspace(workspace, &mut output, cx);
3523 } else {
3524 ws.read_with(cx, |ws, cx| {
3525 dump_single_workspace(ws, &mut output, cx);
3526 });
3527 }
3528 }
3529
3530 let project = workspace.project().clone();
3531 cx.spawn_in(window, async move |_this, cx| {
3532 let buffer = project
3533 .update(cx, |project, cx| project.create_buffer(None, false, cx))
3534 .await?;
3535
3536 buffer.update(cx, |buffer, cx| {
3537 buffer.set_text(output, cx);
3538 });
3539
3540 let buffer = cx.new(|cx| {
3541 editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
3542 });
3543
3544 _this.update_in(cx, |workspace, window, cx| {
3545 workspace.add_item_to_active_pane(
3546 Box::new(cx.new(|cx| {
3547 let mut editor =
3548 editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
3549 editor.set_read_only(true);
3550 editor.set_should_serialize(false, cx);
3551 editor.set_breadcrumb_header("Workspace Info".into());
3552 editor
3553 })),
3554 None,
3555 true,
3556 window,
3557 cx,
3558 );
3559 })
3560 })
3561 .detach_and_log_err(cx);
3562}
3563
3564fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
3565 use std::fmt::Write;
3566
3567 let workspace_db_id = workspace.database_id();
3568 match workspace_db_id {
3569 Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
3570 None => writeln!(output, "Workspace DB ID: (none)").ok(),
3571 };
3572
3573 let project = workspace.project().read(cx);
3574
3575 let repos: Vec<_> = project
3576 .repositories(cx)
3577 .values()
3578 .map(|repo| repo.read(cx).snapshot())
3579 .collect();
3580
3581 writeln!(output, "Worktrees:").ok();
3582 for worktree in project.worktrees(cx) {
3583 let worktree = worktree.read(cx);
3584 let abs_path = worktree.abs_path();
3585 let visible = worktree.is_visible();
3586
3587 let repo_info = repos
3588 .iter()
3589 .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
3590
3591 let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
3592 let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
3593 let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
3594
3595 write!(output, " - {}", abs_path.display()).ok();
3596 if !visible {
3597 write!(output, " (hidden)").ok();
3598 }
3599 if let Some(branch) = &branch {
3600 write!(output, " [branch: {branch}]").ok();
3601 }
3602 if is_linked {
3603 if let Some(original) = original_repo_path {
3604 write!(output, " [linked worktree -> {}]", original.display()).ok();
3605 } else {
3606 write!(output, " [linked worktree]").ok();
3607 }
3608 }
3609 writeln!(output).ok();
3610 }
3611
3612 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3613 let panel = panel.read(cx);
3614
3615 let panel_workspace_id = panel.workspace_id();
3616 if panel_workspace_id != workspace_db_id {
3617 writeln!(
3618 output,
3619 " \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
3620 )
3621 .ok();
3622 }
3623
3624 if let Some(thread) = panel.active_agent_thread(cx) {
3625 let thread = thread.read(cx);
3626 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
3627 let session_id = thread.session_id();
3628 let status = match thread.status() {
3629 ThreadStatus::Idle => "idle",
3630 ThreadStatus::Generating => "generating",
3631 };
3632 let entry_count = thread.entries().len();
3633 write!(output, "Active thread: {title} (session: {session_id})").ok();
3634 write!(output, " [{status}, {entry_count} entries").ok();
3635 if thread.is_waiting_for_confirmation() {
3636 write!(output, ", awaiting confirmation").ok();
3637 }
3638 writeln!(output, "]").ok();
3639 } else {
3640 writeln!(output, "Active thread: (none)").ok();
3641 }
3642
3643 let background_threads = panel.background_threads();
3644 if !background_threads.is_empty() {
3645 writeln!(
3646 output,
3647 "Background threads ({}): ",
3648 background_threads.len()
3649 )
3650 .ok();
3651 for (session_id, conversation_view) in background_threads {
3652 if let Some(thread_view) = conversation_view.read(cx).root_thread(cx) {
3653 let thread = thread_view.read(cx).thread.read(cx);
3654 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
3655 let status = match thread.status() {
3656 ThreadStatus::Idle => "idle",
3657 ThreadStatus::Generating => "generating",
3658 };
3659 let entry_count = thread.entries().len();
3660 write!(output, " - {title} (session: {session_id})").ok();
3661 write!(output, " [{status}, {entry_count} entries").ok();
3662 if thread.is_waiting_for_confirmation() {
3663 write!(output, ", awaiting confirmation").ok();
3664 }
3665 writeln!(output, "]").ok();
3666 } else {
3667 writeln!(output, " - (not connected) (session: {session_id})").ok();
3668 }
3669 }
3670 }
3671 } else {
3672 writeln!(output, "Agent panel: not loaded").ok();
3673 }
3674
3675 writeln!(output).ok();
3676}