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