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