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::{
8 ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths,
9};
10use agent_ui::thread_worktree_archive;
11use agent_ui::threads_archive_view::{
12 ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
13};
14use agent_ui::{
15 AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, ArchiveSelectedThread,
16 CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, ThreadId, ThreadImportModal,
17 channels_with_threads, import_threads_from_other_channels,
18};
19use chrono::{DateTime, Utc};
20use editor::Editor;
21use feature_flags::{
22 AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _,
23};
24use gpui::{
25 Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle,
26 Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, WeakEntity,
27 Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px,
28};
29use menu::{
30 Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
31};
32use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, WorktreeId};
33use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
34use remote::{RemoteConnectionOptions, same_remote_connection_identity};
35use ui::utils::platform_title_bar_height;
36
37use serde::{Deserialize, Serialize};
38use settings::Settings as _;
39use std::cmp::Ordering;
40use std::collections::{HashMap, HashSet};
41use std::mem;
42use std::path::{Path, PathBuf};
43use std::rc::Rc;
44use std::sync::Arc;
45use theme::ActiveTheme;
46use ui::{
47 AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel,
48 KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor,
49 Tooltip, WithScrollbar, prelude::*, render_modifiers,
50};
51use util::ResultExt as _;
52use util::path_list::PathList;
53use workspace::{
54 CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject,
55 NextThread, Open, OpenMode, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
56 Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace,
57 notifications::NotificationId, sidebar_side_context_menu,
58};
59
60use zed_actions::OpenRecent;
61use zed_actions::editor::{MoveDown, MoveUp};
62
63use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
64
65use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
66
67#[cfg(test)]
68mod sidebar_tests;
69
70gpui::actions!(
71 agents_sidebar,
72 [
73 /// Creates a new thread in the currently selected or active project group.
74 NewThreadInGroup,
75 /// Toggles between the thread list and the thread history.
76 ToggleThreadHistory,
77 ]
78);
79
80gpui::actions!(
81 dev,
82 [
83 /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer.
84 DumpWorkspaceInfo,
85 ]
86);
87
88const DEFAULT_WIDTH: Pixels = px(300.0);
89const MIN_WIDTH: Pixels = px(200.0);
90const MAX_WIDTH: Pixels = px(800.0);
91
92#[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93enum SerializedSidebarView {
94 #[default]
95 ThreadList,
96 #[serde(alias = "Archive")]
97 History,
98}
99
100#[derive(Default, Serialize, Deserialize)]
101struct SerializedSidebar {
102 #[serde(default)]
103 width: Option<f32>,
104 #[serde(default)]
105 active_view: SerializedSidebarView,
106}
107
108#[derive(Debug, Default)]
109enum SidebarView {
110 #[default]
111 ThreadList,
112 Archive(Entity<ThreadsArchiveView>),
113}
114
115enum ArchiveWorktreeOutcome {
116 Success,
117 Cancelled,
118}
119
120#[derive(Clone, Debug)]
121struct ActiveEntry {
122 thread_id: agent_ui::ThreadId,
123 /// Stable remote identifier, used for matching when thread_id
124 /// differs (e.g. after cross-window activation creates a new
125 /// local ThreadId).
126 session_id: Option<acp::SessionId>,
127 workspace: Entity<Workspace>,
128}
129
130impl ActiveEntry {
131 fn workspace(&self) -> &Entity<Workspace> {
132 &self.workspace
133 }
134
135 fn is_active_thread(&self, thread_id: &agent_ui::ThreadId) -> bool {
136 self.thread_id == *thread_id
137 }
138
139 fn matches_entry(&self, entry: &ListEntry) -> bool {
140 match entry {
141 ListEntry::Thread(thread) => {
142 self.thread_id == thread.metadata.thread_id
143 || self
144 .session_id
145 .as_ref()
146 .zip(thread.metadata.session_id.as_ref())
147 .is_some_and(|(a, b)| a == b)
148 }
149 _ => false,
150 }
151 }
152}
153
154#[derive(Clone, Debug)]
155struct ActiveThreadInfo {
156 session_id: acp::SessionId,
157 title: SharedString,
158 status: AgentThreadStatus,
159 icon: IconName,
160 icon_from_external_svg: Option<SharedString>,
161 is_background: bool,
162 is_title_generating: bool,
163 diff_stats: DiffStats,
164}
165
166#[derive(Clone)]
167enum ThreadEntryWorkspace {
168 Open(Entity<Workspace>),
169 Closed {
170 /// The paths this thread uses (may point to linked worktrees).
171 folder_paths: PathList,
172 /// The project group this thread belongs to.
173 project_group_key: ProjectGroupKey,
174 },
175}
176
177impl ThreadEntryWorkspace {
178 fn is_remote(&self, cx: &App) -> bool {
179 match self {
180 ThreadEntryWorkspace::Open(workspace) => {
181 !workspace.read(cx).project().read(cx).is_local()
182 }
183 ThreadEntryWorkspace::Closed {
184 project_group_key, ..
185 } => project_group_key.host().is_some(),
186 }
187 }
188}
189
190#[derive(Clone)]
191struct ThreadEntry {
192 metadata: ThreadMetadata,
193 icon: IconName,
194 icon_from_external_svg: Option<SharedString>,
195 status: AgentThreadStatus,
196 workspace: ThreadEntryWorkspace,
197 is_live: bool,
198 is_background: bool,
199 is_title_generating: bool,
200 highlight_positions: Vec<usize>,
201 worktrees: Vec<ThreadItemWorktreeInfo>,
202 diff_stats: DiffStats,
203}
204
205impl ThreadEntry {
206 /// Updates this thread entry with active thread information.
207 ///
208 /// The existing [`ThreadEntry`] was likely deserialized from the database
209 /// but if we have a correspond thread already loaded we want to apply the
210 /// live information.
211 fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
212 self.metadata.title = Some(info.title.clone());
213 self.status = info.status;
214 self.icon = info.icon;
215 self.icon_from_external_svg = info.icon_from_external_svg.clone();
216 self.is_live = true;
217 self.is_background = info.is_background;
218 self.is_title_generating = info.is_title_generating;
219 self.diff_stats = info.diff_stats;
220 }
221}
222
223#[derive(Clone)]
224enum ListEntry {
225 ProjectHeader {
226 key: ProjectGroupKey,
227 label: SharedString,
228 highlight_positions: Vec<usize>,
229 has_running_threads: bool,
230 waiting_thread_count: usize,
231 is_active: bool,
232 has_threads: bool,
233 },
234 Thread(ThreadEntry),
235}
236
237#[cfg(test)]
238impl ListEntry {
239 fn session_id(&self) -> Option<&acp::SessionId> {
240 match self {
241 ListEntry::Thread(thread_entry) => thread_entry.metadata.session_id.as_ref(),
242 _ => None,
243 }
244 }
245
246 fn reachable_workspaces<'a>(
247 &'a self,
248 multi_workspace: &'a workspace::MultiWorkspace,
249 cx: &'a App,
250 ) -> Vec<Entity<Workspace>> {
251 match self {
252 ListEntry::Thread(thread) => match &thread.workspace {
253 ThreadEntryWorkspace::Open(ws) => vec![ws.clone()],
254 ThreadEntryWorkspace::Closed { .. } => Vec::new(),
255 },
256 ListEntry::ProjectHeader { key, .. } => multi_workspace
257 .workspaces_for_project_group(key, cx)
258 .unwrap_or_default(),
259 }
260 }
261}
262
263impl From<ThreadEntry> for ListEntry {
264 fn from(thread: ThreadEntry) -> Self {
265 ListEntry::Thread(thread)
266 }
267}
268
269#[derive(Default)]
270struct SidebarContents {
271 entries: Vec<ListEntry>,
272 notified_threads: HashSet<agent_ui::ThreadId>,
273 project_header_indices: Vec<usize>,
274 has_open_projects: bool,
275}
276
277impl SidebarContents {
278 fn is_thread_notified(&self, thread_id: &agent_ui::ThreadId) -> bool {
279 self.notified_threads.contains(thread_id)
280 }
281}
282
283fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
284 let mut positions = Vec::new();
285 let mut query_chars = query.chars().peekable();
286
287 for (byte_idx, candidate_char) in candidate.char_indices() {
288 if let Some(&query_char) = query_chars.peek() {
289 if candidate_char.eq_ignore_ascii_case(&query_char) {
290 positions.push(byte_idx);
291 query_chars.next();
292 }
293 } else {
294 break;
295 }
296 }
297
298 if query_chars.peek().is_none() {
299 Some(positions)
300 } else {
301 None
302 }
303}
304
305// TODO: The mapping from workspace root paths to git repositories needs a
306// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
307// thread persistence (which PathList is saved to the database), and thread
308// querying (which PathList is used to read threads back). All of these need
309// to agree on how repos are resolved for a given workspace, especially in
310// multi-root and nested-repo configurations.
311fn root_repository_snapshots(
312 workspace: &Entity<Workspace>,
313 cx: &App,
314) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
315 let path_list = workspace_path_list(workspace, cx);
316 let project = workspace.read(cx).project().read(cx);
317 project.repositories(cx).values().filter_map(move |repo| {
318 let snapshot = repo.read(cx).snapshot();
319 let is_root = path_list
320 .paths()
321 .iter()
322 .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
323 is_root.then_some(snapshot)
324 })
325}
326
327fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
328 PathList::new(&workspace.read(cx).root_paths(cx))
329}
330
331#[derive(Clone)]
332struct WorkspaceMenuWorktreeLabel {
333 icon: Option<IconName>,
334 primary_name: SharedString,
335 secondary_name: Option<SharedString>,
336}
337
338impl WorkspaceMenuWorktreeLabel {
339 fn render(&self, color: Color) -> impl IntoElement {
340 h_flex()
341 .min_w_0()
342 .gap_0p5()
343 .when_some(self.icon, |this, icon| {
344 this.child(Icon::new(icon).size(IconSize::XSmall).color(color))
345 })
346 .child(
347 Label::new(self.primary_name.clone())
348 .color(color)
349 .truncate(),
350 )
351 .when_some(self.secondary_name.clone(), |this, secondary_name| {
352 this.child(Label::new(":").color(color).alpha(0.5))
353 .child(Label::new(secondary_name).color(color).truncate())
354 })
355 }
356}
357
358fn workspace_menu_worktree_labels(
359 workspace: &Entity<Workspace>,
360 cx: &App,
361) -> Vec<WorkspaceMenuWorktreeLabel> {
362 let root_paths = workspace.read(cx).root_paths(cx);
363 let show_folder_name = root_paths.len() > 1;
364 let project = workspace.read(cx).project().clone();
365 let repository_snapshots: Vec<_> = project
366 .read(cx)
367 .repositories(cx)
368 .values()
369 .map(|repo| repo.read(cx).snapshot())
370 .collect();
371
372 root_paths
373 .into_iter()
374 .map(|root_path| {
375 let root_path = root_path.as_ref();
376 let folder_name = root_path
377 .file_name()
378 .map(|name| SharedString::from(name.to_string_lossy().to_string()))
379 .unwrap_or_default();
380 let repository_snapshot = repository_snapshots
381 .iter()
382 .find(|snapshot| snapshot.work_directory_abs_path.as_ref() == root_path);
383
384 if let Some(snapshot) = repository_snapshot
385 && snapshot.is_linked_worktree()
386 {
387 let worktree_name = project::linked_worktree_short_name(
388 snapshot.original_repo_abs_path.as_ref(),
389 root_path,
390 )
391 .unwrap_or_else(|| folder_name.clone());
392
393 if show_folder_name {
394 WorkspaceMenuWorktreeLabel {
395 icon: Some(IconName::GitWorktree),
396 primary_name: folder_name,
397 secondary_name: Some(worktree_name),
398 }
399 } else {
400 WorkspaceMenuWorktreeLabel {
401 icon: Some(IconName::GitWorktree),
402 primary_name: worktree_name,
403 secondary_name: None,
404 }
405 }
406 } else {
407 WorkspaceMenuWorktreeLabel {
408 icon: None,
409 primary_name: folder_name,
410 secondary_name: None,
411 }
412 }
413 })
414 .collect()
415}
416
417fn apply_worktree_label_mode(
418 mut worktrees: Vec<ThreadItemWorktreeInfo>,
419 mode: AgentThreadWorktreeLabel,
420) -> Vec<ThreadItemWorktreeInfo> {
421 match mode {
422 AgentThreadWorktreeLabel::Both => {}
423 AgentThreadWorktreeLabel::Worktree => {
424 for wt in &mut worktrees {
425 wt.branch_name = None;
426 }
427 }
428 AgentThreadWorktreeLabel::Branch => {
429 for wt in &mut worktrees {
430 // Fall back to showing the worktree name when no branch is
431 // known; an empty chip would be worse than a mismatched icon.
432 if wt.branch_name.is_some() {
433 wt.worktree_name = None;
434 }
435 }
436 }
437 }
438 worktrees
439}
440
441/// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
442/// an SSH connection. Suitable for passing to
443/// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote`
444/// argument.
445fn connect_remote(
446 modal_workspace: Entity<Workspace>,
447 connection_options: RemoteConnectionOptions,
448 window: &mut Window,
449 cx: &mut Context<MultiWorkspace>,
450) -> gpui::Task<anyhow::Result<Option<Entity<remote::RemoteClient>>>> {
451 remote_connection::connect_with_modal(&modal_workspace, connection_options, window, cx)
452}
453
454/// The sidebar re-derives its entire entry list from scratch on every
455/// change via `update_entries` → `rebuild_contents`. Avoid adding
456/// incremental or inter-event coordination state — if something can
457/// be computed from the current world state, compute it in the rebuild.
458pub struct Sidebar {
459 multi_workspace: WeakEntity<MultiWorkspace>,
460 width: Pixels,
461 focus_handle: FocusHandle,
462 filter_editor: Entity<Editor>,
463 list_state: ListState,
464 contents: SidebarContents,
465 /// The index of the list item that currently has the keyboard focus
466 ///
467 /// Note: This is NOT the same as the active item.
468 selection: Option<usize>,
469 /// Tracks which sidebar entry is currently active (highlighted).
470 active_entry: Option<ActiveEntry>,
471 hovered_thread_index: Option<usize>,
472
473 /// Updated only in response to explicit user actions (clicking a
474 /// thread, confirming in the thread switcher, etc.) — never from
475 /// background data changes. Used to sort the thread switcher popup.
476 thread_last_accessed: HashMap<ThreadId, DateTime<Utc>>,
477 thread_switcher: Option<Entity<ThreadSwitcher>>,
478 _thread_switcher_subscriptions: Vec<gpui::Subscription>,
479 pending_thread_activation: Option<agent_ui::ThreadId>,
480 view: SidebarView,
481 restoring_tasks: HashMap<agent_ui::ThreadId, Task<()>>,
482 recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
483 project_header_menu_handles: HashMap<usize, PopoverMenuHandle<ContextMenu>>,
484 project_header_menu_ix: Option<usize>,
485 _subscriptions: Vec<gpui::Subscription>,
486 /// For the thread import banners, if there is just one we show "Import
487 /// Threads" but if we are showing both the external agents and other
488 /// channels import banners then we change the text to disambiguate the
489 /// buttons. This field tracks whether we were using verbose labels so they
490 /// can stay stable after dismissing one of the banners.
491 import_banners_use_verbose_labels: Option<bool>,
492}
493
494impl Sidebar {
495 pub fn new(
496 multi_workspace: Entity<MultiWorkspace>,
497 window: &mut Window,
498 cx: &mut Context<Self>,
499 ) -> Self {
500 let focus_handle = cx.focus_handle();
501 cx.on_focus_in(&focus_handle, window, Self::focus_in)
502 .detach();
503
504 AgentThreadWorktreeLabelFlag::watch(cx);
505
506 let filter_editor = cx.new(|cx| {
507 let mut editor = Editor::single_line(window, cx);
508 editor.set_use_modal_editing(true);
509 editor.set_placeholder_text("Search…", window, cx);
510 editor
511 });
512
513 cx.subscribe_in(
514 &multi_workspace,
515 window,
516 |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
517 MultiWorkspaceEvent::ActiveWorkspaceChanged { .. } => {
518 this.sync_active_entry_from_active_workspace(cx);
519 this.replace_archived_panel_thread(window, cx);
520 this.update_entries(cx);
521 }
522 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
523 this.subscribe_to_workspace(workspace, window, cx);
524 this.update_entries(cx);
525 }
526 MultiWorkspaceEvent::WorkspaceRemoved(_)
527 | MultiWorkspaceEvent::ProjectGroupsChanged => {
528 this.update_entries(cx);
529 }
530 },
531 )
532 .detach();
533
534 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
535 if let editor::EditorEvent::BufferEdited = event {
536 let query = this.filter_editor.read(cx).text(cx);
537 if !query.is_empty() {
538 this.selection.take();
539 }
540 this.update_entries(cx);
541 if !query.is_empty() {
542 this.select_first_entry();
543 }
544 }
545 })
546 .detach();
547
548 cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| {
549 this.update_entries(cx);
550 })
551 .detach();
552
553 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
554 cx.defer_in(window, move |this, window, cx| {
555 for workspace in &workspaces {
556 this.subscribe_to_workspace(workspace, window, cx);
557 }
558 this.update_entries(cx);
559 });
560
561 Self {
562 multi_workspace: multi_workspace.downgrade(),
563 width: DEFAULT_WIDTH,
564 focus_handle,
565 filter_editor,
566 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
567 contents: SidebarContents::default(),
568 selection: None,
569 active_entry: None,
570 hovered_thread_index: None,
571
572 thread_last_accessed: HashMap::new(),
573 thread_switcher: None,
574 _thread_switcher_subscriptions: Vec::new(),
575 pending_thread_activation: None,
576 view: SidebarView::default(),
577 restoring_tasks: HashMap::new(),
578 recent_projects_popover_handle: PopoverMenuHandle::default(),
579 project_header_menu_handles: HashMap::new(),
580 project_header_menu_ix: None,
581 _subscriptions: Vec::new(),
582 import_banners_use_verbose_labels: None,
583 }
584 }
585
586 fn serialize(&mut self, cx: &mut Context<Self>) {
587 cx.emit(workspace::SidebarEvent::SerializeNeeded);
588 }
589
590 fn is_group_collapsed(&self, key: &ProjectGroupKey, cx: &App) -> bool {
591 self.multi_workspace
592 .upgrade()
593 .and_then(|mw| {
594 mw.read(cx)
595 .group_state_by_key(key)
596 .map(|state| !state.expanded)
597 })
598 .unwrap_or(false)
599 }
600
601 fn set_group_expanded(&self, key: &ProjectGroupKey, expanded: bool, cx: &mut Context<Self>) {
602 if let Some(mw) = self.multi_workspace.upgrade() {
603 mw.update(cx, |mw, cx| {
604 if let Some(state) = mw.group_state_by_key_mut(key) {
605 state.expanded = expanded;
606 }
607 mw.serialize(cx);
608 });
609 }
610 }
611
612 fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
613 self.multi_workspace
614 .upgrade()
615 .map_or(false, |mw| mw.read(cx).workspace() == workspace)
616 }
617
618 fn subscribe_to_workspace(
619 &mut self,
620 workspace: &Entity<Workspace>,
621 window: &mut Window,
622 cx: &mut Context<Self>,
623 ) {
624 let project = workspace.read(cx).project().clone();
625 if project.read(cx).is_via_collab() {
626 return;
627 }
628
629 cx.subscribe_in(
630 &project,
631 window,
632 |this, project, event, _window, cx| match event {
633 ProjectEvent::WorktreeAdded(_)
634 | ProjectEvent::WorktreeRemoved(_)
635 | ProjectEvent::WorktreeOrderChanged => {
636 this.update_entries(cx);
637 }
638 ProjectEvent::WorktreePathsChanged { old_worktree_paths } => {
639 this.move_thread_paths(project, old_worktree_paths, cx);
640 this.update_entries(cx);
641 }
642 _ => {}
643 },
644 )
645 .detach();
646
647 let git_store = workspace.read(cx).project().read(cx).git_store().clone();
648 cx.subscribe_in(
649 &git_store,
650 window,
651 |this, _, event: &project::git_store::GitStoreEvent, _window, cx| {
652 if matches!(
653 event,
654 project::git_store::GitStoreEvent::RepositoryUpdated(
655 _,
656 project::git_store::RepositoryEvent::GitWorktreeListChanged
657 | project::git_store::RepositoryEvent::HeadChanged,
658 _,
659 )
660 ) {
661 this.update_entries(cx);
662 }
663 },
664 )
665 .detach();
666
667 cx.subscribe_in(
668 workspace,
669 window,
670 |this, _workspace, event: &workspace::Event, window, cx| {
671 if let workspace::Event::PanelAdded(view) = event {
672 if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
673 this.subscribe_to_agent_panel(&agent_panel, window, cx);
674 this.update_entries(cx);
675 }
676 }
677 },
678 )
679 .detach();
680
681 self.observe_docks(workspace, cx);
682
683 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
684 self.subscribe_to_agent_panel(&agent_panel, window, cx);
685 }
686 }
687
688 fn move_thread_paths(
689 &mut self,
690 project: &Entity<project::Project>,
691 old_paths: &WorktreePaths,
692 cx: &mut Context<Self>,
693 ) {
694 if project.read(cx).is_via_collab() {
695 return;
696 }
697
698 let new_paths = project.read(cx).worktree_paths(cx);
699 let old_folder_paths = old_paths.folder_path_list().clone();
700
701 let added_pairs: Vec<_> = new_paths
702 .ordered_pairs()
703 .filter(|(main, folder)| {
704 !old_paths
705 .ordered_pairs()
706 .any(|(old_main, old_folder)| old_main == *main && old_folder == *folder)
707 })
708 .map(|(m, f)| (m.clone(), f.clone()))
709 .collect();
710
711 let new_folder_paths = new_paths.folder_path_list();
712 let removed_folder_paths: Vec<PathBuf> = old_folder_paths
713 .paths()
714 .iter()
715 .filter(|p| !new_folder_paths.paths().contains(p))
716 .cloned()
717 .collect();
718
719 if added_pairs.is_empty() && removed_folder_paths.is_empty() {
720 return;
721 }
722
723 let remote_connection = project.read(cx).remote_connection_options(cx);
724 ThreadMetadataStore::global(cx).update(cx, |store, store_cx| {
725 store.change_worktree_paths(
726 &old_folder_paths,
727 remote_connection.as_ref(),
728 |paths| {
729 for (main_path, folder_path) in &added_pairs {
730 paths.add_path(main_path, folder_path);
731 }
732 for path in &removed_folder_paths {
733 paths.remove_folder_path(path);
734 }
735 },
736 store_cx,
737 );
738 });
739 }
740
741 fn subscribe_to_agent_panel(
742 &mut self,
743 agent_panel: &Entity<AgentPanel>,
744 window: &mut Window,
745 cx: &mut Context<Self>,
746 ) {
747 cx.subscribe_in(
748 agent_panel,
749 window,
750 |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
751 AgentPanelEvent::ActiveViewChanged => {
752 this.sync_active_entry_from_panel(_agent_panel, cx);
753 this.update_entries(cx);
754 }
755 AgentPanelEvent::ThreadFocused | AgentPanelEvent::RetainedThreadChanged => {
756 this.sync_active_entry_from_panel(_agent_panel, cx);
757 this.update_entries(cx);
758 }
759 AgentPanelEvent::ThreadInteracted { thread_id } => {
760 this.record_thread_interacted(thread_id, cx);
761 this.update_entries(cx);
762 }
763 },
764 )
765 .detach();
766 }
767
768 fn sync_active_entry_from_active_workspace(&mut self, cx: &App) {
769 let panel = self
770 .active_workspace(cx)
771 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx));
772 if let Some(panel) = panel {
773 self.sync_active_entry_from_panel(&panel, cx);
774 }
775 }
776
777 /// When switching workspaces, the active panel may still be showing
778 /// a thread that was archived from a different workspace. In that
779 /// case, create a fresh draft so the panel has valid content and
780 /// `active_entry` can point at it.
781 fn replace_archived_panel_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
782 let Some(workspace) = self.active_workspace(cx) else {
783 return;
784 };
785 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
786 return;
787 };
788 let Some(thread_id) = panel.read(cx).active_thread_id(cx) else {
789 return;
790 };
791 let is_archived = ThreadMetadataStore::global(cx)
792 .read(cx)
793 .entry(thread_id)
794 .is_some_and(|m| m.archived);
795 if is_archived {
796 self.create_new_thread(&workspace, window, cx);
797 }
798 }
799
800 /// Syncs `active_entry` from the agent panel's current state.
801 /// Called from `ActiveViewChanged` — the panel has settled into its
802 /// new view, so we can safely read it without race conditions.
803 ///
804 /// Also resolves `pending_thread_activation` when the panel's
805 /// active thread matches the pending activation.
806 fn sync_active_entry_from_panel(&mut self, agent_panel: &Entity<AgentPanel>, cx: &App) -> bool {
807 let Some(active_workspace) = self.active_workspace(cx) else {
808 return false;
809 };
810
811 // Only sync when the event comes from the active workspace's panel.
812 let is_active_panel = active_workspace
813 .read(cx)
814 .panel::<AgentPanel>(cx)
815 .is_some_and(|p| p == *agent_panel);
816 if !is_active_panel {
817 return false;
818 }
819
820 let panel = agent_panel.read(cx);
821
822 if let Some(pending_thread_id) = self.pending_thread_activation {
823 let panel_thread_id = panel
824 .active_conversation_view()
825 .map(|cv| cv.read(cx).parent_id());
826
827 if panel_thread_id == Some(pending_thread_id) {
828 let session_id = panel
829 .active_agent_thread(cx)
830 .map(|thread| thread.read(cx).session_id().clone());
831 self.active_entry = Some(ActiveEntry {
832 thread_id: pending_thread_id,
833 session_id,
834 workspace: active_workspace,
835 });
836 self.pending_thread_activation = None;
837 return true;
838 }
839 // Pending activation not yet resolved — keep current active_entry.
840 return false;
841 }
842
843 if let Some(thread_id) = panel.active_thread_id(cx) {
844 let is_archived = ThreadMetadataStore::global(cx)
845 .read(cx)
846 .entry(thread_id)
847 .is_some_and(|m| m.archived);
848 if !is_archived {
849 let session_id = panel
850 .active_agent_thread(cx)
851 .map(|thread| thread.read(cx).session_id().clone());
852 self.active_entry = Some(ActiveEntry {
853 thread_id,
854 session_id,
855 workspace: active_workspace,
856 });
857 }
858 }
859
860 false
861 }
862
863 fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
864 let docks: Vec<_> = workspace
865 .read(cx)
866 .all_docks()
867 .into_iter()
868 .cloned()
869 .collect();
870 let workspace = workspace.downgrade();
871 for dock in docks {
872 let workspace = workspace.clone();
873 cx.observe(&dock, move |this, _dock, cx| {
874 let Some(workspace) = workspace.upgrade() else {
875 return;
876 };
877 if !this.is_active_workspace(&workspace, cx) {
878 return;
879 }
880
881 cx.notify();
882 })
883 .detach();
884 }
885 }
886
887 /// Opens a new workspace for a group that has no open workspaces.
888 fn open_workspace_for_group(
889 &mut self,
890 project_group_key: &ProjectGroupKey,
891 window: &mut Window,
892 cx: &mut Context<Self>,
893 ) {
894 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
895 return;
896 };
897 let path_list = project_group_key.path_list().clone();
898 let host = project_group_key.host();
899 let provisional_key = Some(project_group_key.clone());
900 let active_workspace = multi_workspace.read(cx).workspace().clone();
901 let modal_workspace = active_workspace.clone();
902
903 let task = multi_workspace.update(cx, |this, cx| {
904 this.find_or_create_workspace(
905 path_list,
906 host,
907 provisional_key,
908 |options, window, cx| connect_remote(active_workspace, options, window, cx),
909 &[],
910 None,
911 OpenMode::Activate,
912 window,
913 cx,
914 )
915 });
916
917 cx.spawn_in(window, async move |_this, cx| {
918 let result = task.await;
919 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
920 result?;
921 anyhow::Ok(())
922 })
923 .detach_and_log_err(cx);
924 }
925
926 fn open_workspace_and_create_draft(
927 &mut self,
928 project_group_key: &ProjectGroupKey,
929 window: &mut Window,
930 cx: &mut Context<Self>,
931 ) {
932 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
933 return;
934 };
935
936 let path_list = project_group_key.path_list().clone();
937 let host = project_group_key.host();
938 let provisional_key = Some(project_group_key.clone());
939 let active_workspace = multi_workspace.read(cx).workspace().clone();
940
941 let task = multi_workspace.update(cx, |this, cx| {
942 this.find_or_create_workspace(
943 path_list,
944 host,
945 provisional_key,
946 |options, window, cx| connect_remote(active_workspace, options, window, cx),
947 &[],
948 None,
949 OpenMode::Activate,
950 window,
951 cx,
952 )
953 });
954
955 cx.spawn_in(window, async move |this, cx| {
956 let workspace = task.await?;
957 this.update_in(cx, |this, window, cx| {
958 this.create_new_thread(&workspace, window, cx);
959 })?;
960 anyhow::Ok(())
961 })
962 .detach_and_log_err(cx);
963 }
964
965 /// Rebuilds the sidebar contents from current workspace and thread state.
966 ///
967 /// Iterates [`MultiWorkspace::project_group_keys`] to determine project
968 /// groups, then populates thread entries from the metadata store and
969 /// merges live thread info from active agent panels.
970 ///
971 /// Aim for a single forward pass over workspaces and threads plus an
972 /// O(T log T) sort. Avoid adding extra scans over the data.
973 ///
974 /// Properties:
975 ///
976 /// - Should always show every workspace in the multiworkspace
977 /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
978 /// - Should always show every thread, associated with each workspace in the multiworkspace
979 /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
980 fn rebuild_contents(&mut self, cx: &App) {
981 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
982 return;
983 };
984 let mw = multi_workspace.read(cx);
985 let workspaces: Vec<_> = mw.workspaces().cloned().collect();
986 let active_workspace = Some(mw.workspace().clone());
987
988 let agent_server_store = workspaces
989 .first()
990 .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
991
992 let query = self.filter_editor.read(cx).text(cx);
993
994 let previous = mem::take(&mut self.contents);
995
996 let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
997 .entries
998 .iter()
999 .filter_map(|entry| match entry {
1000 ListEntry::Thread(thread) if thread.is_live => {
1001 let sid = thread.metadata.session_id.clone()?;
1002 Some((sid, thread.status))
1003 }
1004 _ => None,
1005 })
1006 .collect();
1007
1008 let mut entries = Vec::new();
1009 let mut notified_threads = previous.notified_threads;
1010 let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
1011 let mut current_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
1012 let mut project_header_indices: Vec<usize> = Vec::new();
1013 let mut seen_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
1014
1015 let has_open_projects = workspaces
1016 .iter()
1017 .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
1018
1019 let resolve_agent_icon = |agent_id: &AgentId| -> (IconName, Option<SharedString>) {
1020 let agent = Agent::from(agent_id.clone());
1021 let icon = match agent {
1022 Agent::NativeAgent => IconName::ZedAgent,
1023 Agent::Custom { .. } => IconName::Terminal,
1024
1025 _ => IconName::ZedAgent,
1026 };
1027 let icon_from_external_svg = agent_server_store
1028 .as_ref()
1029 .and_then(|store| store.read(cx).agent_icon(&agent_id));
1030 (icon, icon_from_external_svg)
1031 };
1032
1033 let groups = mw.project_groups(cx);
1034
1035 let mut all_paths: Vec<PathBuf> = groups
1036 .iter()
1037 .flat_map(|group| group.key.path_list().paths().iter().cloned())
1038 .collect();
1039 all_paths.sort();
1040 all_paths.dedup();
1041 let path_details =
1042 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
1043 project::path_suffix(path, detail)
1044 });
1045 let path_detail_map: HashMap<PathBuf, usize> =
1046 all_paths.into_iter().zip(path_details).collect();
1047
1048 let mut branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
1049 for ws in &workspaces {
1050 let project = ws.read(cx).project().read(cx);
1051 for repo in project.repositories(cx).values() {
1052 let snapshot = repo.read(cx).snapshot();
1053 if let Some(branch) = &snapshot.branch {
1054 branch_by_path.insert(
1055 snapshot.work_directory_abs_path.to_path_buf(),
1056 SharedString::from(Arc::<str>::from(branch.name())),
1057 );
1058 }
1059 for linked_wt in snapshot.linked_worktrees() {
1060 if let Some(branch) = linked_wt.branch_name() {
1061 branch_by_path.insert(
1062 linked_wt.path.clone(),
1063 SharedString::from(Arc::<str>::from(branch)),
1064 );
1065 }
1066 }
1067 }
1068 }
1069
1070 for group in &groups {
1071 let group_key = &group.key;
1072 let group_workspaces = &group.workspaces;
1073 if group_key.path_list().paths().is_empty() {
1074 continue;
1075 }
1076
1077 let label = group_key.display_name(&path_detail_map);
1078
1079 let is_collapsed = self.is_group_collapsed(group_key, cx);
1080 let should_load_threads = !is_collapsed || !query.is_empty();
1081
1082 let is_active = active_workspace
1083 .as_ref()
1084 .is_some_and(|active| group_workspaces.contains(active));
1085
1086 // Collect live thread infos from all workspaces in this group.
1087 let live_infos: Vec<_> = group_workspaces
1088 .iter()
1089 .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
1090 .collect();
1091
1092 let mut threads: Vec<ThreadEntry> = Vec::new();
1093 let mut has_running_threads = false;
1094 let mut waiting_thread_count: usize = 0;
1095 let group_host = group_key.host();
1096
1097 if should_load_threads {
1098 let thread_store = ThreadMetadataStore::global(cx);
1099
1100 // Build a lookup from workspace root paths to their workspace
1101 // entity, used to assign ThreadEntryWorkspace::Open for threads
1102 // whose folder_paths match an open workspace.
1103 let workspace_by_path_list: HashMap<PathList, &Entity<Workspace>> =
1104 group_workspaces
1105 .iter()
1106 .map(|ws| (workspace_path_list(ws, cx), ws))
1107 .collect();
1108
1109 // Resolve a ThreadEntryWorkspace for a thread row. If any open
1110 // workspace's root paths match the thread's folder_paths, use
1111 // Open; otherwise use Closed.
1112 let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace {
1113 workspace_by_path_list
1114 .get(row.folder_paths())
1115 .map(|ws| ThreadEntryWorkspace::Open((*ws).clone()))
1116 .unwrap_or_else(|| ThreadEntryWorkspace::Closed {
1117 folder_paths: row.folder_paths().clone(),
1118 project_group_key: group_key.clone(),
1119 })
1120 };
1121
1122 // Build a ThreadEntry from a metadata row.
1123 let make_thread_entry =
1124 |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry {
1125 let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
1126 let worktrees =
1127 worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path);
1128 ThreadEntry {
1129 metadata: row,
1130 icon,
1131 icon_from_external_svg,
1132 status: AgentThreadStatus::default(),
1133 workspace,
1134 is_live: false,
1135 is_background: false,
1136 is_title_generating: false,
1137 highlight_positions: Vec::new(),
1138 worktrees,
1139 diff_stats: DiffStats::default(),
1140 }
1141 };
1142
1143 // Main code path: one query per group via main_worktree_paths.
1144 // The main_worktree_paths column is set on all new threads and
1145 // points to the group's canonical paths regardless of which
1146 // linked worktree the thread was opened in.
1147 for row in thread_store
1148 .read(cx)
1149 .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref())
1150 .cloned()
1151 {
1152 if !seen_thread_ids.insert(row.thread_id) {
1153 continue;
1154 }
1155 let workspace = resolve_workspace(&row);
1156 threads.push(make_thread_entry(row, workspace));
1157 }
1158
1159 // Legacy threads did not have `main_worktree_paths` populated, so they
1160 // must be queried by their `folder_paths`.
1161
1162 // Load any legacy threads for the main worktrees of this project group.
1163 for row in thread_store
1164 .read(cx)
1165 .entries_for_path(group_key.path_list(), group_host.as_ref())
1166 .cloned()
1167 {
1168 if !seen_thread_ids.insert(row.thread_id) {
1169 continue;
1170 }
1171 let workspace = resolve_workspace(&row);
1172 threads.push(make_thread_entry(row, workspace));
1173 }
1174
1175 // Load any legacy threads for any single linked wortree of this project group.
1176 let mut linked_worktree_paths = HashSet::new();
1177 for workspace in group_workspaces {
1178 if workspace.read(cx).visible_worktrees(cx).count() != 1 {
1179 continue;
1180 }
1181 for snapshot in root_repository_snapshots(workspace, cx) {
1182 for linked_worktree in snapshot.linked_worktrees() {
1183 linked_worktree_paths.insert(linked_worktree.path.clone());
1184 }
1185 }
1186 }
1187 for path in linked_worktree_paths {
1188 let worktree_path_list = PathList::new(std::slice::from_ref(&path));
1189 for row in thread_store
1190 .read(cx)
1191 .entries_for_path(&worktree_path_list, group_host.as_ref())
1192 .cloned()
1193 {
1194 if !seen_thread_ids.insert(row.thread_id) {
1195 continue;
1196 }
1197 threads.push(make_thread_entry(
1198 row,
1199 ThreadEntryWorkspace::Closed {
1200 folder_paths: worktree_path_list.clone(),
1201 project_group_key: group_key.clone(),
1202 },
1203 ));
1204 }
1205 }
1206
1207 // Build a lookup from live_infos and compute running/waiting
1208 // counts in a single pass.
1209 let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
1210 HashMap::new();
1211 for info in &live_infos {
1212 live_info_by_session.insert(&info.session_id, info);
1213 if info.status == AgentThreadStatus::Running {
1214 has_running_threads = true;
1215 }
1216 if info.status == AgentThreadStatus::WaitingForConfirmation {
1217 waiting_thread_count += 1;
1218 }
1219 }
1220
1221 // Merge live info into threads and update notification state
1222 // in a single pass.
1223 for thread in &mut threads {
1224 if let Some(session_id) = &thread.metadata.session_id {
1225 if let Some(info) = live_info_by_session.get(session_id) {
1226 thread.apply_active_info(info);
1227 }
1228 }
1229
1230 let session_id = &thread.metadata.session_id;
1231 let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| {
1232 entry.is_active_thread(&thread.metadata.thread_id)
1233 && active_workspace
1234 .as_ref()
1235 .is_some_and(|active| active == entry.workspace())
1236 });
1237
1238 if thread.status == AgentThreadStatus::Completed
1239 && !is_active_thread
1240 && session_id.as_ref().and_then(|sid| old_statuses.get(sid))
1241 == Some(&AgentThreadStatus::Running)
1242 {
1243 notified_threads.insert(thread.metadata.thread_id);
1244 }
1245
1246 if is_active_thread && !thread.is_background {
1247 notified_threads.remove(&thread.metadata.thread_id);
1248 }
1249 }
1250
1251 threads.sort_by(|a, b| {
1252 let a_time = Self::thread_display_time(&a.metadata);
1253 let b_time = Self::thread_display_time(&b.metadata);
1254 b_time.cmp(&a_time)
1255 });
1256 } else {
1257 for info in live_infos {
1258 if info.status == AgentThreadStatus::Running {
1259 has_running_threads = true;
1260 }
1261 if info.status == AgentThreadStatus::WaitingForConfirmation {
1262 waiting_thread_count += 1;
1263 }
1264 }
1265 }
1266
1267 let has_threads = if !threads.is_empty() {
1268 true
1269 } else {
1270 let store = ThreadMetadataStore::global(cx).read(cx);
1271 store
1272 .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref())
1273 .next()
1274 .is_some()
1275 || store
1276 .entries_for_path(group_key.path_list(), group_host.as_ref())
1277 .next()
1278 .is_some()
1279 };
1280
1281 if !query.is_empty() {
1282 let workspace_highlight_positions =
1283 fuzzy_match_positions(&query, &label).unwrap_or_default();
1284 let workspace_matched = !workspace_highlight_positions.is_empty();
1285
1286 let mut matched_threads: Vec<ThreadEntry> = Vec::new();
1287 for mut thread in threads {
1288 let title: &str = thread
1289 .metadata
1290 .title
1291 .as_ref()
1292 .map_or(DEFAULT_THREAD_TITLE, |t| t.as_ref());
1293 if let Some(positions) = fuzzy_match_positions(&query, title) {
1294 thread.highlight_positions = positions;
1295 }
1296 let mut worktree_matched = false;
1297 for worktree in &mut thread.worktrees {
1298 let Some(name) = worktree.worktree_name.as_ref() else {
1299 continue;
1300 };
1301 if let Some(positions) = fuzzy_match_positions(&query, name) {
1302 worktree.highlight_positions = positions;
1303 worktree_matched = true;
1304 }
1305 }
1306 if workspace_matched
1307 || !thread.highlight_positions.is_empty()
1308 || worktree_matched
1309 {
1310 matched_threads.push(thread);
1311 }
1312 }
1313
1314 if matched_threads.is_empty() && !workspace_matched {
1315 continue;
1316 }
1317
1318 project_header_indices.push(entries.len());
1319 entries.push(ListEntry::ProjectHeader {
1320 key: group_key.clone(),
1321 label,
1322 highlight_positions: workspace_highlight_positions,
1323 has_running_threads,
1324 waiting_thread_count,
1325 is_active,
1326 has_threads,
1327 });
1328
1329 for thread in matched_threads {
1330 if let Some(sid) = thread.metadata.session_id.clone() {
1331 current_session_ids.insert(sid);
1332 }
1333 current_thread_ids.insert(thread.metadata.thread_id);
1334 entries.push(thread.into());
1335 }
1336 } else {
1337 project_header_indices.push(entries.len());
1338 entries.push(ListEntry::ProjectHeader {
1339 key: group_key.clone(),
1340 label,
1341 highlight_positions: Vec::new(),
1342 has_running_threads,
1343 waiting_thread_count,
1344 is_active,
1345 has_threads,
1346 });
1347
1348 if is_collapsed {
1349 continue;
1350 }
1351
1352 for thread in threads {
1353 if let Some(sid) = &thread.metadata.session_id {
1354 current_session_ids.insert(sid.clone());
1355 }
1356 current_thread_ids.insert(thread.metadata.thread_id);
1357 entries.push(thread.into());
1358 }
1359 }
1360 }
1361
1362 notified_threads.retain(|id| current_thread_ids.contains(id));
1363
1364 self.thread_last_accessed
1365 .retain(|id, _| current_thread_ids.contains(id));
1366
1367 self.contents = SidebarContents {
1368 entries,
1369 notified_threads,
1370 project_header_indices,
1371 has_open_projects,
1372 };
1373 }
1374
1375 /// Rebuilds the sidebar's visible entries from already-cached state.
1376 fn update_entries(&mut self, cx: &mut Context<Self>) {
1377 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1378 return;
1379 };
1380 if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1381 return;
1382 }
1383
1384 let had_notifications = self.has_notifications(cx);
1385 let scroll_position = self.list_state.logical_scroll_top();
1386
1387 self.rebuild_contents(cx);
1388
1389 self.list_state.reset(self.contents.entries.len());
1390 self.list_state.scroll_to(scroll_position);
1391
1392 if had_notifications != self.has_notifications(cx) {
1393 multi_workspace.update(cx, |_, cx| {
1394 cx.notify();
1395 });
1396 }
1397
1398 cx.notify();
1399 }
1400
1401 fn select_first_entry(&mut self) {
1402 self.selection = self
1403 .contents
1404 .entries
1405 .iter()
1406 .position(|entry| matches!(entry, ListEntry::Thread(_)))
1407 .or_else(|| {
1408 if self.contents.entries.is_empty() {
1409 None
1410 } else {
1411 Some(0)
1412 }
1413 });
1414 }
1415
1416 fn render_list_entry(
1417 &mut self,
1418 ix: usize,
1419 window: &mut Window,
1420 cx: &mut Context<Self>,
1421 ) -> AnyElement {
1422 let Some(entry) = self.contents.entries.get(ix) else {
1423 return div().into_any_element();
1424 };
1425 let is_focused = self.focus_handle.is_focused(window);
1426 // is_selected means the keyboard selector is here.
1427 let is_selected = is_focused && self.selection == Some(ix);
1428
1429 let is_group_header_after_first =
1430 ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1431
1432 let is_active = self
1433 .active_entry
1434 .as_ref()
1435 .is_some_and(|active| active.matches_entry(entry));
1436
1437 let rendered = match entry {
1438 ListEntry::ProjectHeader {
1439 key,
1440 label,
1441 highlight_positions,
1442 has_running_threads,
1443 waiting_thread_count,
1444 is_active: is_active_group,
1445 has_threads,
1446 } => {
1447 let has_active_draft = is_active
1448 && self
1449 .active_workspace(cx)
1450 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
1451 .is_some_and(|panel| {
1452 let panel = panel.read(cx);
1453 panel.active_thread_is_draft(cx)
1454 || panel.active_conversation_view().is_none()
1455 });
1456 self.project_header_menu_handles.entry(ix).or_default();
1457 self.render_project_header(
1458 ix,
1459 false,
1460 key,
1461 label,
1462 highlight_positions,
1463 *has_running_threads,
1464 *waiting_thread_count,
1465 *is_active_group,
1466 is_selected,
1467 *has_threads,
1468 has_active_draft,
1469 cx,
1470 )
1471 }
1472 ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
1473 };
1474
1475 if is_group_header_after_first {
1476 v_flex()
1477 .w_full()
1478 .border_t_1()
1479 .border_color(cx.theme().colors().border)
1480 .child(rendered)
1481 .into_any_element()
1482 } else {
1483 rendered
1484 }
1485 }
1486
1487 fn render_remote_project_icon(
1488 &self,
1489 ix: usize,
1490 host: Option<&RemoteConnectionOptions>,
1491 ) -> Option<AnyElement> {
1492 let remote_icon_per_type = match host? {
1493 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1494 RemoteConnectionOptions::Docker(_) => IconName::Box,
1495 _ => IconName::Server,
1496 };
1497
1498 Some(
1499 div()
1500 .id(format!("remote-project-icon-{}", ix))
1501 .child(
1502 Icon::new(remote_icon_per_type)
1503 .size(IconSize::XSmall)
1504 .color(Color::Muted),
1505 )
1506 .tooltip(Tooltip::text("Remote Project"))
1507 .into_any_element(),
1508 )
1509 }
1510
1511 fn render_project_header(
1512 &self,
1513 ix: usize,
1514 is_sticky: bool,
1515 key: &ProjectGroupKey,
1516 label: &SharedString,
1517 highlight_positions: &[usize],
1518 has_running_threads: bool,
1519 waiting_thread_count: usize,
1520 is_active: bool,
1521 is_focused: bool,
1522 has_threads: bool,
1523 has_active_draft: bool,
1524 cx: &mut Context<Self>,
1525 ) -> AnyElement {
1526 let host = key.host();
1527
1528 let id_prefix = if is_sticky { "sticky-" } else { "" };
1529 let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1530 let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1531
1532 let is_collapsed = self.is_group_collapsed(key, cx);
1533 let disclosure_icon = if is_collapsed {
1534 IconName::ChevronRight
1535 } else {
1536 IconName::ChevronDown
1537 };
1538
1539 let key_for_toggle = key.clone();
1540 let key_for_focus = key.clone();
1541
1542 let label = if highlight_positions.is_empty() {
1543 Label::new(label.clone())
1544 .when(!is_active, |this| this.color(Color::Muted))
1545 .into_any_element()
1546 } else {
1547 HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1548 .when(!is_active, |this| this.color(Color::Muted))
1549 .into_any_element()
1550 };
1551
1552 let color = cx.theme().colors();
1553 let sidebar_base_bg = color
1554 .title_bar_background
1555 .blend(color.panel_background.opacity(0.25));
1556
1557 let base_bg = color.background.blend(sidebar_base_bg);
1558
1559 let hover_base = color
1560 .element_active
1561 .blend(color.element_background.opacity(0.2));
1562 let hover_solid = base_bg.blend(hover_base);
1563
1564 let group_name_for_gradient = group_name.clone();
1565 let gradient_overlay = move || {
1566 GradientFade::new(base_bg, hover_solid, hover_solid)
1567 .width(px(64.0))
1568 .right(px(-2.0))
1569 .gradient_stop(0.75)
1570 .group_name(group_name_for_gradient.clone())
1571 };
1572
1573 let header = h_flex()
1574 .id(id)
1575 .group(&group_name)
1576 .cursor_pointer()
1577 .relative()
1578 .h(Tab::content_height(cx))
1579 .w_full()
1580 .pl_2()
1581 .pr_1p5()
1582 .justify_between()
1583 .border_1()
1584 .map(|this| {
1585 if is_focused {
1586 this.border_color(color.border_focused)
1587 } else {
1588 this.border_color(gpui::transparent_black())
1589 }
1590 })
1591 .hover(|s| s.bg(hover_solid))
1592 .child(
1593 h_flex()
1594 .relative()
1595 .min_w_0()
1596 .w_full()
1597 .gap_1()
1598 .child(label)
1599 .when_some(
1600 self.render_remote_project_icon(ix, host.as_ref()),
1601 |this, icon| this.child(icon),
1602 )
1603 .when(is_collapsed, |this| {
1604 this.when(has_running_threads, |this| {
1605 this.child(
1606 Icon::new(IconName::LoadCircle)
1607 .size(IconSize::XSmall)
1608 .color(Color::Muted)
1609 .with_rotate_animation(2),
1610 )
1611 })
1612 .when(waiting_thread_count > 0, |this| {
1613 let tooltip_text = if waiting_thread_count == 1 {
1614 "1 thread is waiting for confirmation".to_string()
1615 } else {
1616 format!(
1617 "{waiting_thread_count} threads are waiting for confirmation",
1618 )
1619 };
1620 this.child(
1621 div()
1622 .id(format!("{id_prefix}waiting-indicator-{ix}"))
1623 .child(
1624 Icon::new(IconName::Warning)
1625 .size(IconSize::XSmall)
1626 .color(Color::Warning),
1627 )
1628 .tooltip(Tooltip::text(tooltip_text)),
1629 )
1630 })
1631 })
1632 .child(
1633 div()
1634 .when(!is_focused, |this| this.visible_on_hover(&group_name))
1635 .child(
1636 Icon::new(disclosure_icon)
1637 .size(IconSize::Small)
1638 .color(Color::Muted),
1639 ),
1640 ),
1641 )
1642 .child(gradient_overlay())
1643 .child(
1644 h_flex()
1645 .child(gradient_overlay())
1646 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1647 cx.stop_propagation();
1648 })
1649 .child({
1650 let key = key.clone();
1651 let focus_handle = self.focus_handle.clone();
1652
1653 IconButton::new(
1654 SharedString::from(format!(
1655 "{id_prefix}project-header-new-thread-{ix}",
1656 )),
1657 IconName::Plus,
1658 )
1659 .icon_size(IconSize::Small)
1660 .when(has_active_draft, |this| this.icon_color(Color::Accent))
1661 .when(!has_active_draft, |this| this.visible_on_hover(&group_name))
1662 .tooltip(move |_, cx| {
1663 Tooltip::for_action_in(
1664 "Start New Agent Thread",
1665 &NewThread,
1666 &focus_handle,
1667 cx,
1668 )
1669 })
1670 .on_click(cx.listener(
1671 move |this, _, window, cx| {
1672 this.set_group_expanded(&key, true, cx);
1673 this.selection = None;
1674 if let Some(workspace) = this.workspace_for_group(&key, cx) {
1675 this.create_new_thread(&workspace, window, cx);
1676 } else {
1677 this.open_workspace_and_create_draft(&key, window, cx);
1678 }
1679 },
1680 ))
1681 })
1682 .child(self.render_project_header_ellipsis_menu(
1683 ix,
1684 id_prefix,
1685 key,
1686 is_active,
1687 has_threads,
1688 &group_name,
1689 cx,
1690 )),
1691 )
1692 .on_mouse_down(gpui::MouseButton::Right, {
1693 let menu_handle = self
1694 .project_header_menu_handles
1695 .get(&ix)
1696 .cloned()
1697 .unwrap_or_default();
1698 move |_, window, cx| {
1699 cx.stop_propagation();
1700 menu_handle.toggle(window, cx);
1701 }
1702 })
1703 .on_click(
1704 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
1705 if event.modifiers().secondary() {
1706 this.activate_or_open_workspace_for_group(&key_for_focus, window, cx);
1707 } else {
1708 this.toggle_collapse(&key_for_toggle, window, cx);
1709 }
1710 }),
1711 );
1712
1713 if !is_collapsed && !has_threads {
1714 v_flex()
1715 .w_full()
1716 .child(header)
1717 .child(
1718 h_flex()
1719 .px_2()
1720 .pt_1()
1721 .pb_2()
1722 .gap(px(7.))
1723 .child(Icon::new(IconName::Circle).size(IconSize::Small).color(
1724 Color::Custom(cx.theme().colors().icon_placeholder.opacity(0.1)),
1725 ))
1726 .child(
1727 Label::new("No threads yet")
1728 .size(LabelSize::Small)
1729 .color(Color::Placeholder),
1730 ),
1731 )
1732 .into_any_element()
1733 } else {
1734 header.into_any_element()
1735 }
1736 }
1737
1738 fn render_project_header_ellipsis_menu(
1739 &self,
1740 ix: usize,
1741 id_prefix: &str,
1742 project_group_key: &ProjectGroupKey,
1743 is_active: bool,
1744 has_threads: bool,
1745 group_name: &SharedString,
1746 cx: &mut Context<Self>,
1747 ) -> AnyElement {
1748 let multi_workspace = self.multi_workspace.clone();
1749 let project_group_key = project_group_key.clone();
1750
1751 let show_multi_project_entries = multi_workspace
1752 .read_with(cx, |mw, _| {
1753 project_group_key.host().is_none() && mw.project_group_keys().len() >= 2
1754 })
1755 .unwrap_or(false);
1756
1757 let this = cx.weak_entity();
1758
1759 let trigger_id = SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}"));
1760 let menu_handle = self
1761 .project_header_menu_handles
1762 .get(&ix)
1763 .cloned()
1764 .unwrap_or_default();
1765 let is_menu_open = menu_handle.is_deployed();
1766
1767 PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1768 .with_handle(menu_handle)
1769 .trigger(
1770 IconButton::new(trigger_id, IconName::Ellipsis)
1771 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1772 .icon_size(IconSize::Small)
1773 .when(!is_menu_open, |el| el.visible_on_hover(group_name)),
1774 )
1775 .on_open(Rc::new({
1776 let this = this.clone();
1777 move |_window, cx| {
1778 this.update(cx, |sidebar, cx| {
1779 sidebar.project_header_menu_ix = Some(ix);
1780 cx.notify();
1781 })
1782 .ok();
1783 }
1784 }))
1785 .menu(move |window, cx| {
1786 let multi_workspace = multi_workspace.clone();
1787 let project_group_key = project_group_key.clone();
1788 let this_for_menu = this.clone();
1789
1790 let open_workspaces = multi_workspace
1791 .read_with(cx, |multi_workspace, cx| {
1792 multi_workspace
1793 .workspaces_for_project_group(&project_group_key, cx)
1794 .unwrap_or_default()
1795 })
1796 .unwrap_or_default();
1797
1798 let active_workspace = multi_workspace
1799 .read_with(cx, |multi_workspace, _cx| {
1800 multi_workspace.workspace().clone()
1801 })
1802 .ok();
1803 let workspace_labels: Vec<_> = open_workspaces
1804 .iter()
1805 .map(|workspace| workspace_menu_worktree_labels(workspace, cx))
1806 .collect();
1807 let workspace_is_active: Vec<_> = open_workspaces
1808 .iter()
1809 .map(|workspace| active_workspace.as_ref() == Some(workspace))
1810 .collect();
1811
1812 let menu =
1813 ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
1814 let menu = menu.end_slot_action(Box::new(menu::SecondaryConfirm));
1815 let weak_menu = menu_cx.weak_entity();
1816
1817 let menu = menu.when(show_multi_project_entries, |this| {
1818 this.entry(
1819 "Open Project in New Window",
1820 Some(Box::new(workspace::MoveProjectToNewWindow)),
1821 {
1822 let project_group_key = project_group_key.clone();
1823 let multi_workspace = multi_workspace.clone();
1824 move |window, cx| {
1825 multi_workspace
1826 .update(cx, |multi_workspace, cx| {
1827 multi_workspace
1828 .open_project_group_in_new_window(
1829 &project_group_key,
1830 window,
1831 cx,
1832 )
1833 .detach_and_log_err(cx);
1834 })
1835 .ok();
1836 }
1837 },
1838 )
1839 });
1840
1841 let menu = menu
1842 .custom_entry(
1843 {
1844 move |_window, cx| {
1845 let action = h_flex()
1846 .opacity(0.6)
1847 .children(render_modifiers(
1848 &Modifiers::secondary_key(),
1849 PlatformStyle::platform(),
1850 None,
1851 Some(TextSize::Default.rems(cx).into()),
1852 false,
1853 ))
1854 .child(Label::new("-click").color(Color::Muted));
1855
1856 let label = if has_threads {
1857 "Focus Last Workspace"
1858 } else {
1859 "Focus Workspace"
1860 };
1861
1862 h_flex()
1863 .w_full()
1864 .justify_between()
1865 .gap_4()
1866 .child(
1867 Label::new(label)
1868 .when(is_active, |s| s.color(Color::Disabled)),
1869 )
1870 .child(action)
1871 .into_any_element()
1872 }
1873 },
1874 {
1875 let project_group_key = project_group_key.clone();
1876 let this = this_for_menu.clone();
1877 move |window, cx| {
1878 if is_active {
1879 return;
1880 }
1881 this.update(cx, |sidebar, cx| {
1882 if let Some(workspace) =
1883 sidebar.workspace_for_group(&project_group_key, cx)
1884 {
1885 sidebar.activate_workspace(&workspace, window, cx);
1886 } else {
1887 sidebar.open_workspace_for_group(
1888 &project_group_key,
1889 window,
1890 cx,
1891 );
1892 }
1893 sidebar.selection = None;
1894 sidebar.active_entry = None;
1895 })
1896 .ok();
1897 }
1898 },
1899 )
1900 .selectable(!is_active);
1901
1902 let menu = if open_workspaces.is_empty() {
1903 menu
1904 } else {
1905 let mut menu = menu.separator().header("Open Workspaces");
1906
1907 for (
1908 workspace_index,
1909 ((workspace, workspace_label), is_active_workspace),
1910 ) in open_workspaces
1911 .iter()
1912 .cloned()
1913 .zip(workspace_labels.iter().cloned())
1914 .zip(workspace_is_active.iter().copied())
1915 .enumerate()
1916 {
1917 let activate_multi_workspace = multi_workspace.clone();
1918 let close_multi_workspace = multi_workspace.clone();
1919 let activate_weak_menu = weak_menu.clone();
1920 let close_weak_menu = weak_menu.clone();
1921 let activate_workspace = workspace.clone();
1922 let close_workspace = workspace.clone();
1923
1924 menu = menu.custom_entry(
1925 move |_window, _cx| {
1926 let close_multi_workspace = close_multi_workspace.clone();
1927 let close_weak_menu = close_weak_menu.clone();
1928 let close_workspace = close_workspace.clone();
1929 let label_color = if is_active_workspace {
1930 Color::Accent
1931 } else {
1932 Color::Default
1933 };
1934 let row_group_name = SharedString::from(format!(
1935 "workspace-menu-row-{workspace_index}"
1936 ));
1937
1938 h_flex()
1939 .group(&row_group_name)
1940 .w_full()
1941 .gap_2()
1942 .justify_between()
1943 .child(h_flex().min_w_0().gap_1().children(
1944 workspace_label.iter().enumerate().map(
1945 |(label_ix, label)| {
1946 h_flex()
1947 .gap_1()
1948 .when(label_ix > 0, |this| {
1949 this.child(
1950 Label::new("•").alpha(0.25),
1951 )
1952 })
1953 .child(label.render(label_color))
1954 .into_any_element()
1955 },
1956 ),
1957 ))
1958 .when(!is_active_workspace, |this| {
1959 let close_multi_workspace =
1960 close_multi_workspace.clone();
1961 let close_weak_menu = close_weak_menu.clone();
1962 let close_workspace = close_workspace.clone();
1963
1964 this.child(
1965 IconButton::new(
1966 ("close-workspace", workspace_index),
1967 IconName::Close,
1968 )
1969 .icon_size(IconSize::Small)
1970 .visible_on_hover(&row_group_name)
1971 .tooltip(Tooltip::text("Close Workspace"))
1972 .on_click(move |_, window, cx| {
1973 cx.stop_propagation();
1974 window.prevent_default();
1975 close_multi_workspace
1976 .update(cx, |multi_workspace, cx| {
1977 multi_workspace
1978 .close_workspace(
1979 &close_workspace,
1980 window,
1981 cx,
1982 )
1983 .detach_and_log_err(cx);
1984 })
1985 .ok();
1986 close_weak_menu
1987 .update(cx, |_, cx| {
1988 cx.emit(DismissEvent)
1989 })
1990 .ok();
1991 }),
1992 )
1993 })
1994 .into_any_element()
1995 },
1996 move |window, cx| {
1997 activate_multi_workspace
1998 .update(cx, |multi_workspace, cx| {
1999 multi_workspace.activate(
2000 activate_workspace.clone(),
2001 None,
2002 window,
2003 cx,
2004 );
2005 })
2006 .ok();
2007 activate_weak_menu
2008 .update(cx, |_, cx| cx.emit(DismissEvent))
2009 .ok();
2010 },
2011 );
2012 }
2013
2014 menu
2015 };
2016
2017 let project_group_key = project_group_key.clone();
2018 let remove_multi_workspace = multi_workspace.clone();
2019 menu.separator()
2020 .entry("Remove Project", None, move |window, cx| {
2021 remove_multi_workspace
2022 .update(cx, |multi_workspace, cx| {
2023 multi_workspace
2024 .remove_project_group(&project_group_key, window, cx)
2025 .detach_and_log_err(cx);
2026 })
2027 .ok();
2028 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
2029 })
2030 });
2031
2032 let this = this.clone();
2033
2034 window
2035 .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
2036 this.update(cx, |sidebar, cx| {
2037 sidebar.project_header_menu_ix = None;
2038 cx.notify();
2039 })
2040 .ok();
2041 })
2042 .detach();
2043
2044 Some(menu)
2045 })
2046 .anchor(gpui::Anchor::TopRight)
2047 .offset(gpui::Point {
2048 x: px(0.),
2049 y: px(1.),
2050 })
2051 .into_any_element()
2052 }
2053
2054 fn render_sticky_header(
2055 &self,
2056 window: &mut Window,
2057 cx: &mut Context<Self>,
2058 ) -> Option<AnyElement> {
2059 let scroll_top = self.list_state.logical_scroll_top();
2060
2061 let &header_idx = self
2062 .contents
2063 .project_header_indices
2064 .iter()
2065 .rev()
2066 .find(|&&idx| idx <= scroll_top.item_ix)?;
2067
2068 let needs_sticky = header_idx < scroll_top.item_ix
2069 || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
2070
2071 if !needs_sticky {
2072 return None;
2073 }
2074
2075 let ListEntry::ProjectHeader {
2076 key,
2077 label,
2078 highlight_positions,
2079 has_running_threads,
2080 waiting_thread_count,
2081 is_active,
2082 has_threads,
2083 } = self.contents.entries.get(header_idx)?
2084 else {
2085 return None;
2086 };
2087
2088 let is_focused = self.focus_handle.is_focused(window);
2089 let is_selected = is_focused && self.selection == Some(header_idx);
2090
2091 let has_active_draft = *is_active
2092 && self
2093 .active_workspace(cx)
2094 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
2095 .is_some_and(|panel| {
2096 let panel = panel.read(cx);
2097 panel.active_thread_is_draft(cx) || panel.active_conversation_view().is_none()
2098 });
2099 let header_element = self.render_project_header(
2100 header_idx,
2101 true,
2102 key,
2103 &label,
2104 &highlight_positions,
2105 *has_running_threads,
2106 *waiting_thread_count,
2107 *is_active,
2108 is_selected,
2109 *has_threads,
2110 has_active_draft,
2111 cx,
2112 );
2113
2114 let top_offset = self
2115 .contents
2116 .project_header_indices
2117 .iter()
2118 .find(|&&idx| idx > header_idx)
2119 .and_then(|&next_idx| {
2120 let bounds = self.list_state.bounds_for_item(next_idx)?;
2121 let viewport = self.list_state.viewport_bounds();
2122 let y_in_viewport = bounds.origin.y - viewport.origin.y;
2123 let header_height = bounds.size.height;
2124 (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
2125 })
2126 .unwrap_or(px(0.));
2127
2128 let color = cx.theme().colors();
2129 let background = color
2130 .title_bar_background
2131 .blend(color.panel_background.opacity(0.2));
2132
2133 let element = v_flex()
2134 .absolute()
2135 .top(top_offset)
2136 .left_0()
2137 .w_full()
2138 .bg(background)
2139 .border_b_1()
2140 .border_color(color.border.opacity(0.5))
2141 .child(header_element)
2142 .shadow_xs()
2143 .into_any_element();
2144
2145 Some(element)
2146 }
2147
2148 fn toggle_collapse(
2149 &mut self,
2150 project_group_key: &ProjectGroupKey,
2151 _window: &mut Window,
2152 cx: &mut Context<Self>,
2153 ) {
2154 let is_collapsed = self.is_group_collapsed(project_group_key, cx);
2155 self.set_group_expanded(project_group_key, is_collapsed, cx);
2156 self.update_entries(cx);
2157 }
2158
2159 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
2160 let mut dispatch_context = KeyContext::new_with_defaults();
2161 dispatch_context.add("ThreadsSidebar");
2162 dispatch_context.add("menu");
2163
2164 let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx));
2165
2166 let identifier = if self.filter_editor.focus_handle(cx).is_focused(window)
2167 || is_archived_search_focused
2168 {
2169 "searching"
2170 } else {
2171 "not_searching"
2172 };
2173
2174 dispatch_context.add(identifier);
2175 dispatch_context
2176 }
2177
2178 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2179 if !self.focus_handle.is_focused(window) {
2180 return;
2181 }
2182
2183 if let SidebarView::Archive(archive) = &self.view {
2184 let has_selection = archive.read(cx).has_selection();
2185 if !has_selection {
2186 archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
2187 }
2188 } else if self.selection.is_none() {
2189 self.filter_editor.focus_handle(cx).focus(window, cx);
2190 }
2191 }
2192
2193 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
2194 if self.reset_filter_editor_text(window, cx) {
2195 self.update_entries(cx);
2196 } else {
2197 self.selection = None;
2198 self.filter_editor.focus_handle(cx).focus(window, cx);
2199 cx.notify();
2200 }
2201 }
2202
2203 fn focus_sidebar_filter(
2204 &mut self,
2205 _: &FocusSidebarFilter,
2206 window: &mut Window,
2207 cx: &mut Context<Self>,
2208 ) {
2209 self.selection = None;
2210 if let SidebarView::Archive(archive) = &self.view {
2211 archive.update(cx, |view, cx| {
2212 view.clear_selection();
2213 view.focus_filter_editor(window, cx);
2214 });
2215 } else {
2216 self.filter_editor.focus_handle(cx).focus(window, cx);
2217 }
2218
2219 // When vim mode is active, the editor defaults to normal mode which
2220 // blocks text input. Switch to insert mode so the user can type
2221 // immediately.
2222 if vim_mode_setting::VimModeSetting::get_global(cx).0 {
2223 if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
2224 window.dispatch_action(action, cx);
2225 }
2226 }
2227
2228 cx.notify();
2229 }
2230
2231 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2232 self.filter_editor.update(cx, |editor, cx| {
2233 if editor.buffer().read(cx).len(cx).0 > 0 {
2234 editor.set_text("", window, cx);
2235 true
2236 } else {
2237 false
2238 }
2239 })
2240 }
2241
2242 fn has_filter_query(&self, cx: &App) -> bool {
2243 !self.filter_editor.read(cx).text(cx).is_empty()
2244 }
2245
2246 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
2247 self.select_next(&SelectNext, window, cx);
2248 if self.selection.is_some() {
2249 self.focus_handle.focus(window, cx);
2250 }
2251 }
2252
2253 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
2254 self.select_previous(&SelectPrevious, window, cx);
2255 if self.selection.is_some() {
2256 self.focus_handle.focus(window, cx);
2257 }
2258 }
2259
2260 fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2261 if self.selection.is_none() {
2262 self.select_next(&SelectNext, window, cx);
2263 }
2264 if self.selection.is_some() {
2265 self.focus_handle.focus(window, cx);
2266 }
2267 }
2268
2269 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
2270 let next = match self.selection {
2271 Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
2272 Some(_) if !self.contents.entries.is_empty() => 0,
2273 None if !self.contents.entries.is_empty() => 0,
2274 _ => return,
2275 };
2276 self.selection = Some(next);
2277 self.list_state.scroll_to_reveal_item(next);
2278 cx.notify();
2279 }
2280
2281 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
2282 match self.selection {
2283 Some(0) => {
2284 self.selection = None;
2285 self.filter_editor.focus_handle(cx).focus(window, cx);
2286 cx.notify();
2287 }
2288 Some(ix) => {
2289 self.selection = Some(ix - 1);
2290 self.list_state.scroll_to_reveal_item(ix - 1);
2291 cx.notify();
2292 }
2293 None if !self.contents.entries.is_empty() => {
2294 let last = self.contents.entries.len() - 1;
2295 self.selection = Some(last);
2296 self.list_state.scroll_to_reveal_item(last);
2297 cx.notify();
2298 }
2299 None => {}
2300 }
2301 }
2302
2303 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
2304 if !self.contents.entries.is_empty() {
2305 self.selection = Some(0);
2306 self.list_state.scroll_to_reveal_item(0);
2307 cx.notify();
2308 }
2309 }
2310
2311 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
2312 if let Some(last) = self.contents.entries.len().checked_sub(1) {
2313 self.selection = Some(last);
2314 self.list_state.scroll_to_reveal_item(last);
2315 cx.notify();
2316 }
2317 }
2318
2319 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
2320 let Some(ix) = self.selection else { return };
2321 let Some(entry) = self.contents.entries.get(ix) else {
2322 return;
2323 };
2324
2325 match entry {
2326 ListEntry::ProjectHeader { key, .. } => {
2327 let key = key.clone();
2328 self.toggle_collapse(&key, window, cx);
2329 }
2330 ListEntry::Thread(thread) => {
2331 let metadata = thread.metadata.clone();
2332 match &thread.workspace {
2333 ThreadEntryWorkspace::Open(workspace) => {
2334 let workspace = workspace.clone();
2335 self.activate_thread(metadata, &workspace, false, window, cx);
2336 }
2337 ThreadEntryWorkspace::Closed {
2338 folder_paths,
2339 project_group_key,
2340 } => {
2341 let folder_paths = folder_paths.clone();
2342 let project_group_key = project_group_key.clone();
2343 self.open_workspace_and_activate_thread(
2344 metadata,
2345 folder_paths,
2346 &project_group_key,
2347 window,
2348 cx,
2349 );
2350 }
2351 }
2352 }
2353 }
2354 }
2355
2356 fn find_workspace_across_windows(
2357 &self,
2358 cx: &App,
2359 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2360 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2361 cx.windows()
2362 .into_iter()
2363 .filter_map(|window| window.downcast::<MultiWorkspace>())
2364 .find_map(|window| {
2365 let workspace = window.read(cx).ok().and_then(|multi_workspace| {
2366 multi_workspace
2367 .workspaces()
2368 .find(|workspace| predicate(workspace, cx))
2369 .cloned()
2370 })?;
2371 Some((window, workspace))
2372 })
2373 }
2374
2375 fn find_workspace_in_current_window(
2376 &self,
2377 cx: &App,
2378 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2379 ) -> Option<Entity<Workspace>> {
2380 self.multi_workspace.upgrade().and_then(|multi_workspace| {
2381 multi_workspace
2382 .read(cx)
2383 .workspaces()
2384 .find(|workspace| predicate(workspace, cx))
2385 .cloned()
2386 })
2387 }
2388
2389 fn load_agent_thread_in_workspace(
2390 workspace: &Entity<Workspace>,
2391 metadata: &ThreadMetadata,
2392 focus: bool,
2393 window: &mut Window,
2394 cx: &mut App,
2395 ) {
2396 let load_thread = |agent_panel: Entity<AgentPanel>,
2397 metadata: &ThreadMetadata,
2398 focus: bool,
2399 window: &mut Window,
2400 cx: &mut App| {
2401 let Some(session_id) = metadata.session_id.clone() else {
2402 return;
2403 };
2404 agent_panel.update(cx, |panel, cx| {
2405 panel.load_agent_thread(
2406 Agent::from(metadata.agent_id.clone()),
2407 session_id,
2408 Some(metadata.folder_paths().clone()),
2409 metadata.title.clone(),
2410 focus,
2411 "sidebar",
2412 window,
2413 cx,
2414 );
2415 });
2416 };
2417
2418 let mut existing_panel = None;
2419 workspace.update(cx, |workspace, cx| {
2420 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2421 existing_panel = Some(panel);
2422 }
2423 });
2424
2425 if let Some(agent_panel) = existing_panel {
2426 load_thread(agent_panel, metadata, focus, window, cx);
2427 workspace.update(cx, |workspace, cx| {
2428 if focus {
2429 workspace.focus_panel::<AgentPanel>(window, cx);
2430 } else {
2431 workspace.reveal_panel::<AgentPanel>(window, cx);
2432 }
2433 });
2434 return;
2435 }
2436
2437 let workspace = workspace.downgrade();
2438 let metadata = metadata.clone();
2439 let mut async_window_cx = window.to_async(cx);
2440 cx.spawn(async move |_cx| {
2441 let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?;
2442
2443 workspace.update_in(&mut async_window_cx, |workspace, window, cx| {
2444 let panel = workspace.panel::<AgentPanel>(cx).unwrap_or_else(|| {
2445 workspace.add_panel(panel.clone(), window, cx);
2446 panel.clone()
2447 });
2448 load_thread(panel, &metadata, focus, window, cx);
2449 if focus {
2450 workspace.focus_panel::<AgentPanel>(window, cx);
2451 } else {
2452 workspace.reveal_panel::<AgentPanel>(window, cx);
2453 }
2454 })?;
2455
2456 anyhow::Ok(())
2457 })
2458 .detach_and_log_err(cx);
2459 }
2460
2461 fn activate_thread_locally(
2462 &mut self,
2463 metadata: &ThreadMetadata,
2464 workspace: &Entity<Workspace>,
2465 retain: bool,
2466 window: &mut Window,
2467 cx: &mut Context<Self>,
2468 ) {
2469 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2470 return;
2471 };
2472
2473 // Set active_entry eagerly so the sidebar highlight updates
2474 // immediately, rather than waiting for a deferred AgentPanel
2475 // event which can race with ActiveWorkspaceChanged clearing it.
2476 self.active_entry = Some(ActiveEntry {
2477 thread_id: metadata.thread_id,
2478 session_id: metadata.session_id.clone(),
2479 workspace: workspace.clone(),
2480 });
2481 self.record_thread_access(&metadata.thread_id);
2482
2483 if metadata.session_id.is_some() {
2484 self.pending_thread_activation = Some(metadata.thread_id);
2485 }
2486
2487 multi_workspace.update(cx, |multi_workspace, cx| {
2488 multi_workspace.activate(workspace.clone(), None, window, cx);
2489 if retain {
2490 multi_workspace.retain_active_workspace(cx);
2491 }
2492 });
2493
2494 // Drafts (and other retained threads without a session_id) are
2495 // already in memory — activate them directly instead of loading.
2496 let thread_id = metadata.thread_id;
2497 if metadata.session_id.is_none() {
2498 workspace.update(cx, |ws, cx| {
2499 if let Some(panel) = ws.panel::<AgentPanel>(cx) {
2500 panel.update(cx, |panel, cx| {
2501 panel.activate_retained_thread(thread_id, true, window, cx);
2502 });
2503 }
2504 ws.focus_panel::<AgentPanel>(window, cx);
2505 });
2506 self.pending_thread_activation = None;
2507 } else {
2508 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
2509 }
2510
2511 self.update_entries(cx);
2512 }
2513
2514 fn activate_thread_in_other_window(
2515 &self,
2516 metadata: ThreadMetadata,
2517 workspace: Entity<Workspace>,
2518 target_window: WindowHandle<MultiWorkspace>,
2519 cx: &mut Context<Self>,
2520 ) {
2521 let target_session_id = metadata.session_id.clone();
2522 let metadata_thread_id = metadata.thread_id;
2523 let workspace_for_entry = workspace.clone();
2524
2525 let activated = target_window
2526 .update(cx, |multi_workspace, window, cx| {
2527 window.activate_window();
2528 multi_workspace.activate(workspace.clone(), None, window, cx);
2529 Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
2530 })
2531 .log_err()
2532 .is_some();
2533
2534 if activated {
2535 if let Some(target_sidebar) = target_window
2536 .read(cx)
2537 .ok()
2538 .and_then(|multi_workspace| {
2539 multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2540 })
2541 .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2542 {
2543 target_sidebar.update(cx, |sidebar, cx| {
2544 sidebar.pending_thread_activation = Some(metadata_thread_id);
2545 sidebar.active_entry = Some(ActiveEntry {
2546 thread_id: metadata_thread_id,
2547 session_id: target_session_id.clone(),
2548 workspace: workspace_for_entry.clone(),
2549 });
2550 sidebar.record_thread_access(&metadata_thread_id);
2551 sidebar.update_entries(cx);
2552 });
2553 }
2554 }
2555 }
2556
2557 fn activate_thread(
2558 &mut self,
2559 metadata: ThreadMetadata,
2560 workspace: &Entity<Workspace>,
2561 retain: bool,
2562 window: &mut Window,
2563 cx: &mut Context<Self>,
2564 ) {
2565 if self
2566 .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2567 .is_some()
2568 {
2569 self.activate_thread_locally(&metadata, &workspace, retain, window, cx);
2570 return;
2571 }
2572
2573 let Some((target_window, workspace)) =
2574 self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2575 else {
2576 return;
2577 };
2578
2579 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2580 }
2581
2582 fn open_workspace_and_activate_thread(
2583 &mut self,
2584 metadata: ThreadMetadata,
2585 folder_paths: PathList,
2586 project_group_key: &ProjectGroupKey,
2587 window: &mut Window,
2588 cx: &mut Context<Self>,
2589 ) {
2590 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2591 return;
2592 };
2593
2594 let pending_thread_id = metadata.thread_id;
2595 // Mark the pending thread activation so rebuild_contents
2596 // preserves the Thread active_entry during loading and
2597 // reconciliation cannot synthesize an empty fallback draft.
2598 self.pending_thread_activation = Some(pending_thread_id);
2599
2600 let host = project_group_key.host();
2601 let provisional_key = Some(project_group_key.clone());
2602 let active_workspace = multi_workspace.read(cx).workspace().clone();
2603 let modal_workspace = active_workspace.clone();
2604
2605 let open_task = multi_workspace.update(cx, |this, cx| {
2606 this.find_or_create_workspace(
2607 folder_paths,
2608 host,
2609 provisional_key,
2610 |options, window, cx| connect_remote(active_workspace, options, window, cx),
2611 &[],
2612 None,
2613 OpenMode::Activate,
2614 window,
2615 cx,
2616 )
2617 });
2618
2619 cx.spawn_in(window, async move |this, cx| {
2620 let result = open_task.await;
2621 // Dismiss the modal as soon as the open attempt completes so
2622 // failures or cancellations do not leave a stale connection modal behind.
2623 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
2624
2625 if result.is_err() {
2626 this.update(cx, |this, _cx| {
2627 if this.pending_thread_activation == Some(pending_thread_id) {
2628 this.pending_thread_activation = None;
2629 }
2630 })
2631 .ok();
2632 }
2633
2634 let workspace = result?;
2635 this.update_in(cx, |this, window, cx| {
2636 this.activate_thread(metadata, &workspace, false, window, cx);
2637 })?;
2638 anyhow::Ok(())
2639 })
2640 .detach_and_log_err(cx);
2641 }
2642
2643 fn find_current_workspace_for_path_list(
2644 &self,
2645 path_list: &PathList,
2646 remote_connection: Option<&RemoteConnectionOptions>,
2647 cx: &App,
2648 ) -> Option<Entity<Workspace>> {
2649 self.find_workspace_in_current_window(cx, |workspace, cx| {
2650 workspace_path_list(workspace, cx).paths() == path_list.paths()
2651 && same_remote_connection_identity(
2652 workspace
2653 .read(cx)
2654 .project()
2655 .read(cx)
2656 .remote_connection_options(cx)
2657 .as_ref(),
2658 remote_connection,
2659 )
2660 })
2661 }
2662
2663 fn find_open_workspace_for_path_list(
2664 &self,
2665 path_list: &PathList,
2666 remote_connection: Option<&RemoteConnectionOptions>,
2667 cx: &App,
2668 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2669 self.find_workspace_across_windows(cx, |workspace, cx| {
2670 workspace_path_list(workspace, cx).paths() == path_list.paths()
2671 && same_remote_connection_identity(
2672 workspace
2673 .read(cx)
2674 .project()
2675 .read(cx)
2676 .remote_connection_options(cx)
2677 .as_ref(),
2678 remote_connection,
2679 )
2680 })
2681 }
2682
2683 fn open_thread_from_archive(
2684 &mut self,
2685 metadata: ThreadMetadata,
2686 window: &mut Window,
2687 cx: &mut Context<Self>,
2688 ) {
2689 let thread_id = metadata.thread_id;
2690 let weak_archive_view = match &self.view {
2691 SidebarView::Archive(view) => Some(view.downgrade()),
2692 _ => None,
2693 };
2694
2695 if metadata.folder_paths().paths().is_empty() {
2696 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.unarchive(thread_id, cx));
2697
2698 let active_workspace = self
2699 .multi_workspace
2700 .upgrade()
2701 .map(|w| w.read(cx).workspace().clone());
2702
2703 if let Some(workspace) = active_workspace {
2704 self.activate_thread_locally(&metadata, &workspace, false, window, cx);
2705 } else {
2706 let path_list = metadata.folder_paths().clone();
2707 if let Some((target_window, workspace)) = self.find_open_workspace_for_path_list(
2708 &path_list,
2709 metadata.remote_connection.as_ref(),
2710 cx,
2711 ) {
2712 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2713 } else {
2714 let key = ProjectGroupKey::from_worktree_paths(
2715 &metadata.worktree_paths,
2716 metadata.remote_connection.clone(),
2717 );
2718 self.open_workspace_and_activate_thread(metadata, path_list, &key, window, cx);
2719 }
2720 }
2721 self.show_thread_list(window, cx);
2722 return;
2723 }
2724
2725 let store = ThreadMetadataStore::global(cx);
2726 let task = if metadata.archived {
2727 store
2728 .read(cx)
2729 .get_archived_worktrees_for_thread(thread_id, cx)
2730 } else {
2731 Task::ready(Ok(Vec::new()))
2732 };
2733 let path_list = metadata.folder_paths().clone();
2734
2735 let restore_task = cx.spawn_in(window, async move |this, cx| {
2736 let result: anyhow::Result<()> = async {
2737 let archived_worktrees = task.await?;
2738
2739 if archived_worktrees.is_empty() {
2740 this.update_in(cx, |this, window, cx| {
2741 this.restoring_tasks.remove(&thread_id);
2742 if metadata.archived {
2743 ThreadMetadataStore::global(cx)
2744 .update(cx, |store, cx| store.unarchive(thread_id, cx));
2745 }
2746
2747 if let Some(workspace) = this.find_current_workspace_for_path_list(
2748 &path_list,
2749 metadata.remote_connection.as_ref(),
2750 cx,
2751 ) {
2752 this.activate_thread_locally(&metadata, &workspace, false, window, cx);
2753 } else if let Some((target_window, workspace)) = this
2754 .find_open_workspace_for_path_list(
2755 &path_list,
2756 metadata.remote_connection.as_ref(),
2757 cx,
2758 )
2759 {
2760 this.activate_thread_in_other_window(
2761 metadata,
2762 workspace,
2763 target_window,
2764 cx,
2765 );
2766 } else {
2767 let key = ProjectGroupKey::from_worktree_paths(
2768 &metadata.worktree_paths,
2769 metadata.remote_connection.clone(),
2770 );
2771 this.open_workspace_and_activate_thread(
2772 metadata, path_list, &key, window, cx,
2773 );
2774 }
2775 this.show_thread_list(window, cx);
2776 })?;
2777 return anyhow::Ok(());
2778 }
2779
2780 let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new();
2781 for row in &archived_worktrees {
2782 match thread_worktree_archive::restore_worktree_via_git(
2783 row,
2784 metadata.remote_connection.as_ref(),
2785 &mut *cx,
2786 )
2787 .await
2788 {
2789 Ok(restored_path) => {
2790 thread_worktree_archive::cleanup_archived_worktree_record(
2791 row,
2792 metadata.remote_connection.as_ref(),
2793 &mut *cx,
2794 )
2795 .await;
2796 path_replacements.push((row.worktree_path.clone(), restored_path));
2797 }
2798 Err(error) => {
2799 log::error!("Failed to restore worktree: {error:#}");
2800 this.update_in(cx, |this, _window, cx| {
2801 this.restoring_tasks.remove(&thread_id);
2802 if let Some(weak_archive_view) = &weak_archive_view {
2803 weak_archive_view
2804 .update(cx, |view, cx| {
2805 view.clear_restoring(&thread_id, cx);
2806 })
2807 .ok();
2808 }
2809
2810 if let Some(multi_workspace) = this.multi_workspace.upgrade() {
2811 let workspace = multi_workspace.read(cx).workspace().clone();
2812 workspace.update(cx, |workspace, cx| {
2813 struct RestoreWorktreeErrorToast;
2814 workspace.show_toast(
2815 Toast::new(
2816 NotificationId::unique::<RestoreWorktreeErrorToast>(
2817 ),
2818 format!("Failed to restore worktree: {error:#}"),
2819 )
2820 .autohide(),
2821 cx,
2822 );
2823 });
2824 }
2825 })
2826 .ok();
2827 return anyhow::Ok(());
2828 }
2829 }
2830 }
2831
2832 if !path_replacements.is_empty() {
2833 cx.update(|_window, cx| {
2834 store.update(cx, |store, cx| {
2835 store.update_restored_worktree_paths(thread_id, &path_replacements, cx);
2836 });
2837 })?;
2838
2839 let updated_metadata =
2840 cx.update(|_window, cx| store.read(cx).entry(thread_id).cloned())?;
2841
2842 if let Some(updated_metadata) = updated_metadata {
2843 let new_paths = updated_metadata.folder_paths().clone();
2844 let key = ProjectGroupKey::from_worktree_paths(
2845 &updated_metadata.worktree_paths,
2846 updated_metadata.remote_connection.clone(),
2847 );
2848
2849 cx.update(|_window, cx| {
2850 store.update(cx, |store, cx| {
2851 store.unarchive(updated_metadata.thread_id, cx);
2852 });
2853 })?;
2854
2855 this.update_in(cx, |this, window, cx| {
2856 this.restoring_tasks.remove(&thread_id);
2857 this.open_workspace_and_activate_thread(
2858 updated_metadata,
2859 new_paths,
2860 &key,
2861 window,
2862 cx,
2863 );
2864 this.show_thread_list(window, cx);
2865 })?;
2866 }
2867 }
2868
2869 anyhow::Ok(())
2870 }
2871 .await;
2872 if let Err(error) = result {
2873 log::error!("{error:#}");
2874 }
2875 });
2876 self.restoring_tasks.insert(thread_id, restore_task);
2877 }
2878
2879 fn expand_selected_entry(
2880 &mut self,
2881 _: &SelectChild,
2882 _window: &mut Window,
2883 cx: &mut Context<Self>,
2884 ) {
2885 let Some(ix) = self.selection else { return };
2886
2887 match self.contents.entries.get(ix) {
2888 Some(ListEntry::ProjectHeader { key, .. }) => {
2889 let key = key.clone();
2890 if self.is_group_collapsed(&key, cx) {
2891 self.set_group_expanded(&key, true, cx);
2892 self.update_entries(cx);
2893 } else if ix + 1 < self.contents.entries.len() {
2894 self.selection = Some(ix + 1);
2895 self.list_state.scroll_to_reveal_item(ix + 1);
2896 cx.notify();
2897 }
2898 }
2899 _ => {}
2900 }
2901 }
2902
2903 fn collapse_selected_entry(
2904 &mut self,
2905 _: &SelectParent,
2906 _window: &mut Window,
2907 cx: &mut Context<Self>,
2908 ) {
2909 let Some(ix) = self.selection else { return };
2910
2911 match self.contents.entries.get(ix) {
2912 Some(ListEntry::ProjectHeader { key, .. }) => {
2913 let key = key.clone();
2914 if !self.is_group_collapsed(&key, cx) {
2915 self.set_group_expanded(&key, false, cx);
2916 self.update_entries(cx);
2917 }
2918 }
2919 Some(ListEntry::Thread(_)) => {
2920 for i in (0..ix).rev() {
2921 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
2922 {
2923 let key = key.clone();
2924 self.selection = Some(i);
2925 self.set_group_expanded(&key, false, cx);
2926 self.update_entries(cx);
2927 break;
2928 }
2929 }
2930 }
2931 None => {}
2932 }
2933 }
2934
2935 fn toggle_selected_fold(
2936 &mut self,
2937 _: &editor::actions::ToggleFold,
2938 _window: &mut Window,
2939 cx: &mut Context<Self>,
2940 ) {
2941 let Some(ix) = self.selection else { return };
2942
2943 // Find the group header for the current selection.
2944 let header_ix = match self.contents.entries.get(ix) {
2945 Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2946 Some(ListEntry::Thread(_)) => (0..ix).rev().find(|&i| {
2947 matches!(
2948 self.contents.entries.get(i),
2949 Some(ListEntry::ProjectHeader { .. })
2950 )
2951 }),
2952 None => None,
2953 };
2954
2955 if let Some(header_ix) = header_ix {
2956 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
2957 {
2958 let key = key.clone();
2959 if self.is_group_collapsed(&key, cx) {
2960 self.set_group_expanded(&key, true, cx);
2961 } else {
2962 self.selection = Some(header_ix);
2963 self.set_group_expanded(&key, false, cx);
2964 }
2965 self.update_entries(cx);
2966 }
2967 }
2968 }
2969
2970 fn fold_all(
2971 &mut self,
2972 _: &editor::actions::FoldAll,
2973 _window: &mut Window,
2974 cx: &mut Context<Self>,
2975 ) {
2976 if let Some(mw) = self.multi_workspace.upgrade() {
2977 mw.update(cx, |mw, _cx| {
2978 mw.set_all_groups_expanded(false);
2979 });
2980 }
2981 self.update_entries(cx);
2982 }
2983
2984 fn unfold_all(
2985 &mut self,
2986 _: &editor::actions::UnfoldAll,
2987 _window: &mut Window,
2988 cx: &mut Context<Self>,
2989 ) {
2990 if let Some(mw) = self.multi_workspace.upgrade() {
2991 mw.update(cx, |mw, _cx| {
2992 mw.set_all_groups_expanded(true);
2993 });
2994 }
2995 self.update_entries(cx);
2996 }
2997
2998 fn stop_thread(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut Context<Self>) {
2999 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
3000 return;
3001 };
3002
3003 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
3004 for workspace in workspaces {
3005 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3006 let cancelled =
3007 agent_panel.update(cx, |panel, cx| panel.cancel_thread(thread_id, cx));
3008 if cancelled {
3009 return;
3010 }
3011 }
3012 }
3013 }
3014
3015 fn archive_thread(
3016 &mut self,
3017 session_id: &acp::SessionId,
3018 window: &mut Window,
3019 cx: &mut Context<Self>,
3020 ) {
3021 let store = ThreadMetadataStore::global(cx);
3022 let metadata = store.read(cx).entry_by_session(session_id).cloned();
3023 let active_workspace = metadata.as_ref().and_then(|metadata| {
3024 self.active_entry.as_ref().and_then(|entry| {
3025 if entry.is_active_thread(&metadata.thread_id) {
3026 Some(entry.workspace.clone())
3027 } else {
3028 None
3029 }
3030 })
3031 });
3032 let thread_id = metadata.as_ref().map(|metadata| metadata.thread_id);
3033 let thread_folder_paths = metadata
3034 .as_ref()
3035 .map(|metadata| metadata.folder_paths().clone())
3036 .or_else(|| {
3037 active_workspace
3038 .as_ref()
3039 .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)))
3040 });
3041
3042 // Compute which linked worktree roots should be archived from disk if
3043 // this thread is archived. This must happen before we remove any
3044 // workspace from the MultiWorkspace, because `build_root_plan` needs
3045 // the currently open workspaces in order to find the affected projects
3046 // and repository handles for each linked worktree.
3047 let roots_to_archive = metadata
3048 .as_ref()
3049 .map(|metadata| {
3050 let mut workspaces = self
3051 .multi_workspace
3052 .upgrade()
3053 .map(|multi_workspace| {
3054 multi_workspace
3055 .read(cx)
3056 .workspaces()
3057 .cloned()
3058 .collect::<Vec<_>>()
3059 })
3060 .unwrap_or_default();
3061 for workspace in thread_worktree_archive::all_open_workspaces(cx) {
3062 if !workspaces.contains(&workspace) {
3063 workspaces.push(workspace);
3064 }
3065 }
3066 metadata
3067 .folder_paths()
3068 .ordered_paths()
3069 .filter_map(|path| {
3070 thread_worktree_archive::build_root_plan(
3071 path,
3072 metadata.remote_connection.as_ref(),
3073 &workspaces,
3074 cx,
3075 )
3076 })
3077 .filter(|plan| {
3078 thread_id.map_or(true, |tid| {
3079 !store
3080 .read(cx)
3081 .path_is_referenced_by_other_unarchived_threads(
3082 tid,
3083 &plan.root_path,
3084 metadata.remote_connection.as_ref(),
3085 )
3086 })
3087 })
3088 .collect::<Vec<_>>()
3089 })
3090 .unwrap_or_default();
3091
3092 // Find the neighbor thread in the sidebar (by display position).
3093 // Look below first, then above, for the nearest thread that isn't
3094 // the one being archived. We capture both the neighbor's metadata
3095 // (for activation) and its workspace paths (for the workspace
3096 // removal fallback).
3097 let current_pos = self.contents.entries.iter().position(|entry| match entry {
3098 ListEntry::Thread(thread) => thread_id.map_or_else(
3099 || thread.metadata.session_id.as_ref() == Some(session_id),
3100 |tid| thread.metadata.thread_id == tid,
3101 ),
3102 _ => false,
3103 });
3104 let neighbor = current_pos.and_then(|pos| {
3105 self.contents.entries[pos + 1..]
3106 .iter()
3107 .chain(self.contents.entries[..pos].iter().rev())
3108 .find_map(|entry| match entry {
3109 ListEntry::Thread(t) if t.metadata.session_id.as_ref() != Some(session_id) => {
3110 let (workspace_paths, project_group_key) = match &t.workspace {
3111 ThreadEntryWorkspace::Open(ws) => (
3112 PathList::new(&ws.read(cx).root_paths(cx)),
3113 ws.read(cx).project_group_key(cx),
3114 ),
3115 ThreadEntryWorkspace::Closed {
3116 folder_paths,
3117 project_group_key,
3118 } => (folder_paths.clone(), project_group_key.clone()),
3119 };
3120 Some((t.metadata.clone(), workspace_paths, project_group_key))
3121 }
3122 _ => None,
3123 })
3124 });
3125
3126 // Check if archiving this thread would leave its worktree workspace
3127 // with no threads, requiring workspace removal.
3128 let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| {
3129 if folder_paths.is_empty() {
3130 return None;
3131 }
3132
3133 let thread_remote_connection =
3134 metadata.as_ref().and_then(|m| m.remote_connection.as_ref());
3135 let remaining = ThreadMetadataStore::global(cx)
3136 .read(cx)
3137 .entries_for_path(folder_paths, thread_remote_connection)
3138 .filter(|t| t.session_id.as_ref() != Some(session_id))
3139 .count();
3140
3141 if remaining > 0 {
3142 return None;
3143 }
3144
3145 let multi_workspace = self.multi_workspace.upgrade()?;
3146 let workspace = multi_workspace
3147 .read(cx)
3148 .workspace_for_paths(folder_paths, None, cx)?;
3149
3150 let group_key = workspace.read(cx).project_group_key(cx);
3151 let is_linked_worktree = group_key.path_list() != folder_paths;
3152
3153 is_linked_worktree.then_some(workspace)
3154 });
3155
3156 // Also find workspaces for root plans that aren't covered by
3157 // workspace_to_remove. For workspaces that exclusively contain
3158 // worktrees being archived, remove the whole workspace. For
3159 // "mixed" workspaces (containing both archived and non-archived
3160 // worktrees), close only the editor items referencing the
3161 // archived worktrees so their Entity<Worktree> handles are
3162 // dropped without destroying the user's workspace layout.
3163 let mut workspaces_to_remove: Vec<Entity<Workspace>> =
3164 workspace_to_remove.into_iter().collect();
3165 let mut close_item_tasks: Vec<Task<anyhow::Result<()>>> = Vec::new();
3166
3167 let archive_paths: HashSet<&Path> = roots_to_archive
3168 .iter()
3169 .map(|root| root.root_path.as_path())
3170 .collect();
3171
3172 // Classify workspaces into "exclusive" (all worktrees archived)
3173 // and "mixed" (some worktrees archived, some not).
3174 let mut mixed_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeId>)> = Vec::new();
3175
3176 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3177 let all_workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
3178
3179 for workspace in all_workspaces {
3180 if workspaces_to_remove.contains(&workspace) {
3181 continue;
3182 }
3183
3184 let project = workspace.read(cx).project().read(cx);
3185 let visible_worktrees: Vec<_> = project
3186 .visible_worktrees(cx)
3187 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
3188 .collect();
3189
3190 let archived_worktree_ids: Vec<WorktreeId> = visible_worktrees
3191 .iter()
3192 .filter(|(_, path)| archive_paths.contains(path.as_ref()))
3193 .map(|(id, _)| *id)
3194 .collect();
3195
3196 if archived_worktree_ids.is_empty() {
3197 continue;
3198 }
3199
3200 if visible_worktrees.len() == archived_worktree_ids.len() {
3201 workspaces_to_remove.push(workspace);
3202 } else {
3203 mixed_workspaces.push((workspace, archived_worktree_ids));
3204 }
3205 }
3206 }
3207
3208 // For mixed workspaces, close only items belonging to the
3209 // worktrees being archived.
3210 for (workspace, archived_worktree_ids) in &mixed_workspaces {
3211 let panes: Vec<_> = workspace.read(cx).panes().to_vec();
3212 for pane in panes {
3213 let items_to_close: Vec<EntityId> = pane
3214 .read(cx)
3215 .items()
3216 .filter(|item| {
3217 item.project_path(cx)
3218 .is_some_and(|pp| archived_worktree_ids.contains(&pp.worktree_id))
3219 })
3220 .map(|item| item.item_id())
3221 .collect();
3222
3223 if !items_to_close.is_empty() {
3224 let task = pane.update(cx, |pane, cx| {
3225 pane.close_items(window, cx, SaveIntent::Close, &|item_id| {
3226 items_to_close.contains(&item_id)
3227 })
3228 });
3229 close_item_tasks.push(task);
3230 }
3231 }
3232 }
3233
3234 if !workspaces_to_remove.is_empty() {
3235 let multi_workspace = self.multi_workspace.upgrade().unwrap();
3236 let session_id = session_id.clone();
3237
3238 let (fallback_paths, project_group_key) = neighbor
3239 .as_ref()
3240 .map(|(_, paths, project_group_key)| (paths.clone(), project_group_key.clone()))
3241 .unwrap_or_else(|| {
3242 workspaces_to_remove
3243 .first()
3244 .map(|ws| {
3245 let key = ws.read(cx).project_group_key(cx);
3246 (key.path_list().clone(), key)
3247 })
3248 .unwrap_or_default()
3249 });
3250
3251 let excluded = workspaces_to_remove.clone();
3252 let remove_task = multi_workspace.update(cx, |mw, cx| {
3253 mw.remove(
3254 workspaces_to_remove,
3255 move |this, window, cx| {
3256 let active_workspace = this.workspace().clone();
3257 this.find_or_create_workspace(
3258 fallback_paths,
3259 project_group_key.host(),
3260 Some(project_group_key),
3261 |options, window, cx| {
3262 connect_remote(active_workspace, options, window, cx)
3263 },
3264 &excluded,
3265 None,
3266 OpenMode::Activate,
3267 window,
3268 cx,
3269 )
3270 },
3271 window,
3272 cx,
3273 )
3274 });
3275
3276 let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
3277 let thread_folder_paths = thread_folder_paths.clone();
3278 cx.spawn_in(window, async move |this, cx| {
3279 if !remove_task.await? {
3280 return anyhow::Ok(());
3281 }
3282
3283 for task in close_item_tasks {
3284 let result: anyhow::Result<()> = task.await;
3285 result.log_err();
3286 }
3287
3288 this.update_in(cx, |this, window, cx| {
3289 let in_flight = thread_id.and_then(|tid| {
3290 this.start_archive_worktree_task(tid, roots_to_archive, cx)
3291 });
3292 this.archive_and_activate(
3293 &session_id,
3294 thread_id,
3295 neighbor_metadata.as_ref(),
3296 thread_folder_paths.as_ref(),
3297 in_flight,
3298 window,
3299 cx,
3300 );
3301 })?;
3302 anyhow::Ok(())
3303 })
3304 .detach_and_log_err(cx);
3305 } else if !close_item_tasks.is_empty() {
3306 let session_id = session_id.clone();
3307 let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
3308 let thread_folder_paths = thread_folder_paths.clone();
3309 cx.spawn_in(window, async move |this, cx| {
3310 for task in close_item_tasks {
3311 let result: anyhow::Result<()> = task.await;
3312 result.log_err();
3313 }
3314
3315 this.update_in(cx, |this, window, cx| {
3316 let in_flight = thread_id.and_then(|tid| {
3317 this.start_archive_worktree_task(tid, roots_to_archive, cx)
3318 });
3319 this.archive_and_activate(
3320 &session_id,
3321 thread_id,
3322 neighbor_metadata.as_ref(),
3323 thread_folder_paths.as_ref(),
3324 in_flight,
3325 window,
3326 cx,
3327 );
3328 })?;
3329 anyhow::Ok(())
3330 })
3331 .detach_and_log_err(cx);
3332 } else {
3333 let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
3334 let in_flight = thread_id
3335 .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx));
3336 self.archive_and_activate(
3337 session_id,
3338 thread_id,
3339 neighbor_metadata.as_ref(),
3340 thread_folder_paths.as_ref(),
3341 in_flight,
3342 window,
3343 cx,
3344 );
3345 }
3346 }
3347
3348 /// Archive a thread and activate the nearest neighbor or a draft.
3349 ///
3350 /// IMPORTANT: when activating a neighbor or creating a fallback draft,
3351 /// this method also activates the target workspace in the MultiWorkspace.
3352 /// This is critical because `rebuild_contents` derives the active
3353 /// workspace from `mw.workspace()`. If the linked worktree workspace is
3354 /// still active after archiving its last thread, `rebuild_contents` sees
3355 /// the threadless linked worktree as active and emits a spurious
3356 /// "+ New Thread" entry with the worktree chip — keeping the worktree
3357 /// alive and preventing disk cleanup.
3358 ///
3359 /// When `in_flight_archive` is present, it is the background task that
3360 /// persists the linked worktree's git state and deletes it from disk.
3361 /// We attach it to the metadata store at the same time we mark the thread
3362 /// archived so failures can automatically unarchive the thread and user-
3363 /// initiated unarchive can cancel the task.
3364 fn archive_and_activate(
3365 &mut self,
3366 _session_id: &acp::SessionId,
3367 thread_id: Option<agent_ui::ThreadId>,
3368 neighbor: Option<&ThreadMetadata>,
3369 thread_folder_paths: Option<&PathList>,
3370 in_flight_archive: Option<(Task<()>, smol::channel::Sender<()>)>,
3371 window: &mut Window,
3372 cx: &mut Context<Self>,
3373 ) {
3374 if let Some(thread_id) = thread_id {
3375 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3376 store.archive(thread_id, in_flight_archive, cx);
3377 });
3378 }
3379
3380 let is_active = self
3381 .active_entry
3382 .as_ref()
3383 .is_some_and(|entry| thread_id.is_some_and(|tid| entry.is_active_thread(&tid)));
3384
3385 if is_active {
3386 self.active_entry = None;
3387 }
3388
3389 if !is_active {
3390 // The user is looking at a different thread/draft. Clear the
3391 // archived thread from its workspace's panel so that switching
3392 // to that workspace later doesn't show a stale thread.
3393 if let Some(folder_paths) = thread_folder_paths {
3394 if let Some(workspace) = self
3395 .multi_workspace
3396 .upgrade()
3397 .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx))
3398 {
3399 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3400 let panel_shows_archived = panel
3401 .read(cx)
3402 .active_conversation_view()
3403 .map(|cv| cv.read(cx).parent_id())
3404 .is_some_and(|live_thread_id| {
3405 thread_id.is_some_and(|id| id == live_thread_id)
3406 });
3407 if panel_shows_archived {
3408 panel.update(cx, |panel, cx| {
3409 panel.clear_base_view(window, cx);
3410 });
3411 }
3412 }
3413 }
3414 }
3415 return;
3416 }
3417
3418 // Try to activate the neighbor thread. If its workspace is open,
3419 // tell the panel to load it and activate that workspace.
3420 // `rebuild_contents` will reconcile `active_entry` once the thread
3421 // finishes loading.
3422
3423 if let Some(metadata) = neighbor {
3424 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
3425 mw.read(cx)
3426 .workspace_for_paths(metadata.folder_paths(), None, cx)
3427 }) {
3428 self.active_entry = Some(ActiveEntry {
3429 thread_id: metadata.thread_id,
3430 session_id: metadata.session_id.clone(),
3431 workspace: workspace.clone(),
3432 });
3433 self.activate_workspace(&workspace, window, cx);
3434 Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx);
3435 return;
3436 }
3437 }
3438
3439 // No neighbor or its workspace isn't open — just clear the
3440 // panel so the group is left empty.
3441 if let Some(folder_paths) = thread_folder_paths {
3442 let workspace = self
3443 .multi_workspace
3444 .upgrade()
3445 .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx));
3446 if let Some(workspace) = workspace {
3447 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3448 panel.update(cx, |panel, cx| {
3449 panel.clear_base_view(window, cx);
3450 });
3451 }
3452 }
3453 }
3454 }
3455
3456 fn start_archive_worktree_task(
3457 &self,
3458 thread_id: ThreadId,
3459 roots: Vec<thread_worktree_archive::RootPlan>,
3460 cx: &mut Context<Self>,
3461 ) -> Option<(Task<()>, smol::channel::Sender<()>)> {
3462 if roots.is_empty() {
3463 return None;
3464 }
3465
3466 let (cancel_tx, cancel_rx) = smol::channel::bounded::<()>(1);
3467 let task = cx.spawn(async move |_this, cx| {
3468 match Self::archive_worktree_roots(roots, cancel_rx, cx).await {
3469 Ok(ArchiveWorktreeOutcome::Success) => {
3470 cx.update(|cx| {
3471 ThreadMetadataStore::global(cx).update(cx, |store, _cx| {
3472 store.cleanup_completed_archive(thread_id);
3473 });
3474 });
3475 }
3476 Ok(ArchiveWorktreeOutcome::Cancelled) => {}
3477 Err(error) => {
3478 log::error!("Failed to archive worktree: {error:#}");
3479 cx.update(|cx| {
3480 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3481 store.unarchive(thread_id, cx);
3482 });
3483 });
3484 }
3485 }
3486 });
3487
3488 Some((task, cancel_tx))
3489 }
3490
3491 async fn archive_worktree_roots(
3492 roots: Vec<thread_worktree_archive::RootPlan>,
3493 cancel_rx: smol::channel::Receiver<()>,
3494 cx: &mut gpui::AsyncApp,
3495 ) -> anyhow::Result<ArchiveWorktreeOutcome> {
3496 let mut completed_persists: Vec<(i64, thread_worktree_archive::RootPlan)> = Vec::new();
3497
3498 for root in &roots {
3499 if cancel_rx.is_closed() {
3500 for &(id, ref completed_root) in completed_persists.iter().rev() {
3501 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3502 }
3503 return Ok(ArchiveWorktreeOutcome::Cancelled);
3504 }
3505
3506 match thread_worktree_archive::persist_worktree_state(root, cx).await {
3507 Ok(id) => {
3508 completed_persists.push((id, root.clone()));
3509 }
3510 Err(error) => {
3511 for &(id, ref completed_root) in completed_persists.iter().rev() {
3512 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3513 }
3514 return Err(error);
3515 }
3516 }
3517
3518 if cancel_rx.is_closed() {
3519 for &(id, ref completed_root) in completed_persists.iter().rev() {
3520 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3521 }
3522 return Ok(ArchiveWorktreeOutcome::Cancelled);
3523 }
3524
3525 if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await {
3526 if let Some(&(id, ref completed_root)) = completed_persists.last() {
3527 if completed_root.root_path == root.root_path {
3528 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3529 completed_persists.pop();
3530 }
3531 }
3532 for &(id, ref completed_root) in completed_persists.iter().rev() {
3533 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3534 }
3535 return Err(error);
3536 }
3537 }
3538
3539 Ok(ArchiveWorktreeOutcome::Success)
3540 }
3541
3542 fn activate_workspace(
3543 &self,
3544 workspace: &Entity<Workspace>,
3545 window: &mut Window,
3546 cx: &mut Context<Self>,
3547 ) {
3548 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3549 multi_workspace.update(cx, |mw, cx| {
3550 mw.activate(workspace.clone(), None, window, cx);
3551 });
3552 }
3553 }
3554
3555 fn archive_selected_thread(
3556 &mut self,
3557 _: &ArchiveSelectedThread,
3558 window: &mut Window,
3559 cx: &mut Context<Self>,
3560 ) {
3561 let Some(ix) = self.selection else {
3562 return;
3563 };
3564 match self.contents.entries.get(ix) {
3565 Some(ListEntry::Thread(thread)) => {
3566 match thread.status {
3567 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
3568 return;
3569 }
3570 AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
3571 }
3572 if let Some(session_id) = thread.metadata.session_id.clone() {
3573 self.archive_thread(&session_id, window, cx);
3574 }
3575 }
3576 _ => {}
3577 }
3578 }
3579
3580 fn record_thread_access(&mut self, id: &ThreadId) {
3581 self.thread_last_accessed.insert(*id, Utc::now());
3582 }
3583
3584 fn record_thread_interacted(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut App) {
3585 let store = ThreadMetadataStore::global(cx);
3586 store.update(cx, |store, cx| {
3587 store.update_interacted_at(thread_id, Utc::now(), cx);
3588 })
3589 }
3590
3591 fn thread_display_time(metadata: &ThreadMetadata) -> DateTime<Utc> {
3592 metadata.interacted_at.unwrap_or(metadata.updated_at)
3593 }
3594
3595 /// The sort order used by the ctrl-tab switcher
3596 fn thread_cmp_for_switcher(&self, left: &ThreadMetadata, right: &ThreadMetadata) -> Ordering {
3597 let sort_time = |x: &ThreadMetadata| {
3598 self.thread_last_accessed
3599 .get(&x.thread_id)
3600 .copied()
3601 .or(x.interacted_at)
3602 .unwrap_or(x.updated_at)
3603 };
3604
3605 // .reverse() = most recent first
3606 sort_time(left).cmp(&sort_time(right)).reverse()
3607 }
3608
3609 fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
3610 let mut current_header_label: Option<SharedString> = None;
3611 let mut current_header_key: Option<ProjectGroupKey> = None;
3612 let mut entries: Vec<ThreadSwitcherEntry> = self
3613 .contents
3614 .entries
3615 .iter()
3616 .filter_map(|entry| match entry {
3617 ListEntry::ProjectHeader { label, key, .. } => {
3618 current_header_label = Some(label.clone());
3619 current_header_key = Some(key.clone());
3620 None
3621 }
3622 ListEntry::Thread(thread) => {
3623 let session_id = thread.metadata.session_id.clone()?;
3624 let workspace = match &thread.workspace {
3625 ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
3626 ThreadEntryWorkspace::Closed { .. } => {
3627 current_header_key.as_ref().and_then(|key| {
3628 self.multi_workspace.upgrade().and_then(|mw| {
3629 mw.read(cx).workspace_for_paths(
3630 key.path_list(),
3631 key.host().as_ref(),
3632 cx,
3633 )
3634 })
3635 })
3636 }
3637 }?;
3638 let notified = self.contents.is_thread_notified(&thread.metadata.thread_id);
3639 let timestamp: SharedString =
3640 format_history_entry_timestamp(Self::thread_display_time(&thread.metadata))
3641 .into();
3642 Some(ThreadSwitcherEntry {
3643 session_id,
3644 title: thread.metadata.display_title(),
3645 icon: thread.icon,
3646 icon_from_external_svg: thread.icon_from_external_svg.clone(),
3647 status: thread.status,
3648 metadata: thread.metadata.clone(),
3649 workspace,
3650 project_name: current_header_label.clone(),
3651 worktrees: thread
3652 .worktrees
3653 .iter()
3654 .cloned()
3655 .map(|mut wt| {
3656 wt.highlight_positions = Vec::new();
3657 wt
3658 })
3659 .collect(),
3660 diff_stats: thread.diff_stats,
3661 is_title_generating: thread.is_title_generating,
3662 notified,
3663 timestamp,
3664 })
3665 }
3666 })
3667 .collect();
3668
3669 entries.sort_by(|a, b| self.thread_cmp_for_switcher(&a.metadata, &b.metadata));
3670
3671 entries
3672 }
3673
3674 fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
3675 self.thread_switcher = None;
3676 self._thread_switcher_subscriptions.clear();
3677 if let Some(mw) = self.multi_workspace.upgrade() {
3678 mw.update(cx, |mw, cx| {
3679 mw.set_sidebar_overlay(None, cx);
3680 });
3681 }
3682 }
3683
3684 fn on_toggle_thread_switcher(
3685 &mut self,
3686 action: &ToggleThreadSwitcher,
3687 window: &mut Window,
3688 cx: &mut Context<Self>,
3689 ) {
3690 self.toggle_thread_switcher_impl(action.select_last, window, cx);
3691 }
3692
3693 fn toggle_thread_switcher_impl(
3694 &mut self,
3695 select_last: bool,
3696 window: &mut Window,
3697 cx: &mut Context<Self>,
3698 ) {
3699 if let Some(thread_switcher) = &self.thread_switcher {
3700 thread_switcher.update(cx, |switcher, cx| {
3701 if select_last {
3702 switcher.select_last(cx);
3703 } else {
3704 switcher.cycle_selection(cx);
3705 }
3706 });
3707 return;
3708 }
3709
3710 let entries = self.mru_threads_for_switcher(cx);
3711 if entries.len() < 2 {
3712 return;
3713 }
3714
3715 let weak_multi_workspace = self.multi_workspace.clone();
3716
3717 let original_metadata = match &self.active_entry {
3718 Some(ActiveEntry { thread_id, .. }) => entries
3719 .iter()
3720 .find(|e| *thread_id == e.metadata.thread_id)
3721 .map(|e| e.metadata.clone()),
3722 _ => None,
3723 };
3724 let original_workspace = self
3725 .multi_workspace
3726 .upgrade()
3727 .map(|mw| mw.read(cx).workspace().clone());
3728
3729 let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
3730
3731 let mut subscriptions = Vec::new();
3732
3733 subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
3734 let thread_switcher = thread_switcher.clone();
3735 move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
3736 ThreadSwitcherEvent::Preview {
3737 metadata,
3738 workspace,
3739 } => {
3740 if let Some(mw) = weak_multi_workspace.upgrade() {
3741 mw.update(cx, |mw, cx| {
3742 mw.activate(workspace.clone(), None, window, cx);
3743 });
3744 }
3745 this.active_entry = Some(ActiveEntry {
3746 thread_id: metadata.thread_id,
3747 session_id: metadata.session_id.clone(),
3748 workspace: workspace.clone(),
3749 });
3750 this.update_entries(cx);
3751 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3752 let focus = thread_switcher.focus_handle(cx);
3753 window.focus(&focus, cx);
3754 }
3755 ThreadSwitcherEvent::Confirmed {
3756 metadata,
3757 workspace,
3758 } => {
3759 if let Some(mw) = weak_multi_workspace.upgrade() {
3760 mw.update(cx, |mw, cx| {
3761 mw.activate(workspace.clone(), None, window, cx);
3762 mw.retain_active_workspace(cx);
3763 });
3764 }
3765 this.record_thread_access(&metadata.thread_id);
3766 this.active_entry = Some(ActiveEntry {
3767 thread_id: metadata.thread_id,
3768 session_id: metadata.session_id.clone(),
3769 workspace: workspace.clone(),
3770 });
3771 this.update_entries(cx);
3772 this.dismiss_thread_switcher(cx);
3773 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
3774 }
3775 ThreadSwitcherEvent::Dismissed => {
3776 if let Some(mw) = weak_multi_workspace.upgrade() {
3777 if let Some(original_ws) = &original_workspace {
3778 mw.update(cx, |mw, cx| {
3779 mw.activate(original_ws.clone(), None, window, cx);
3780 });
3781 }
3782 }
3783 if let Some(metadata) = &original_metadata {
3784 if let Some(original_ws) = &original_workspace {
3785 this.active_entry = Some(ActiveEntry {
3786 thread_id: metadata.thread_id,
3787 session_id: metadata.session_id.clone(),
3788 workspace: original_ws.clone(),
3789 });
3790 }
3791 this.update_entries(cx);
3792 if let Some(original_ws) = &original_workspace {
3793 Self::load_agent_thread_in_workspace(
3794 original_ws,
3795 metadata,
3796 false,
3797 window,
3798 cx,
3799 );
3800 }
3801 }
3802 this.dismiss_thread_switcher(cx);
3803 }
3804 }
3805 }));
3806
3807 subscriptions.push(cx.subscribe_in(
3808 &thread_switcher,
3809 window,
3810 |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
3811 this.dismiss_thread_switcher(cx);
3812 },
3813 ));
3814
3815 let focus = thread_switcher.focus_handle(cx);
3816 let overlay_view = gpui::AnyView::from(thread_switcher.clone());
3817
3818 // Replay the initial preview that was emitted during construction
3819 // before subscriptions were wired up.
3820 let initial_preview = thread_switcher
3821 .read(cx)
3822 .selected_entry()
3823 .map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
3824
3825 self.thread_switcher = Some(thread_switcher);
3826 self._thread_switcher_subscriptions = subscriptions;
3827 if let Some(mw) = self.multi_workspace.upgrade() {
3828 mw.update(cx, |mw, cx| {
3829 mw.set_sidebar_overlay(Some(overlay_view), cx);
3830 });
3831 }
3832
3833 if let Some((metadata, workspace)) = initial_preview {
3834 if let Some(mw) = self.multi_workspace.upgrade() {
3835 mw.update(cx, |mw, cx| {
3836 mw.activate(workspace.clone(), None, window, cx);
3837 });
3838 }
3839 self.active_entry = Some(ActiveEntry {
3840 thread_id: metadata.thread_id,
3841 session_id: metadata.session_id.clone(),
3842 workspace: workspace.clone(),
3843 });
3844 self.update_entries(cx);
3845 Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
3846 }
3847
3848 window.focus(&focus, cx);
3849 }
3850
3851 fn render_thread(
3852 &self,
3853 ix: usize,
3854 thread: &ThreadEntry,
3855 is_active: bool,
3856 is_focused: bool,
3857 cx: &mut Context<Self>,
3858 ) -> AnyElement {
3859 let has_notification = self.contents.is_thread_notified(&thread.metadata.thread_id);
3860
3861 let title: SharedString = thread.metadata.display_title();
3862 let metadata = thread.metadata.clone();
3863 let thread_workspace = thread.workspace.clone();
3864
3865 let is_hovered = self.hovered_thread_index == Some(ix);
3866 let is_selected = is_active;
3867 let is_running = matches!(
3868 thread.status,
3869 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
3870 );
3871
3872 let thread_id_for_actions = thread.metadata.thread_id;
3873 let session_id_for_delete = thread.metadata.session_id.clone();
3874 let focus_handle = self.focus_handle.clone();
3875
3876 let id = SharedString::from(format!("thread-entry-{}", ix));
3877
3878 let color = cx.theme().colors();
3879 let sidebar_bg = color
3880 .title_bar_background
3881 .blend(color.panel_background.opacity(0.25));
3882
3883 let timestamp = format_history_entry_timestamp(Self::thread_display_time(&thread.metadata));
3884
3885 let is_remote = thread.workspace.is_remote(cx);
3886
3887 let worktrees = apply_worktree_label_mode(
3888 thread.worktrees.clone(),
3889 cx.flag_value::<AgentThreadWorktreeLabelFlag>(),
3890 );
3891
3892 ThreadItem::new(id, title)
3893 .base_bg(sidebar_bg)
3894 .icon(thread.icon)
3895 .status(thread.status)
3896 .is_remote(is_remote)
3897 .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
3898 this.custom_icon_from_external_svg(svg)
3899 })
3900 .worktrees(worktrees)
3901 .timestamp(timestamp)
3902 .highlight_positions(thread.highlight_positions.to_vec())
3903 .title_generating(thread.is_title_generating)
3904 .notified(has_notification)
3905 .when(thread.diff_stats.lines_added > 0, |this| {
3906 this.added(thread.diff_stats.lines_added as usize)
3907 })
3908 .when(thread.diff_stats.lines_removed > 0, |this| {
3909 this.removed(thread.diff_stats.lines_removed as usize)
3910 })
3911 .selected(is_selected)
3912 .focused(is_focused)
3913 .hovered(is_hovered)
3914 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
3915 if *is_hovered {
3916 this.hovered_thread_index = Some(ix);
3917 } else if this.hovered_thread_index == Some(ix) {
3918 this.hovered_thread_index = None;
3919 }
3920 cx.notify();
3921 }))
3922 .when(is_hovered && is_running, |this| {
3923 this.action_slot(
3924 IconButton::new("stop-thread", IconName::Stop)
3925 .icon_size(IconSize::Small)
3926 .icon_color(Color::Error)
3927 .style(ButtonStyle::Tinted(TintColor::Error))
3928 .tooltip(Tooltip::text("Stop Generation"))
3929 .on_click({
3930 cx.listener(move |this, _, _window, cx| {
3931 this.stop_thread(&thread_id_for_actions, cx);
3932 })
3933 }),
3934 )
3935 })
3936 .when(is_hovered && !is_running, |this| {
3937 this.action_slot(
3938 IconButton::new("archive-thread", IconName::Archive)
3939 .icon_size(IconSize::Small)
3940 .icon_color(Color::Muted)
3941 .tooltip({
3942 let focus_handle = focus_handle.clone();
3943 move |_window, cx| {
3944 Tooltip::for_action_in(
3945 "Archive Thread",
3946 &ArchiveSelectedThread,
3947 &focus_handle,
3948 cx,
3949 )
3950 }
3951 })
3952 .on_click({
3953 let session_id = session_id_for_delete.clone();
3954 cx.listener(move |this, _, window, cx| {
3955 if let Some(ref session_id) = session_id {
3956 this.archive_thread(session_id, window, cx);
3957 }
3958 })
3959 }),
3960 )
3961 })
3962 .on_click({
3963 cx.listener(move |this, _, window, cx| {
3964 this.selection = None;
3965 match &thread_workspace {
3966 ThreadEntryWorkspace::Open(workspace) => {
3967 this.activate_thread(metadata.clone(), workspace, false, window, cx);
3968 }
3969 ThreadEntryWorkspace::Closed {
3970 folder_paths,
3971 project_group_key,
3972 } => {
3973 this.open_workspace_and_activate_thread(
3974 metadata.clone(),
3975 folder_paths.clone(),
3976 project_group_key,
3977 window,
3978 cx,
3979 );
3980 }
3981 }
3982 })
3983 })
3984 .into_any_element()
3985 }
3986
3987 fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
3988 div()
3989 .min_w_0()
3990 .flex_1()
3991 .capture_action(
3992 cx.listener(|this, _: &editor::actions::Newline, window, cx| {
3993 this.editor_confirm(window, cx);
3994 }),
3995 )
3996 .child(self.filter_editor.clone())
3997 }
3998
3999 fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4000 let multi_workspace = self.multi_workspace.upgrade();
4001
4002 let workspace = multi_workspace
4003 .as_ref()
4004 .map(|mw| mw.read(cx).workspace().downgrade());
4005
4006 let focus_handle = workspace
4007 .as_ref()
4008 .and_then(|ws| ws.upgrade())
4009 .map(|w| w.read(cx).focus_handle(cx))
4010 .unwrap_or_else(|| cx.focus_handle());
4011
4012 let window_project_groups: Vec<ProjectGroupKey> = multi_workspace
4013 .as_ref()
4014 .map(|mw| mw.read(cx).project_group_keys())
4015 .unwrap_or_default();
4016
4017 let popover_handle = self.recent_projects_popover_handle.clone();
4018
4019 PopoverMenu::new("sidebar-recent-projects-menu")
4020 .with_handle(popover_handle)
4021 .menu(move |window, cx| {
4022 workspace.as_ref().map(|ws| {
4023 SidebarRecentProjects::popover(
4024 ws.clone(),
4025 window_project_groups.clone(),
4026 focus_handle.clone(),
4027 window,
4028 cx,
4029 )
4030 })
4031 })
4032 .trigger_with_tooltip(
4033 IconButton::new("open-project", IconName::OpenFolder)
4034 .icon_size(IconSize::Small)
4035 .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
4036 |_window, cx| {
4037 Tooltip::for_action(
4038 "Add Project",
4039 &OpenRecent {
4040 create_new_window: false,
4041 },
4042 cx,
4043 )
4044 },
4045 )
4046 .offset(gpui::Point {
4047 x: px(-2.0),
4048 y: px(-2.0),
4049 })
4050 .anchor(gpui::Anchor::BottomRight)
4051 }
4052
4053 fn new_thread_in_group(
4054 &mut self,
4055 _: &NewThreadInGroup,
4056 window: &mut Window,
4057 cx: &mut Context<Self>,
4058 ) {
4059 if let Some(key) = self.selected_group_key() {
4060 self.set_group_expanded(&key, true, cx);
4061 self.selection = None;
4062 if let Some(workspace) = self.workspace_for_group(&key, cx) {
4063 self.create_new_thread(&workspace, window, cx);
4064 } else {
4065 self.open_workspace_and_create_draft(&key, window, cx);
4066 }
4067 } else if let Some(workspace) = self.active_workspace(cx) {
4068 self.create_new_thread(&workspace, window, cx);
4069 }
4070 }
4071
4072 fn create_new_thread(
4073 &mut self,
4074 workspace: &Entity<Workspace>,
4075 window: &mut Window,
4076 cx: &mut Context<Self>,
4077 ) {
4078 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4079 return;
4080 };
4081
4082 multi_workspace.update(cx, |multi_workspace, cx| {
4083 multi_workspace.activate(workspace.clone(), None, window, cx);
4084 });
4085
4086 let draft_id = workspace.update(cx, |workspace, cx| {
4087 let panel = workspace.panel::<AgentPanel>(cx)?;
4088 let draft_id = panel.update(cx, |panel, cx| {
4089 panel.activate_draft(true, window, cx);
4090 panel.active_thread_id(cx)
4091 });
4092 workspace.focus_panel::<AgentPanel>(window, cx);
4093 draft_id
4094 });
4095
4096 if let Some(draft_id) = draft_id {
4097 self.active_entry = Some(ActiveEntry {
4098 thread_id: draft_id,
4099 session_id: None,
4100 workspace: workspace.clone(),
4101 });
4102 }
4103 }
4104
4105 fn selected_group_key(&self) -> Option<ProjectGroupKey> {
4106 let ix = self.selection?;
4107 match self.contents.entries.get(ix) {
4108 Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()),
4109 Some(ListEntry::Thread(_)) => {
4110 (0..ix)
4111 .rev()
4112 .find_map(|i| match self.contents.entries.get(i) {
4113 Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()),
4114 _ => None,
4115 })
4116 }
4117 _ => None,
4118 }
4119 }
4120
4121 fn workspace_for_group(&self, key: &ProjectGroupKey, cx: &App) -> Option<Entity<Workspace>> {
4122 let mw = self.multi_workspace.upgrade()?;
4123 let mw = mw.read(cx);
4124 let active = mw.workspace().clone();
4125 let active_key = active.read(cx).project_group_key(cx);
4126 if active_key == *key {
4127 Some(active)
4128 } else {
4129 mw.workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
4130 }
4131 }
4132
4133 pub(crate) fn activate_or_open_workspace_for_group(
4134 &mut self,
4135 key: &ProjectGroupKey,
4136 window: &mut Window,
4137 cx: &mut Context<Self>,
4138 ) {
4139 let workspace = self
4140 .multi_workspace
4141 .upgrade()
4142 .and_then(|mw| mw.read(cx).last_active_workspace_for_group(key, cx))
4143 .or_else(|| self.workspace_for_group(key, cx));
4144 if let Some(workspace) = workspace {
4145 self.activate_workspace(&workspace, window, cx);
4146 } else {
4147 self.open_workspace_for_group(key, window, cx);
4148 }
4149 self.selection = None;
4150 self.active_entry = None;
4151 }
4152
4153 fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
4154 let multi_workspace = self.multi_workspace.upgrade()?;
4155 let multi_workspace = multi_workspace.read(cx);
4156 Some(multi_workspace.project_group_key_for_workspace(multi_workspace.workspace(), cx))
4157 }
4158
4159 fn active_project_header_position(&self, cx: &App) -> Option<usize> {
4160 let active_key = self.active_project_group_key(cx)?;
4161 self.contents
4162 .project_header_indices
4163 .iter()
4164 .position(|&entry_ix| {
4165 matches!(
4166 &self.contents.entries[entry_ix],
4167 ListEntry::ProjectHeader { key, .. } if *key == active_key
4168 )
4169 })
4170 }
4171
4172 fn cycle_project_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4173 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4174 return;
4175 };
4176
4177 let header_count = self.contents.project_header_indices.len();
4178 if header_count == 0 {
4179 return;
4180 }
4181
4182 let current_pos = self.active_project_header_position(cx);
4183
4184 let next_pos = match current_pos {
4185 Some(pos) => {
4186 if forward {
4187 (pos + 1) % header_count
4188 } else {
4189 (pos + header_count - 1) % header_count
4190 }
4191 }
4192 None => 0,
4193 };
4194
4195 let header_entry_ix = self.contents.project_header_indices[next_pos];
4196 let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_entry_ix)
4197 else {
4198 return;
4199 };
4200 let key = key.clone();
4201
4202 // Uncollapse the target group so that threads become visible.
4203 self.set_group_expanded(&key, true, cx);
4204
4205 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
4206 mw.read(cx)
4207 .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
4208 }) {
4209 multi_workspace.update(cx, |multi_workspace, cx| {
4210 multi_workspace.activate(workspace, None, window, cx);
4211 multi_workspace.retain_active_workspace(cx);
4212 });
4213 } else {
4214 self.open_workspace_for_group(&key, window, cx);
4215 }
4216 }
4217
4218 fn on_next_project(&mut self, _: &NextProject, window: &mut Window, cx: &mut Context<Self>) {
4219 self.cycle_project_impl(true, window, cx);
4220 }
4221
4222 fn on_previous_project(
4223 &mut self,
4224 _: &PreviousProject,
4225 window: &mut Window,
4226 cx: &mut Context<Self>,
4227 ) {
4228 self.cycle_project_impl(false, window, cx);
4229 }
4230
4231 fn cycle_thread_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4232 let thread_indices: Vec<usize> = self
4233 .contents
4234 .entries
4235 .iter()
4236 .enumerate()
4237 .filter_map(|(ix, entry)| match entry {
4238 ListEntry::Thread(_) => Some(ix),
4239 _ => None,
4240 })
4241 .collect();
4242
4243 if thread_indices.is_empty() {
4244 return;
4245 }
4246
4247 let current_thread_pos = self.active_entry.as_ref().and_then(|active| {
4248 thread_indices
4249 .iter()
4250 .position(|&ix| active.matches_entry(&self.contents.entries[ix]))
4251 });
4252
4253 let next_pos = match current_thread_pos {
4254 Some(pos) => {
4255 let count = thread_indices.len();
4256 if forward {
4257 (pos + 1) % count
4258 } else {
4259 (pos + count - 1) % count
4260 }
4261 }
4262 None => 0,
4263 };
4264
4265 let entry_ix = thread_indices[next_pos];
4266 let ListEntry::Thread(thread) = &self.contents.entries[entry_ix] else {
4267 return;
4268 };
4269
4270 let metadata = thread.metadata.clone();
4271 match &thread.workspace {
4272 ThreadEntryWorkspace::Open(workspace) => {
4273 let workspace = workspace.clone();
4274 self.activate_thread(metadata, &workspace, true, window, cx);
4275 }
4276 ThreadEntryWorkspace::Closed {
4277 folder_paths,
4278 project_group_key,
4279 } => {
4280 let folder_paths = folder_paths.clone();
4281 let project_group_key = project_group_key.clone();
4282 self.open_workspace_and_activate_thread(
4283 metadata,
4284 folder_paths,
4285 &project_group_key,
4286 window,
4287 cx,
4288 );
4289 }
4290 }
4291 }
4292
4293 fn on_next_thread(&mut self, _: &NextThread, window: &mut Window, cx: &mut Context<Self>) {
4294 self.cycle_thread_impl(true, window, cx);
4295 }
4296
4297 fn on_previous_thread(
4298 &mut self,
4299 _: &PreviousThread,
4300 window: &mut Window,
4301 cx: &mut Context<Self>,
4302 ) {
4303 self.cycle_thread_impl(false, window, cx);
4304 }
4305
4306 fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
4307 let has_query = self.has_filter_query(cx);
4308 let message = if has_query {
4309 "No threads match your search."
4310 } else {
4311 "No threads yet"
4312 };
4313
4314 v_flex()
4315 .id("sidebar-no-results")
4316 .p_4()
4317 .size_full()
4318 .items_center()
4319 .justify_center()
4320 .child(
4321 Label::new(message)
4322 .size(LabelSize::Small)
4323 .color(Color::Muted),
4324 )
4325 }
4326
4327 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4328 v_flex()
4329 .id("sidebar-empty-state")
4330 .p_4()
4331 .size_full()
4332 .items_center()
4333 .justify_center()
4334 .gap_1()
4335 .track_focus(&self.focus_handle(cx))
4336 .child(
4337 Button::new("open_project", "Open Project")
4338 .full_width()
4339 .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
4340 .on_click(|_, window, cx| {
4341 let side = match AgentSettings::get_global(cx).sidebar_side() {
4342 SidebarSide::Left => "left",
4343 SidebarSide::Right => "right",
4344 };
4345 telemetry::event!("Sidebar Add Project Clicked", side = side);
4346 window.dispatch_action(
4347 Open {
4348 create_new_window: false,
4349 }
4350 .boxed_clone(),
4351 cx,
4352 );
4353 }),
4354 )
4355 .child(
4356 h_flex()
4357 .w_1_2()
4358 .gap_2()
4359 .child(Divider::horizontal().color(ui::DividerColor::Border))
4360 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
4361 .child(Divider::horizontal().color(ui::DividerColor::Border)),
4362 )
4363 .child(
4364 Button::new("clone_repo", "Clone Repository")
4365 .full_width()
4366 .on_click(|_, window, cx| {
4367 window.dispatch_action(git::Clone.boxed_clone(), cx);
4368 }),
4369 )
4370 }
4371
4372 fn render_sidebar_header(
4373 &self,
4374 no_open_projects: bool,
4375 window: &Window,
4376 cx: &mut Context<Self>,
4377 ) -> impl IntoElement {
4378 let has_query = self.has_filter_query(cx);
4379 let sidebar_on_left = self.side(cx) == SidebarSide::Left;
4380 let sidebar_on_right = self.side(cx) == SidebarSide::Right;
4381 let not_fullscreen = !window.is_fullscreen();
4382 let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4383 let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4384 let right_window_controls =
4385 !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
4386 let header_height = platform_title_bar_height(window);
4387
4388 h_flex()
4389 .h(header_height)
4390 .mt_px()
4391 .pb_px()
4392 .when(left_window_controls, |this| {
4393 this.children(Self::render_left_window_controls(window, cx))
4394 })
4395 .map(|this| {
4396 if traffic_lights {
4397 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
4398 } else if !left_window_controls {
4399 this.pl_1p5()
4400 } else {
4401 this
4402 }
4403 })
4404 .when(!right_window_controls, |this| this.pr_1p5())
4405 .gap_1()
4406 .when(!no_open_projects, |this| {
4407 this.border_b_1()
4408 .border_color(cx.theme().colors().border)
4409 .when(traffic_lights, |this| {
4410 this.child(Divider::vertical().color(ui::DividerColor::Border))
4411 })
4412 .child(
4413 div().ml_1().child(
4414 Icon::new(IconName::MagnifyingGlass)
4415 .size(IconSize::Small)
4416 .color(Color::Muted),
4417 ),
4418 )
4419 .child(self.render_filter_input(cx))
4420 .child(
4421 h_flex()
4422 .gap_1()
4423 .when(
4424 self.selection.is_some()
4425 && !self.filter_editor.focus_handle(cx).is_focused(window),
4426 |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
4427 )
4428 .when(has_query, |this| {
4429 this.child(
4430 IconButton::new("clear_filter", IconName::Close)
4431 .icon_size(IconSize::Small)
4432 .tooltip(Tooltip::text("Clear Search"))
4433 .on_click(cx.listener(|this, _, window, cx| {
4434 this.reset_filter_editor_text(window, cx);
4435 this.update_entries(cx);
4436 })),
4437 )
4438 }),
4439 )
4440 })
4441 .when(right_window_controls, |this| {
4442 this.children(Self::render_right_window_controls(window, cx))
4443 })
4444 }
4445
4446 fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4447 platform_title_bar::render_left_window_controls(
4448 cx.button_layout(),
4449 Box::new(CloseWindow),
4450 window,
4451 )
4452 }
4453
4454 fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4455 platform_title_bar::render_right_window_controls(
4456 cx.button_layout(),
4457 Box::new(CloseWindow),
4458 window,
4459 )
4460 }
4461
4462 fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
4463 let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
4464
4465 sidebar_side_context_menu("sidebar-toggle-menu", _cx)
4466 .anchor(if on_right {
4467 gpui::Anchor::BottomRight
4468 } else {
4469 gpui::Anchor::BottomLeft
4470 })
4471 .attach(if on_right {
4472 gpui::Anchor::TopRight
4473 } else {
4474 gpui::Anchor::TopLeft
4475 })
4476 .trigger(move |_is_active, _window, _cx| {
4477 let icon = if on_right {
4478 IconName::ThreadsSidebarRightOpen
4479 } else {
4480 IconName::ThreadsSidebarLeftOpen
4481 };
4482 IconButton::new("sidebar-close-toggle", icon)
4483 .icon_size(IconSize::Small)
4484 .tooltip(Tooltip::element(move |_window, cx| {
4485 v_flex()
4486 .gap_1()
4487 .child(
4488 h_flex()
4489 .gap_2()
4490 .justify_between()
4491 .child(Label::new("Toggle Sidebar"))
4492 .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
4493 )
4494 .child(
4495 h_flex()
4496 .pt_1()
4497 .gap_2()
4498 .border_t_1()
4499 .border_color(cx.theme().colors().border_variant)
4500 .justify_between()
4501 .child(Label::new("Focus Sidebar"))
4502 .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
4503 )
4504 .into_any_element()
4505 }))
4506 .on_click(|_, window, cx| {
4507 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
4508 multi_workspace.update(cx, |multi_workspace, cx| {
4509 multi_workspace.close_sidebar(window, cx);
4510 });
4511 }
4512 })
4513 })
4514 }
4515
4516 fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4517 let is_archive = matches!(self.view, SidebarView::Archive(..));
4518 let on_right = self.side(cx) == SidebarSide::Right;
4519
4520 h_flex()
4521 .p_1()
4522 .gap_1()
4523 .when(on_right, |this| this.flex_row_reverse())
4524 .border_t_1()
4525 .border_color(cx.theme().colors().border)
4526 .child(self.render_sidebar_toggle_button(cx))
4527 .child(
4528 IconButton::new("history", IconName::Clock)
4529 .icon_size(IconSize::Small)
4530 .toggle_state(is_archive)
4531 .tooltip(move |_, cx| {
4532 let label = if is_archive {
4533 "Hide Thread History"
4534 } else {
4535 "Show Thread History"
4536 };
4537 Tooltip::for_action(label, &ToggleThreadHistory, cx)
4538 })
4539 .on_click(cx.listener(|this, _, window, cx| {
4540 this.toggle_archive(&ToggleThreadHistory, window, cx);
4541 })),
4542 )
4543 .child(div().flex_1())
4544 .child(self.render_recent_projects_button(cx))
4545 }
4546
4547 fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
4548 self.multi_workspace
4549 .upgrade()
4550 .map(|w| w.read(cx).workspace().clone())
4551 }
4552
4553 fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4554 let Some(active_workspace) = self.active_workspace(cx) else {
4555 return;
4556 };
4557
4558 let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
4559 return;
4560 };
4561
4562 let agent_server_store = active_workspace
4563 .read(cx)
4564 .project()
4565 .read(cx)
4566 .agent_server_store()
4567 .clone();
4568
4569 let workspace_handle = active_workspace.downgrade();
4570 let multi_workspace = self.multi_workspace.clone();
4571
4572 active_workspace.update(cx, |workspace, cx| {
4573 workspace.toggle_modal(window, cx, |window, cx| {
4574 ThreadImportModal::new(
4575 agent_server_store,
4576 agent_registry_store,
4577 workspace_handle.clone(),
4578 multi_workspace.clone(),
4579 window,
4580 cx,
4581 )
4582 });
4583 });
4584 }
4585
4586 fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
4587 let has_external_agents = self
4588 .active_workspace(cx)
4589 .map(|ws| {
4590 ws.read(cx)
4591 .project()
4592 .read(cx)
4593 .agent_server_store()
4594 .read(cx)
4595 .has_external_agents()
4596 })
4597 .unwrap_or(false);
4598
4599 has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
4600 }
4601
4602 fn render_acp_import_onboarding(
4603 &mut self,
4604 verbose_labels: bool,
4605 cx: &mut Context<Self>,
4606 ) -> impl IntoElement {
4607 let on_import = cx.listener(|this, _, window, cx| {
4608 this.show_archive(window, cx);
4609 this.show_thread_import_modal(window, cx);
4610 });
4611 render_import_onboarding_banner(
4612 "acp",
4613 "Looking for threads from external agents?",
4614 "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.",
4615 if verbose_labels {
4616 "Import Threads from External Agents"
4617 } else {
4618 "Import Threads"
4619 },
4620 |_, _window, cx| AcpThreadImportOnboarding::dismiss(cx),
4621 on_import,
4622 cx,
4623 )
4624 }
4625
4626 fn should_render_cross_channel_import_onboarding(&self, cx: &App) -> bool {
4627 !CrossChannelImportOnboarding::dismissed(cx) && !channels_with_threads(cx).is_empty()
4628 }
4629
4630 fn render_cross_channel_import_onboarding(
4631 &mut self,
4632 verbose_labels: bool,
4633 cx: &mut Context<Self>,
4634 ) -> impl IntoElement {
4635 let channels = channels_with_threads(cx);
4636 let channel_names = channels
4637 .iter()
4638 .map(|channel| channel.display_name())
4639 .collect::<Vec<_>>()
4640 .join(" and ");
4641
4642 let description = format!(
4643 "Import threads from {} to continue where you left off.",
4644 channel_names
4645 );
4646
4647 let on_import = cx.listener(|this, _, _window, cx| {
4648 CrossChannelImportOnboarding::dismiss(cx);
4649 if let Some(workspace) = this.active_workspace(cx) {
4650 workspace.update(cx, |workspace, cx| {
4651 import_threads_from_other_channels(workspace, cx);
4652 });
4653 }
4654 });
4655 render_import_onboarding_banner(
4656 "channel",
4657 "Threads found from other channels",
4658 description,
4659 if verbose_labels {
4660 "Import Threads from Other Channels"
4661 } else {
4662 "Import Threads"
4663 },
4664 |_, _window, cx| CrossChannelImportOnboarding::dismiss(cx),
4665 on_import,
4666 cx,
4667 )
4668 }
4669
4670 fn toggle_archive(
4671 &mut self,
4672 _: &ToggleThreadHistory,
4673 window: &mut Window,
4674 cx: &mut Context<Self>,
4675 ) {
4676 match &self.view {
4677 SidebarView::ThreadList => {
4678 let side = match self.side(cx) {
4679 SidebarSide::Left => "left",
4680 SidebarSide::Right => "right",
4681 };
4682 telemetry::event!("Thread History Viewed", side = side);
4683 self.show_archive(window, cx);
4684 }
4685 SidebarView::Archive(_) => self.show_thread_list(window, cx),
4686 }
4687 }
4688
4689 fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4690 let Some(active_workspace) = self
4691 .multi_workspace
4692 .upgrade()
4693 .map(|w| w.read(cx).workspace().clone())
4694 else {
4695 return;
4696 };
4697 let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
4698 return;
4699 };
4700
4701 let agent_server_store = active_workspace
4702 .read(cx)
4703 .project()
4704 .read(cx)
4705 .agent_server_store()
4706 .downgrade();
4707
4708 let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
4709
4710 let archive_view = cx.new(|cx| {
4711 ThreadsArchiveView::new(
4712 active_workspace.downgrade(),
4713 agent_connection_store.clone(),
4714 agent_server_store.clone(),
4715 window,
4716 cx,
4717 )
4718 });
4719
4720 let subscription = cx.subscribe_in(
4721 &archive_view,
4722 window,
4723 |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
4724 ThreadsArchiveViewEvent::Close => {
4725 this.show_thread_list(window, cx);
4726 }
4727 ThreadsArchiveViewEvent::Activate { thread } => {
4728 this.open_thread_from_archive(thread.clone(), window, cx);
4729 }
4730 ThreadsArchiveViewEvent::CancelRestore { thread_id } => {
4731 this.restoring_tasks.remove(thread_id);
4732 }
4733 ThreadsArchiveViewEvent::Import => {
4734 this.show_thread_import_modal(window, cx);
4735 }
4736 },
4737 );
4738
4739 self._subscriptions.push(subscription);
4740 self.view = SidebarView::Archive(archive_view.clone());
4741 archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
4742 self.serialize(cx);
4743 cx.notify();
4744 }
4745
4746 fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4747 self.view = SidebarView::ThreadList;
4748 self._subscriptions.clear();
4749 let handle = self.filter_editor.read(cx).focus_handle(cx);
4750 handle.focus(window, cx);
4751 self.serialize(cx);
4752 cx.notify();
4753 }
4754}
4755
4756fn render_import_onboarding_banner(
4757 id: impl Into<SharedString>,
4758 title: impl Into<SharedString>,
4759 description: impl Into<SharedString>,
4760 button_label: impl Into<SharedString>,
4761 on_dismiss: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
4762 on_import: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
4763 cx: &App,
4764) -> impl IntoElement {
4765 let id: SharedString = id.into();
4766 let bg = cx.theme().colors().text_accent;
4767
4768 v_flex()
4769 .min_w_0()
4770 .w_full()
4771 .p_2()
4772 .border_t_1()
4773 .border_color(cx.theme().colors().border)
4774 .bg(linear_gradient(
4775 360.,
4776 linear_color_stop(bg.opacity(0.06), 1.),
4777 linear_color_stop(bg.opacity(0.), 0.),
4778 ))
4779 .child(
4780 h_flex()
4781 .min_w_0()
4782 .w_full()
4783 .gap_1()
4784 .justify_between()
4785 .flex_wrap()
4786 .child(Label::new(title).size(LabelSize::Small))
4787 .child(
4788 IconButton::new(
4789 SharedString::from(format!("close-{id}-onboarding")),
4790 IconName::Close,
4791 )
4792 .icon_size(IconSize::Small)
4793 .on_click(on_dismiss),
4794 ),
4795 )
4796 .child(
4797 Label::new(description)
4798 .size(LabelSize::Small)
4799 .color(Color::Muted)
4800 .mb_2(),
4801 )
4802 .child(
4803 Button::new(SharedString::from(format!("import-{id}")), button_label)
4804 .full_width()
4805 .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
4806 .label_size(LabelSize::Small)
4807 .start_icon(
4808 Icon::new(IconName::Download)
4809 .size(IconSize::Small)
4810 .color(Color::Muted),
4811 )
4812 .on_click(on_import),
4813 )
4814}
4815
4816impl WorkspaceSidebar for Sidebar {
4817 fn width(&self, _cx: &App) -> Pixels {
4818 self.width
4819 }
4820
4821 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
4822 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
4823 cx.notify();
4824 }
4825
4826 fn has_notifications(&self, _cx: &App) -> bool {
4827 !self.contents.notified_threads.is_empty()
4828 }
4829
4830 fn is_threads_list_view_active(&self) -> bool {
4831 matches!(self.view, SidebarView::ThreadList)
4832 }
4833
4834 fn side(&self, cx: &App) -> SidebarSide {
4835 AgentSettings::get_global(cx).sidebar_side()
4836 }
4837
4838 fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4839 self.selection = None;
4840 cx.notify();
4841 }
4842
4843 fn toggle_thread_switcher(
4844 &mut self,
4845 select_last: bool,
4846 window: &mut Window,
4847 cx: &mut Context<Self>,
4848 ) {
4849 self.toggle_thread_switcher_impl(select_last, window, cx);
4850 }
4851
4852 fn cycle_project(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4853 self.cycle_project_impl(forward, window, cx);
4854 }
4855
4856 fn cycle_thread(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4857 self.cycle_thread_impl(forward, window, cx);
4858 }
4859
4860 fn serialized_state(&self, _cx: &App) -> Option<String> {
4861 let serialized = SerializedSidebar {
4862 width: Some(f32::from(self.width)),
4863 active_view: match self.view {
4864 SidebarView::ThreadList => SerializedSidebarView::ThreadList,
4865 SidebarView::Archive(_) => SerializedSidebarView::History,
4866 },
4867 };
4868 serde_json::to_string(&serialized).ok()
4869 }
4870
4871 fn restore_serialized_state(
4872 &mut self,
4873 state: &str,
4874 window: &mut Window,
4875 cx: &mut Context<Self>,
4876 ) {
4877 if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
4878 if let Some(width) = serialized.width {
4879 self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
4880 }
4881 if serialized.active_view == SerializedSidebarView::History {
4882 cx.defer_in(window, |this, window, cx| {
4883 this.show_archive(window, cx);
4884 });
4885 }
4886 }
4887 cx.notify();
4888 }
4889}
4890
4891impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
4892
4893impl Focusable for Sidebar {
4894 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4895 self.focus_handle.clone()
4896 }
4897}
4898
4899impl Render for Sidebar {
4900 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4901 let _titlebar_height = ui::utils::platform_title_bar_height(window);
4902 let ui_font = theme_settings::setup_ui_font(window, cx);
4903 let sticky_header = self.render_sticky_header(window, cx);
4904
4905 let color = cx.theme().colors();
4906 let bg = color
4907 .title_bar_background
4908 .blend(color.panel_background.opacity(0.25));
4909
4910 let no_open_projects = !self.contents.has_open_projects;
4911 let no_search_results = self.contents.entries.is_empty();
4912
4913 v_flex()
4914 .id("workspace-sidebar")
4915 .key_context(self.dispatch_context(window, cx))
4916 .track_focus(&self.focus_handle)
4917 .on_action(cx.listener(Self::select_next))
4918 .on_action(cx.listener(Self::select_previous))
4919 .on_action(cx.listener(Self::editor_move_down))
4920 .on_action(cx.listener(Self::editor_move_up))
4921 .on_action(cx.listener(Self::select_first))
4922 .on_action(cx.listener(Self::select_last))
4923 .on_action(cx.listener(Self::confirm))
4924 .on_action(cx.listener(Self::expand_selected_entry))
4925 .on_action(cx.listener(Self::collapse_selected_entry))
4926 .on_action(cx.listener(Self::toggle_selected_fold))
4927 .on_action(cx.listener(Self::fold_all))
4928 .on_action(cx.listener(Self::unfold_all))
4929 .on_action(cx.listener(Self::cancel))
4930 .on_action(cx.listener(Self::archive_selected_thread))
4931 .on_action(cx.listener(Self::new_thread_in_group))
4932 .on_action(cx.listener(Self::toggle_archive))
4933 .on_action(cx.listener(Self::focus_sidebar_filter))
4934 .on_action(cx.listener(Self::on_toggle_thread_switcher))
4935 .on_action(cx.listener(Self::on_next_project))
4936 .on_action(cx.listener(Self::on_previous_project))
4937 .on_action(cx.listener(Self::on_next_thread))
4938 .on_action(cx.listener(Self::on_previous_thread))
4939 .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
4940 this.recent_projects_popover_handle.toggle(window, cx);
4941 }))
4942 .font(ui_font)
4943 .h_full()
4944 .w(self.width)
4945 .bg(bg)
4946 .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
4947 .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
4948 .border_color(color.border)
4949 .map(|this| match &self.view {
4950 SidebarView::ThreadList => this
4951 .child(self.render_sidebar_header(no_open_projects, window, cx))
4952 .map(|this| {
4953 if no_open_projects {
4954 this.child(self.render_empty_state(cx))
4955 } else {
4956 this.child(
4957 v_flex()
4958 .relative()
4959 .flex_1()
4960 .overflow_hidden()
4961 .child(
4962 list(
4963 self.list_state.clone(),
4964 cx.processor(Self::render_list_entry),
4965 )
4966 .flex_1()
4967 .size_full(),
4968 )
4969 .when(no_search_results, |this| {
4970 this.child(self.render_no_results(cx))
4971 })
4972 .when_some(sticky_header, |this, header| this.child(header))
4973 .vertical_scrollbar_for(&self.list_state, window, cx),
4974 )
4975 }
4976 }),
4977 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
4978 })
4979 .map(|this| {
4980 let show_acp = self.should_render_acp_import_onboarding(cx);
4981 let show_cross_channel = self.should_render_cross_channel_import_onboarding(cx);
4982
4983 let verbose = *self
4984 .import_banners_use_verbose_labels
4985 .get_or_insert(show_acp && show_cross_channel);
4986
4987 this.when(show_acp, |this| {
4988 this.child(self.render_acp_import_onboarding(verbose, cx))
4989 })
4990 .when(show_cross_channel, |this| {
4991 this.child(self.render_cross_channel_import_onboarding(verbose, cx))
4992 })
4993 })
4994 .child(self.render_sidebar_bottom_bar(cx))
4995 }
4996}
4997
4998fn all_thread_infos_for_workspace(
4999 workspace: &Entity<Workspace>,
5000 cx: &App,
5001) -> impl Iterator<Item = ActiveThreadInfo> {
5002 let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
5003 return None.into_iter().flatten();
5004 };
5005 let agent_panel = agent_panel.read(cx);
5006 let threads = agent_panel
5007 .conversation_views()
5008 .into_iter()
5009 .filter_map(|conversation_view| {
5010 let has_pending_tool_call = conversation_view
5011 .read(cx)
5012 .root_thread_has_pending_tool_call(cx);
5013 let conversation_thread_id = conversation_view.read(cx).parent_id();
5014 let thread_view = conversation_view.read(cx).root_thread_view()?;
5015 let thread_view_ref = thread_view.read(cx);
5016 let thread = thread_view_ref.thread.read(cx);
5017
5018 let icon = thread_view_ref.agent_icon;
5019 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
5020 let title = thread
5021 .title()
5022 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
5023 let is_title_generating = thread_view_ref
5024 .as_native_thread(cx)
5025 .is_some_and(|native_thread| native_thread.read(cx).is_generating_title());
5026 let session_id = thread.session_id().clone();
5027 let is_background = agent_panel.is_retained_thread(&conversation_thread_id);
5028
5029 let status = if has_pending_tool_call {
5030 AgentThreadStatus::WaitingForConfirmation
5031 } else if thread.had_error() {
5032 AgentThreadStatus::Error
5033 } else {
5034 match thread.status() {
5035 ThreadStatus::Generating => AgentThreadStatus::Running,
5036 ThreadStatus::Idle => AgentThreadStatus::Completed,
5037 }
5038 };
5039
5040 let diff_stats = thread.action_log().read(cx).diff_stats(cx);
5041
5042 Some(ActiveThreadInfo {
5043 session_id,
5044 title,
5045 status,
5046 icon,
5047 icon_from_external_svg,
5048 is_background,
5049 is_title_generating,
5050 diff_stats,
5051 })
5052 });
5053
5054 Some(threads).into_iter().flatten()
5055}
5056
5057pub fn dump_workspace_info(
5058 workspace: &mut Workspace,
5059 _: &DumpWorkspaceInfo,
5060 window: &mut gpui::Window,
5061 cx: &mut gpui::Context<Workspace>,
5062) {
5063 use std::fmt::Write;
5064
5065 let mut output = String::new();
5066 let this_entity = cx.entity();
5067
5068 let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
5069 let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
5070 Some(mw) => mw.read(cx).workspaces().cloned().collect(),
5071 None => vec![this_entity.clone()],
5072 };
5073 let active_workspace = multi_workspace
5074 .as_ref()
5075 .map(|mw| mw.read(cx).workspace().clone());
5076
5077 writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
5078
5079 if let Some(mw) = &multi_workspace {
5080 let keys: Vec<_> = mw.read(cx).project_group_keys();
5081 writeln!(output, "Project group keys ({}):", keys.len()).ok();
5082 for key in keys {
5083 writeln!(output, " - {key:?}").ok();
5084 }
5085 }
5086
5087 writeln!(output).ok();
5088
5089 for (index, ws) in workspaces.iter().enumerate() {
5090 let is_active = active_workspace.as_ref() == Some(ws);
5091 writeln!(
5092 output,
5093 "--- Workspace {index}{} ---",
5094 if is_active { " (active)" } else { "" }
5095 )
5096 .ok();
5097
5098 // project_group_key_for_workspace internally reads the workspace,
5099 // so we can only call it for workspaces other than this_entity
5100 // (which is already being updated).
5101 if let Some(mw) = &multi_workspace {
5102 if *ws == this_entity {
5103 let workspace_key = workspace.project_group_key(cx);
5104 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
5105 } else {
5106 let effective_key = mw.read(cx).project_group_key_for_workspace(ws, cx);
5107 let workspace_key = ws.read(cx).project_group_key(cx);
5108 if effective_key != workspace_key {
5109 writeln!(
5110 output,
5111 "ProjectGroupKey (multi_workspace): {effective_key:?}"
5112 )
5113 .ok();
5114 writeln!(
5115 output,
5116 "ProjectGroupKey (workspace, DISAGREES): {workspace_key:?}"
5117 )
5118 .ok();
5119 } else {
5120 writeln!(output, "ProjectGroupKey: {effective_key:?}").ok();
5121 }
5122 }
5123 } else {
5124 let workspace_key = workspace.project_group_key(cx);
5125 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
5126 }
5127
5128 // The action handler is already inside an update on `this_entity`,
5129 // so we must avoid a nested read/update on that same entity.
5130 if *ws == this_entity {
5131 dump_single_workspace(workspace, &mut output, cx);
5132 } else {
5133 ws.read_with(cx, |ws, cx| {
5134 dump_single_workspace(ws, &mut output, cx);
5135 });
5136 }
5137 }
5138
5139 let project = workspace.project().clone();
5140 cx.spawn_in(window, async move |_this, cx| {
5141 let buffer = project
5142 .update(cx, |project, cx| project.create_buffer(None, false, cx))
5143 .await?;
5144
5145 buffer.update(cx, |buffer, cx| {
5146 buffer.set_text(output, cx);
5147 });
5148
5149 let buffer = cx.new(|cx| {
5150 editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
5151 });
5152
5153 _this.update_in(cx, |workspace, window, cx| {
5154 workspace.add_item_to_active_pane(
5155 Box::new(cx.new(|cx| {
5156 let mut editor =
5157 editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
5158 editor.set_read_only(true);
5159 editor.set_should_serialize(false, cx);
5160 editor.set_breadcrumb_header("Workspace Info".into());
5161 editor
5162 })),
5163 None,
5164 true,
5165 window,
5166 cx,
5167 );
5168 })
5169 })
5170 .detach_and_log_err(cx);
5171}
5172
5173fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
5174 use std::fmt::Write;
5175
5176 let workspace_db_id = workspace.database_id();
5177 match workspace_db_id {
5178 Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
5179 None => writeln!(output, "Workspace DB ID: (none)").ok(),
5180 };
5181
5182 let project = workspace.project().read(cx);
5183
5184 let repos: Vec<_> = project
5185 .repositories(cx)
5186 .values()
5187 .map(|repo| repo.read(cx).snapshot())
5188 .collect();
5189
5190 writeln!(output, "Worktrees:").ok();
5191 for worktree in project.worktrees(cx) {
5192 let worktree = worktree.read(cx);
5193 let abs_path = worktree.abs_path();
5194 let visible = worktree.is_visible();
5195
5196 let repo_info = repos
5197 .iter()
5198 .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
5199
5200 let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
5201 let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
5202 let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
5203
5204 write!(output, " - {}", abs_path.display()).ok();
5205 if !visible {
5206 write!(output, " (hidden)").ok();
5207 }
5208 if let Some(branch) = &branch {
5209 write!(output, " [branch: {branch}]").ok();
5210 }
5211 if is_linked {
5212 if let Some(original) = original_repo_path {
5213 write!(output, " [linked worktree -> {}]", original.display()).ok();
5214 } else {
5215 write!(output, " [linked worktree]").ok();
5216 }
5217 }
5218 writeln!(output).ok();
5219 }
5220
5221 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5222 let panel = panel.read(cx);
5223
5224 let panel_workspace_id = panel.workspace_id();
5225 if panel_workspace_id != workspace_db_id {
5226 writeln!(
5227 output,
5228 " \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
5229 )
5230 .ok();
5231 }
5232
5233 if let Some(thread) = panel.active_agent_thread(cx) {
5234 let thread = thread.read(cx);
5235 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5236 let session_id = thread.session_id();
5237 let status = match thread.status() {
5238 ThreadStatus::Idle => "idle",
5239 ThreadStatus::Generating => "generating",
5240 };
5241 let entry_count = thread.entries().len();
5242 write!(output, "Active thread: {title} (session: {session_id})").ok();
5243 write!(output, " [{status}, {entry_count} entries").ok();
5244 if panel
5245 .active_conversation_view()
5246 .is_some_and(|conversation_view| {
5247 conversation_view
5248 .read(cx)
5249 .root_thread_has_pending_tool_call(cx)
5250 })
5251 {
5252 write!(output, ", awaiting confirmation").ok();
5253 }
5254 writeln!(output, "]").ok();
5255 } else {
5256 writeln!(output, "Active thread: (none)").ok();
5257 }
5258
5259 let background_threads = panel.retained_threads();
5260 if !background_threads.is_empty() {
5261 writeln!(
5262 output,
5263 "Background threads ({}): ",
5264 background_threads.len()
5265 )
5266 .ok();
5267 for (session_id, conversation_view) in background_threads {
5268 if let Some(thread_view) = conversation_view.read(cx).root_thread_view() {
5269 let thread = thread_view.read(cx).thread.read(cx);
5270 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5271 let status = match thread.status() {
5272 ThreadStatus::Idle => "idle",
5273 ThreadStatus::Generating => "generating",
5274 };
5275 let entry_count = thread.entries().len();
5276 write!(output, " - {title} (thread: {session_id:?})").ok();
5277 write!(output, " [{status}, {entry_count} entries").ok();
5278 if conversation_view
5279 .read(cx)
5280 .root_thread_has_pending_tool_call(cx)
5281 {
5282 write!(output, ", awaiting confirmation").ok();
5283 }
5284 writeln!(output, "]").ok();
5285 } else {
5286 writeln!(output, " - (not connected) (thread: {session_id:?})").ok();
5287 }
5288 }
5289 }
5290 } else {
5291 writeln!(output, "Agent panel: not loaded").ok();
5292 }
5293
5294 writeln!(output).ok();
5295}