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_click(
1673 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
1674 if event.modifiers().secondary() {
1675 this.activate_or_open_workspace_for_group(&key_for_focus, window, cx);
1676 } else {
1677 this.toggle_collapse(&key_for_toggle, window, cx);
1678 }
1679 }),
1680 );
1681
1682 if !is_collapsed && !has_threads {
1683 v_flex()
1684 .w_full()
1685 .child(header)
1686 .child(
1687 h_flex()
1688 .px_2()
1689 .pt_1()
1690 .pb_2()
1691 .gap(px(7.))
1692 .child(Icon::new(IconName::Circle).size(IconSize::Small).color(
1693 Color::Custom(cx.theme().colors().icon_placeholder.opacity(0.1)),
1694 ))
1695 .child(
1696 Label::new("No threads yet")
1697 .size(LabelSize::Small)
1698 .color(Color::Placeholder),
1699 ),
1700 )
1701 .into_any_element()
1702 } else {
1703 header.into_any_element()
1704 }
1705 }
1706
1707 fn render_project_header_ellipsis_menu(
1708 &self,
1709 ix: usize,
1710 id_prefix: &str,
1711 project_group_key: &ProjectGroupKey,
1712 is_active: bool,
1713 has_threads: bool,
1714 group_name: &SharedString,
1715 cx: &mut Context<Self>,
1716 ) -> AnyElement {
1717 let multi_workspace = self.multi_workspace.clone();
1718 let project_group_key = project_group_key.clone();
1719
1720 let show_multi_project_entries = multi_workspace
1721 .read_with(cx, |mw, _| {
1722 project_group_key.host().is_none() && mw.project_group_keys().len() >= 2
1723 })
1724 .unwrap_or(false);
1725
1726 let this = cx.weak_entity();
1727
1728 let trigger_id = SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}"));
1729 let menu_handle = self
1730 .project_header_menu_handles
1731 .get(&ix)
1732 .cloned()
1733 .unwrap_or_default();
1734 let is_menu_open = menu_handle.is_deployed();
1735
1736 PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1737 .with_handle(menu_handle)
1738 .trigger(
1739 IconButton::new(trigger_id, IconName::Ellipsis)
1740 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1741 .icon_size(IconSize::Small)
1742 .when(!is_menu_open, |el| el.visible_on_hover(group_name)),
1743 )
1744 .on_open(Rc::new({
1745 let this = this.clone();
1746 move |_window, cx| {
1747 this.update(cx, |sidebar, cx| {
1748 sidebar.project_header_menu_ix = Some(ix);
1749 cx.notify();
1750 })
1751 .ok();
1752 }
1753 }))
1754 .menu(move |window, cx| {
1755 let multi_workspace = multi_workspace.clone();
1756 let project_group_key = project_group_key.clone();
1757 let this_for_menu = this.clone();
1758
1759 let open_workspaces = multi_workspace
1760 .read_with(cx, |multi_workspace, cx| {
1761 multi_workspace
1762 .workspaces_for_project_group(&project_group_key, cx)
1763 .unwrap_or_default()
1764 })
1765 .unwrap_or_default();
1766
1767 let active_workspace = multi_workspace
1768 .read_with(cx, |multi_workspace, _cx| {
1769 multi_workspace.workspace().clone()
1770 })
1771 .ok();
1772 let workspace_labels: Vec<_> = open_workspaces
1773 .iter()
1774 .map(|workspace| workspace_menu_worktree_labels(workspace, cx))
1775 .collect();
1776 let workspace_is_active: Vec<_> = open_workspaces
1777 .iter()
1778 .map(|workspace| active_workspace.as_ref() == Some(workspace))
1779 .collect();
1780
1781 let menu =
1782 ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
1783 let menu = menu.end_slot_action(Box::new(menu::SecondaryConfirm));
1784 let weak_menu = menu_cx.weak_entity();
1785
1786 let menu = menu.when(show_multi_project_entries, |this| {
1787 this.entry(
1788 "Open Project in New Window",
1789 Some(Box::new(workspace::MoveProjectToNewWindow)),
1790 {
1791 let project_group_key = project_group_key.clone();
1792 let multi_workspace = multi_workspace.clone();
1793 move |window, cx| {
1794 multi_workspace
1795 .update(cx, |multi_workspace, cx| {
1796 multi_workspace
1797 .open_project_group_in_new_window(
1798 &project_group_key,
1799 window,
1800 cx,
1801 )
1802 .detach_and_log_err(cx);
1803 })
1804 .ok();
1805 }
1806 },
1807 )
1808 });
1809
1810 let menu = menu
1811 .custom_entry(
1812 {
1813 move |_window, cx| {
1814 let action = h_flex()
1815 .opacity(0.6)
1816 .children(render_modifiers(
1817 &Modifiers::secondary_key(),
1818 PlatformStyle::platform(),
1819 None,
1820 Some(TextSize::Default.rems(cx).into()),
1821 false,
1822 ))
1823 .child(Label::new("-click").color(Color::Muted));
1824
1825 let label = if has_threads {
1826 "Focus Last Workspace"
1827 } else {
1828 "Focus Workspace"
1829 };
1830
1831 h_flex()
1832 .w_full()
1833 .justify_between()
1834 .gap_4()
1835 .child(
1836 Label::new(label)
1837 .when(is_active, |s| s.color(Color::Disabled)),
1838 )
1839 .child(action)
1840 .into_any_element()
1841 }
1842 },
1843 {
1844 let project_group_key = project_group_key.clone();
1845 let this = this_for_menu.clone();
1846 move |window, cx| {
1847 if is_active {
1848 return;
1849 }
1850 this.update(cx, |sidebar, cx| {
1851 if let Some(workspace) =
1852 sidebar.workspace_for_group(&project_group_key, cx)
1853 {
1854 sidebar.activate_workspace(&workspace, window, cx);
1855 } else {
1856 sidebar.open_workspace_for_group(
1857 &project_group_key,
1858 window,
1859 cx,
1860 );
1861 }
1862 sidebar.selection = None;
1863 sidebar.active_entry = None;
1864 })
1865 .ok();
1866 }
1867 },
1868 )
1869 .selectable(!is_active);
1870
1871 let menu = if open_workspaces.is_empty() {
1872 menu
1873 } else {
1874 let mut menu = menu.separator().header("Open Workspaces");
1875
1876 for (
1877 workspace_index,
1878 ((workspace, workspace_label), is_active_workspace),
1879 ) in open_workspaces
1880 .iter()
1881 .cloned()
1882 .zip(workspace_labels.iter().cloned())
1883 .zip(workspace_is_active.iter().copied())
1884 .enumerate()
1885 {
1886 let activate_multi_workspace = multi_workspace.clone();
1887 let close_multi_workspace = multi_workspace.clone();
1888 let activate_weak_menu = weak_menu.clone();
1889 let close_weak_menu = weak_menu.clone();
1890 let activate_workspace = workspace.clone();
1891 let close_workspace = workspace.clone();
1892
1893 menu = menu.custom_entry(
1894 move |_window, _cx| {
1895 let close_multi_workspace = close_multi_workspace.clone();
1896 let close_weak_menu = close_weak_menu.clone();
1897 let close_workspace = close_workspace.clone();
1898 let label_color = if is_active_workspace {
1899 Color::Accent
1900 } else {
1901 Color::Muted
1902 };
1903 let row_group_name = SharedString::from(format!(
1904 "workspace-menu-row-{workspace_index}"
1905 ));
1906
1907 h_flex()
1908 .w_full()
1909 .group(&row_group_name)
1910 .justify_between()
1911 .gap_2()
1912 .child(h_flex().min_w_0().gap_3().children(
1913 workspace_label.iter().map(|label| {
1914 h_flex()
1915 .min_w_0()
1916 .gap_0p5()
1917 .when_some(label.icon, |this, icon| {
1918 this.child(
1919 Icon::new(icon)
1920 .size(IconSize::XSmall)
1921 .color(label_color),
1922 )
1923 })
1924 .child(
1925 Label::new(label.primary_name.clone())
1926 .color(label_color)
1927 .truncate(),
1928 )
1929 .when_some(
1930 label.secondary_name.clone(),
1931 |this, secondary_name| {
1932 this.child(
1933 Label::new(":")
1934 .color(label_color),
1935 )
1936 .child(
1937 Label::new(secondary_name)
1938 .color(label_color)
1939 .truncate(),
1940 )
1941 },
1942 )
1943 .into_any_element()
1944 }),
1945 ))
1946 .child(
1947 IconButton::new(
1948 ("close-workspace", workspace_index),
1949 IconName::Close,
1950 )
1951 .shape(ui::IconButtonShape::Square)
1952 .style(ButtonStyle::Subtle)
1953 .visible_on_hover(&row_group_name)
1954 .tooltip(Tooltip::text("Close Workspace"))
1955 .on_click(move |_, window, cx| {
1956 cx.stop_propagation();
1957 window.prevent_default();
1958 close_multi_workspace
1959 .update(cx, |multi_workspace, cx| {
1960 multi_workspace
1961 .close_workspace(
1962 &close_workspace,
1963 window,
1964 cx,
1965 )
1966 .detach_and_log_err(cx);
1967 })
1968 .ok();
1969 close_weak_menu
1970 .update(cx, |_, cx| cx.emit(DismissEvent))
1971 .ok();
1972 }),
1973 )
1974 .into_any_element()
1975 },
1976 move |window, cx| {
1977 activate_multi_workspace
1978 .update(cx, |multi_workspace, cx| {
1979 multi_workspace.activate(
1980 activate_workspace.clone(),
1981 None,
1982 window,
1983 cx,
1984 );
1985 })
1986 .ok();
1987 activate_weak_menu
1988 .update(cx, |_, cx| cx.emit(DismissEvent))
1989 .ok();
1990 },
1991 );
1992 }
1993
1994 menu
1995 };
1996
1997 let project_group_key = project_group_key.clone();
1998 let remove_multi_workspace = multi_workspace.clone();
1999 menu.separator()
2000 .entry("Remove Project", None, move |window, cx| {
2001 remove_multi_workspace
2002 .update(cx, |multi_workspace, cx| {
2003 multi_workspace
2004 .remove_project_group(&project_group_key, window, cx)
2005 .detach_and_log_err(cx);
2006 })
2007 .ok();
2008 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
2009 })
2010 });
2011
2012 let this = this.clone();
2013
2014 window
2015 .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
2016 this.update(cx, |sidebar, cx| {
2017 sidebar.project_header_menu_ix = None;
2018 cx.notify();
2019 })
2020 .ok();
2021 })
2022 .detach();
2023
2024 Some(menu)
2025 })
2026 .anchor(gpui::Corner::TopRight)
2027 .offset(gpui::Point {
2028 x: px(0.),
2029 y: px(1.),
2030 })
2031 .into_any_element()
2032 }
2033
2034 fn render_sticky_header(
2035 &self,
2036 window: &mut Window,
2037 cx: &mut Context<Self>,
2038 ) -> Option<AnyElement> {
2039 let scroll_top = self.list_state.logical_scroll_top();
2040
2041 let &header_idx = self
2042 .contents
2043 .project_header_indices
2044 .iter()
2045 .rev()
2046 .find(|&&idx| idx <= scroll_top.item_ix)?;
2047
2048 let needs_sticky = header_idx < scroll_top.item_ix
2049 || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
2050
2051 if !needs_sticky {
2052 return None;
2053 }
2054
2055 let ListEntry::ProjectHeader {
2056 key,
2057 label,
2058 highlight_positions,
2059 has_running_threads,
2060 waiting_thread_count,
2061 is_active,
2062 has_threads,
2063 } = self.contents.entries.get(header_idx)?
2064 else {
2065 return None;
2066 };
2067
2068 let is_focused = self.focus_handle.is_focused(window);
2069 let is_selected = is_focused && self.selection == Some(header_idx);
2070
2071 let has_active_draft = *is_active
2072 && self
2073 .active_workspace(cx)
2074 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
2075 .is_some_and(|panel| {
2076 let panel = panel.read(cx);
2077 panel.active_thread_is_draft(cx) || panel.active_conversation_view().is_none()
2078 });
2079 let header_element = self.render_project_header(
2080 header_idx,
2081 true,
2082 key,
2083 &label,
2084 &highlight_positions,
2085 *has_running_threads,
2086 *waiting_thread_count,
2087 *is_active,
2088 is_selected,
2089 *has_threads,
2090 has_active_draft,
2091 cx,
2092 );
2093
2094 let top_offset = self
2095 .contents
2096 .project_header_indices
2097 .iter()
2098 .find(|&&idx| idx > header_idx)
2099 .and_then(|&next_idx| {
2100 let bounds = self.list_state.bounds_for_item(next_idx)?;
2101 let viewport = self.list_state.viewport_bounds();
2102 let y_in_viewport = bounds.origin.y - viewport.origin.y;
2103 let header_height = bounds.size.height;
2104 (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
2105 })
2106 .unwrap_or(px(0.));
2107
2108 let color = cx.theme().colors();
2109 let background = color
2110 .title_bar_background
2111 .blend(color.panel_background.opacity(0.2));
2112
2113 let element = v_flex()
2114 .absolute()
2115 .top(top_offset)
2116 .left_0()
2117 .w_full()
2118 .bg(background)
2119 .border_b_1()
2120 .border_color(color.border.opacity(0.5))
2121 .child(header_element)
2122 .shadow_xs()
2123 .into_any_element();
2124
2125 Some(element)
2126 }
2127
2128 fn toggle_collapse(
2129 &mut self,
2130 project_group_key: &ProjectGroupKey,
2131 _window: &mut Window,
2132 cx: &mut Context<Self>,
2133 ) {
2134 let is_collapsed = self.is_group_collapsed(project_group_key, cx);
2135 self.set_group_expanded(project_group_key, is_collapsed, cx);
2136 self.update_entries(cx);
2137 }
2138
2139 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
2140 let mut dispatch_context = KeyContext::new_with_defaults();
2141 dispatch_context.add("ThreadsSidebar");
2142 dispatch_context.add("menu");
2143
2144 let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx));
2145
2146 let identifier = if self.filter_editor.focus_handle(cx).is_focused(window)
2147 || is_archived_search_focused
2148 {
2149 "searching"
2150 } else {
2151 "not_searching"
2152 };
2153
2154 dispatch_context.add(identifier);
2155 dispatch_context
2156 }
2157
2158 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2159 if !self.focus_handle.is_focused(window) {
2160 return;
2161 }
2162
2163 if let SidebarView::Archive(archive) = &self.view {
2164 let has_selection = archive.read(cx).has_selection();
2165 if !has_selection {
2166 archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
2167 }
2168 } else if self.selection.is_none() {
2169 self.filter_editor.focus_handle(cx).focus(window, cx);
2170 }
2171 }
2172
2173 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
2174 if self.reset_filter_editor_text(window, cx) {
2175 self.update_entries(cx);
2176 } else {
2177 self.selection = None;
2178 self.filter_editor.focus_handle(cx).focus(window, cx);
2179 cx.notify();
2180 }
2181 }
2182
2183 fn focus_sidebar_filter(
2184 &mut self,
2185 _: &FocusSidebarFilter,
2186 window: &mut Window,
2187 cx: &mut Context<Self>,
2188 ) {
2189 self.selection = None;
2190 if let SidebarView::Archive(archive) = &self.view {
2191 archive.update(cx, |view, cx| {
2192 view.clear_selection();
2193 view.focus_filter_editor(window, cx);
2194 });
2195 } else {
2196 self.filter_editor.focus_handle(cx).focus(window, cx);
2197 }
2198
2199 // When vim mode is active, the editor defaults to normal mode which
2200 // blocks text input. Switch to insert mode so the user can type
2201 // immediately.
2202 if vim_mode_setting::VimModeSetting::get_global(cx).0 {
2203 if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
2204 window.dispatch_action(action, cx);
2205 }
2206 }
2207
2208 cx.notify();
2209 }
2210
2211 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2212 self.filter_editor.update(cx, |editor, cx| {
2213 if editor.buffer().read(cx).len(cx).0 > 0 {
2214 editor.set_text("", window, cx);
2215 true
2216 } else {
2217 false
2218 }
2219 })
2220 }
2221
2222 fn has_filter_query(&self, cx: &App) -> bool {
2223 !self.filter_editor.read(cx).text(cx).is_empty()
2224 }
2225
2226 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
2227 self.select_next(&SelectNext, window, cx);
2228 if self.selection.is_some() {
2229 self.focus_handle.focus(window, cx);
2230 }
2231 }
2232
2233 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
2234 self.select_previous(&SelectPrevious, window, cx);
2235 if self.selection.is_some() {
2236 self.focus_handle.focus(window, cx);
2237 }
2238 }
2239
2240 fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2241 if self.selection.is_none() {
2242 self.select_next(&SelectNext, window, cx);
2243 }
2244 if self.selection.is_some() {
2245 self.focus_handle.focus(window, cx);
2246 }
2247 }
2248
2249 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
2250 let next = match self.selection {
2251 Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
2252 Some(_) if !self.contents.entries.is_empty() => 0,
2253 None if !self.contents.entries.is_empty() => 0,
2254 _ => return,
2255 };
2256 self.selection = Some(next);
2257 self.list_state.scroll_to_reveal_item(next);
2258 cx.notify();
2259 }
2260
2261 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
2262 match self.selection {
2263 Some(0) => {
2264 self.selection = None;
2265 self.filter_editor.focus_handle(cx).focus(window, cx);
2266 cx.notify();
2267 }
2268 Some(ix) => {
2269 self.selection = Some(ix - 1);
2270 self.list_state.scroll_to_reveal_item(ix - 1);
2271 cx.notify();
2272 }
2273 None if !self.contents.entries.is_empty() => {
2274 let last = self.contents.entries.len() - 1;
2275 self.selection = Some(last);
2276 self.list_state.scroll_to_reveal_item(last);
2277 cx.notify();
2278 }
2279 None => {}
2280 }
2281 }
2282
2283 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
2284 if !self.contents.entries.is_empty() {
2285 self.selection = Some(0);
2286 self.list_state.scroll_to_reveal_item(0);
2287 cx.notify();
2288 }
2289 }
2290
2291 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
2292 if let Some(last) = self.contents.entries.len().checked_sub(1) {
2293 self.selection = Some(last);
2294 self.list_state.scroll_to_reveal_item(last);
2295 cx.notify();
2296 }
2297 }
2298
2299 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
2300 let Some(ix) = self.selection else { return };
2301 let Some(entry) = self.contents.entries.get(ix) else {
2302 return;
2303 };
2304
2305 match entry {
2306 ListEntry::ProjectHeader { key, .. } => {
2307 let key = key.clone();
2308 self.toggle_collapse(&key, window, cx);
2309 }
2310 ListEntry::Thread(thread) => {
2311 let metadata = thread.metadata.clone();
2312 match &thread.workspace {
2313 ThreadEntryWorkspace::Open(workspace) => {
2314 let workspace = workspace.clone();
2315 self.activate_thread(metadata, &workspace, false, window, cx);
2316 }
2317 ThreadEntryWorkspace::Closed {
2318 folder_paths,
2319 project_group_key,
2320 } => {
2321 let folder_paths = folder_paths.clone();
2322 let project_group_key = project_group_key.clone();
2323 self.open_workspace_and_activate_thread(
2324 metadata,
2325 folder_paths,
2326 &project_group_key,
2327 window,
2328 cx,
2329 );
2330 }
2331 }
2332 }
2333 }
2334 }
2335
2336 fn find_workspace_across_windows(
2337 &self,
2338 cx: &App,
2339 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2340 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2341 cx.windows()
2342 .into_iter()
2343 .filter_map(|window| window.downcast::<MultiWorkspace>())
2344 .find_map(|window| {
2345 let workspace = window.read(cx).ok().and_then(|multi_workspace| {
2346 multi_workspace
2347 .workspaces()
2348 .find(|workspace| predicate(workspace, cx))
2349 .cloned()
2350 })?;
2351 Some((window, workspace))
2352 })
2353 }
2354
2355 fn find_workspace_in_current_window(
2356 &self,
2357 cx: &App,
2358 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2359 ) -> Option<Entity<Workspace>> {
2360 self.multi_workspace.upgrade().and_then(|multi_workspace| {
2361 multi_workspace
2362 .read(cx)
2363 .workspaces()
2364 .find(|workspace| predicate(workspace, cx))
2365 .cloned()
2366 })
2367 }
2368
2369 fn load_agent_thread_in_workspace(
2370 workspace: &Entity<Workspace>,
2371 metadata: &ThreadMetadata,
2372 focus: bool,
2373 window: &mut Window,
2374 cx: &mut App,
2375 ) {
2376 let load_thread = |agent_panel: Entity<AgentPanel>,
2377 metadata: &ThreadMetadata,
2378 focus: bool,
2379 window: &mut Window,
2380 cx: &mut App| {
2381 let Some(session_id) = metadata.session_id.clone() else {
2382 return;
2383 };
2384 agent_panel.update(cx, |panel, cx| {
2385 panel.load_agent_thread(
2386 Agent::from(metadata.agent_id.clone()),
2387 session_id,
2388 Some(metadata.folder_paths().clone()),
2389 metadata.title.clone(),
2390 focus,
2391 "sidebar",
2392 window,
2393 cx,
2394 );
2395 });
2396 };
2397
2398 let mut existing_panel = None;
2399 workspace.update(cx, |workspace, cx| {
2400 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2401 existing_panel = Some(panel);
2402 }
2403 });
2404
2405 if let Some(agent_panel) = existing_panel {
2406 load_thread(agent_panel, metadata, focus, window, cx);
2407 workspace.update(cx, |workspace, cx| {
2408 if focus {
2409 workspace.focus_panel::<AgentPanel>(window, cx);
2410 } else {
2411 workspace.reveal_panel::<AgentPanel>(window, cx);
2412 }
2413 });
2414 return;
2415 }
2416
2417 let workspace = workspace.downgrade();
2418 let metadata = metadata.clone();
2419 let mut async_window_cx = window.to_async(cx);
2420 cx.spawn(async move |_cx| {
2421 let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?;
2422
2423 workspace.update_in(&mut async_window_cx, |workspace, window, cx| {
2424 let panel = workspace.panel::<AgentPanel>(cx).unwrap_or_else(|| {
2425 workspace.add_panel(panel.clone(), window, cx);
2426 panel.clone()
2427 });
2428 load_thread(panel, &metadata, focus, window, cx);
2429 if focus {
2430 workspace.focus_panel::<AgentPanel>(window, cx);
2431 } else {
2432 workspace.reveal_panel::<AgentPanel>(window, cx);
2433 }
2434 })?;
2435
2436 anyhow::Ok(())
2437 })
2438 .detach_and_log_err(cx);
2439 }
2440
2441 fn activate_thread_locally(
2442 &mut self,
2443 metadata: &ThreadMetadata,
2444 workspace: &Entity<Workspace>,
2445 retain: bool,
2446 window: &mut Window,
2447 cx: &mut Context<Self>,
2448 ) {
2449 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2450 return;
2451 };
2452
2453 // Set active_entry eagerly so the sidebar highlight updates
2454 // immediately, rather than waiting for a deferred AgentPanel
2455 // event which can race with ActiveWorkspaceChanged clearing it.
2456 self.active_entry = Some(ActiveEntry {
2457 thread_id: metadata.thread_id,
2458 session_id: metadata.session_id.clone(),
2459 workspace: workspace.clone(),
2460 });
2461 self.record_thread_access(&metadata.thread_id);
2462
2463 if metadata.session_id.is_some() {
2464 self.pending_thread_activation = Some(metadata.thread_id);
2465 }
2466
2467 multi_workspace.update(cx, |multi_workspace, cx| {
2468 multi_workspace.activate(workspace.clone(), None, window, cx);
2469 if retain {
2470 multi_workspace.retain_active_workspace(cx);
2471 }
2472 });
2473
2474 // Drafts (and other retained threads without a session_id) are
2475 // already in memory — activate them directly instead of loading.
2476 let thread_id = metadata.thread_id;
2477 if metadata.session_id.is_none() {
2478 workspace.update(cx, |ws, cx| {
2479 if let Some(panel) = ws.panel::<AgentPanel>(cx) {
2480 panel.update(cx, |panel, cx| {
2481 panel.activate_retained_thread(thread_id, true, window, cx);
2482 });
2483 }
2484 ws.focus_panel::<AgentPanel>(window, cx);
2485 });
2486 self.pending_thread_activation = None;
2487 } else {
2488 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
2489 }
2490
2491 self.update_entries(cx);
2492 }
2493
2494 fn activate_thread_in_other_window(
2495 &self,
2496 metadata: ThreadMetadata,
2497 workspace: Entity<Workspace>,
2498 target_window: WindowHandle<MultiWorkspace>,
2499 cx: &mut Context<Self>,
2500 ) {
2501 let target_session_id = metadata.session_id.clone();
2502 let metadata_thread_id = metadata.thread_id;
2503 let workspace_for_entry = workspace.clone();
2504
2505 let activated = target_window
2506 .update(cx, |multi_workspace, window, cx| {
2507 window.activate_window();
2508 multi_workspace.activate(workspace.clone(), None, window, cx);
2509 Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
2510 })
2511 .log_err()
2512 .is_some();
2513
2514 if activated {
2515 if let Some(target_sidebar) = target_window
2516 .read(cx)
2517 .ok()
2518 .and_then(|multi_workspace| {
2519 multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2520 })
2521 .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2522 {
2523 target_sidebar.update(cx, |sidebar, cx| {
2524 sidebar.pending_thread_activation = Some(metadata_thread_id);
2525 sidebar.active_entry = Some(ActiveEntry {
2526 thread_id: metadata_thread_id,
2527 session_id: target_session_id.clone(),
2528 workspace: workspace_for_entry.clone(),
2529 });
2530 sidebar.record_thread_access(&metadata_thread_id);
2531 sidebar.update_entries(cx);
2532 });
2533 }
2534 }
2535 }
2536
2537 fn activate_thread(
2538 &mut self,
2539 metadata: ThreadMetadata,
2540 workspace: &Entity<Workspace>,
2541 retain: bool,
2542 window: &mut Window,
2543 cx: &mut Context<Self>,
2544 ) {
2545 if self
2546 .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2547 .is_some()
2548 {
2549 self.activate_thread_locally(&metadata, &workspace, retain, window, cx);
2550 return;
2551 }
2552
2553 let Some((target_window, workspace)) =
2554 self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2555 else {
2556 return;
2557 };
2558
2559 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2560 }
2561
2562 fn open_workspace_and_activate_thread(
2563 &mut self,
2564 metadata: ThreadMetadata,
2565 folder_paths: PathList,
2566 project_group_key: &ProjectGroupKey,
2567 window: &mut Window,
2568 cx: &mut Context<Self>,
2569 ) {
2570 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2571 return;
2572 };
2573
2574 let pending_thread_id = metadata.thread_id;
2575 // Mark the pending thread activation so rebuild_contents
2576 // preserves the Thread active_entry during loading and
2577 // reconciliation cannot synthesize an empty fallback draft.
2578 self.pending_thread_activation = Some(pending_thread_id);
2579
2580 let host = project_group_key.host();
2581 let provisional_key = Some(project_group_key.clone());
2582 let active_workspace = multi_workspace.read(cx).workspace().clone();
2583 let modal_workspace = active_workspace.clone();
2584
2585 let open_task = multi_workspace.update(cx, |this, cx| {
2586 this.find_or_create_workspace(
2587 folder_paths,
2588 host,
2589 provisional_key,
2590 |options, window, cx| connect_remote(active_workspace, options, window, cx),
2591 &[],
2592 None,
2593 OpenMode::Activate,
2594 window,
2595 cx,
2596 )
2597 });
2598
2599 cx.spawn_in(window, async move |this, cx| {
2600 let result = open_task.await;
2601 // Dismiss the modal as soon as the open attempt completes so
2602 // failures or cancellations do not leave a stale connection modal behind.
2603 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
2604
2605 if result.is_err() {
2606 this.update(cx, |this, _cx| {
2607 if this.pending_thread_activation == Some(pending_thread_id) {
2608 this.pending_thread_activation = None;
2609 }
2610 })
2611 .ok();
2612 }
2613
2614 let workspace = result?;
2615 this.update_in(cx, |this, window, cx| {
2616 this.activate_thread(metadata, &workspace, false, window, cx);
2617 })?;
2618 anyhow::Ok(())
2619 })
2620 .detach_and_log_err(cx);
2621 }
2622
2623 fn find_current_workspace_for_path_list(
2624 &self,
2625 path_list: &PathList,
2626 remote_connection: Option<&RemoteConnectionOptions>,
2627 cx: &App,
2628 ) -> Option<Entity<Workspace>> {
2629 self.find_workspace_in_current_window(cx, |workspace, cx| {
2630 workspace_path_list(workspace, cx).paths() == path_list.paths()
2631 && same_remote_connection_identity(
2632 workspace
2633 .read(cx)
2634 .project()
2635 .read(cx)
2636 .remote_connection_options(cx)
2637 .as_ref(),
2638 remote_connection,
2639 )
2640 })
2641 }
2642
2643 fn find_open_workspace_for_path_list(
2644 &self,
2645 path_list: &PathList,
2646 remote_connection: Option<&RemoteConnectionOptions>,
2647 cx: &App,
2648 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2649 self.find_workspace_across_windows(cx, |workspace, cx| {
2650 workspace_path_list(workspace, cx).paths() == path_list.paths()
2651 && same_remote_connection_identity(
2652 workspace
2653 .read(cx)
2654 .project()
2655 .read(cx)
2656 .remote_connection_options(cx)
2657 .as_ref(),
2658 remote_connection,
2659 )
2660 })
2661 }
2662
2663 fn open_thread_from_archive(
2664 &mut self,
2665 metadata: ThreadMetadata,
2666 window: &mut Window,
2667 cx: &mut Context<Self>,
2668 ) {
2669 let thread_id = metadata.thread_id;
2670 let weak_archive_view = match &self.view {
2671 SidebarView::Archive(view) => Some(view.downgrade()),
2672 _ => None,
2673 };
2674
2675 if metadata.folder_paths().paths().is_empty() {
2676 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.unarchive(thread_id, cx));
2677
2678 let active_workspace = self
2679 .multi_workspace
2680 .upgrade()
2681 .map(|w| w.read(cx).workspace().clone());
2682
2683 if let Some(workspace) = active_workspace {
2684 self.activate_thread_locally(&metadata, &workspace, false, window, cx);
2685 } else {
2686 let path_list = metadata.folder_paths().clone();
2687 if let Some((target_window, workspace)) = self.find_open_workspace_for_path_list(
2688 &path_list,
2689 metadata.remote_connection.as_ref(),
2690 cx,
2691 ) {
2692 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2693 } else {
2694 let key = ProjectGroupKey::from_worktree_paths(
2695 &metadata.worktree_paths,
2696 metadata.remote_connection.clone(),
2697 );
2698 self.open_workspace_and_activate_thread(metadata, path_list, &key, window, cx);
2699 }
2700 }
2701 self.show_thread_list(window, cx);
2702 return;
2703 }
2704
2705 let store = ThreadMetadataStore::global(cx);
2706 let task = if metadata.archived {
2707 store
2708 .read(cx)
2709 .get_archived_worktrees_for_thread(thread_id, cx)
2710 } else {
2711 Task::ready(Ok(Vec::new()))
2712 };
2713 let path_list = metadata.folder_paths().clone();
2714
2715 let restore_task = cx.spawn_in(window, async move |this, cx| {
2716 let result: anyhow::Result<()> = async {
2717 let archived_worktrees = task.await?;
2718
2719 if archived_worktrees.is_empty() {
2720 this.update_in(cx, |this, window, cx| {
2721 this.restoring_tasks.remove(&thread_id);
2722 if metadata.archived {
2723 ThreadMetadataStore::global(cx)
2724 .update(cx, |store, cx| store.unarchive(thread_id, cx));
2725 }
2726
2727 if let Some(workspace) = this.find_current_workspace_for_path_list(
2728 &path_list,
2729 metadata.remote_connection.as_ref(),
2730 cx,
2731 ) {
2732 this.activate_thread_locally(&metadata, &workspace, false, window, cx);
2733 } else if let Some((target_window, workspace)) = this
2734 .find_open_workspace_for_path_list(
2735 &path_list,
2736 metadata.remote_connection.as_ref(),
2737 cx,
2738 )
2739 {
2740 this.activate_thread_in_other_window(
2741 metadata,
2742 workspace,
2743 target_window,
2744 cx,
2745 );
2746 } else {
2747 let key = ProjectGroupKey::from_worktree_paths(
2748 &metadata.worktree_paths,
2749 metadata.remote_connection.clone(),
2750 );
2751 this.open_workspace_and_activate_thread(
2752 metadata, path_list, &key, window, cx,
2753 );
2754 }
2755 this.show_thread_list(window, cx);
2756 })?;
2757 return anyhow::Ok(());
2758 }
2759
2760 let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new();
2761 for row in &archived_worktrees {
2762 match thread_worktree_archive::restore_worktree_via_git(
2763 row,
2764 metadata.remote_connection.as_ref(),
2765 &mut *cx,
2766 )
2767 .await
2768 {
2769 Ok(restored_path) => {
2770 thread_worktree_archive::cleanup_archived_worktree_record(
2771 row,
2772 metadata.remote_connection.as_ref(),
2773 &mut *cx,
2774 )
2775 .await;
2776 path_replacements.push((row.worktree_path.clone(), restored_path));
2777 }
2778 Err(error) => {
2779 log::error!("Failed to restore worktree: {error:#}");
2780 this.update_in(cx, |this, _window, cx| {
2781 this.restoring_tasks.remove(&thread_id);
2782 if let Some(weak_archive_view) = &weak_archive_view {
2783 weak_archive_view
2784 .update(cx, |view, cx| {
2785 view.clear_restoring(&thread_id, cx);
2786 })
2787 .ok();
2788 }
2789
2790 if let Some(multi_workspace) = this.multi_workspace.upgrade() {
2791 let workspace = multi_workspace.read(cx).workspace().clone();
2792 workspace.update(cx, |workspace, cx| {
2793 struct RestoreWorktreeErrorToast;
2794 workspace.show_toast(
2795 Toast::new(
2796 NotificationId::unique::<RestoreWorktreeErrorToast>(
2797 ),
2798 format!("Failed to restore worktree: {error:#}"),
2799 )
2800 .autohide(),
2801 cx,
2802 );
2803 });
2804 }
2805 })
2806 .ok();
2807 return anyhow::Ok(());
2808 }
2809 }
2810 }
2811
2812 if !path_replacements.is_empty() {
2813 cx.update(|_window, cx| {
2814 store.update(cx, |store, cx| {
2815 store.update_restored_worktree_paths(thread_id, &path_replacements, cx);
2816 });
2817 })?;
2818
2819 let updated_metadata =
2820 cx.update(|_window, cx| store.read(cx).entry(thread_id).cloned())?;
2821
2822 if let Some(updated_metadata) = updated_metadata {
2823 let new_paths = updated_metadata.folder_paths().clone();
2824 let key = ProjectGroupKey::from_worktree_paths(
2825 &updated_metadata.worktree_paths,
2826 updated_metadata.remote_connection.clone(),
2827 );
2828
2829 cx.update(|_window, cx| {
2830 store.update(cx, |store, cx| {
2831 store.unarchive(updated_metadata.thread_id, cx);
2832 });
2833 })?;
2834
2835 this.update_in(cx, |this, window, cx| {
2836 this.restoring_tasks.remove(&thread_id);
2837 this.open_workspace_and_activate_thread(
2838 updated_metadata,
2839 new_paths,
2840 &key,
2841 window,
2842 cx,
2843 );
2844 this.show_thread_list(window, cx);
2845 })?;
2846 }
2847 }
2848
2849 anyhow::Ok(())
2850 }
2851 .await;
2852 if let Err(error) = result {
2853 log::error!("{error:#}");
2854 }
2855 });
2856 self.restoring_tasks.insert(thread_id, restore_task);
2857 }
2858
2859 fn expand_selected_entry(
2860 &mut self,
2861 _: &SelectChild,
2862 _window: &mut Window,
2863 cx: &mut Context<Self>,
2864 ) {
2865 let Some(ix) = self.selection else { return };
2866
2867 match self.contents.entries.get(ix) {
2868 Some(ListEntry::ProjectHeader { key, .. }) => {
2869 let key = key.clone();
2870 if self.is_group_collapsed(&key, cx) {
2871 self.set_group_expanded(&key, true, cx);
2872 self.update_entries(cx);
2873 } else if ix + 1 < self.contents.entries.len() {
2874 self.selection = Some(ix + 1);
2875 self.list_state.scroll_to_reveal_item(ix + 1);
2876 cx.notify();
2877 }
2878 }
2879 _ => {}
2880 }
2881 }
2882
2883 fn collapse_selected_entry(
2884 &mut self,
2885 _: &SelectParent,
2886 _window: &mut Window,
2887 cx: &mut Context<Self>,
2888 ) {
2889 let Some(ix) = self.selection else { return };
2890
2891 match self.contents.entries.get(ix) {
2892 Some(ListEntry::ProjectHeader { key, .. }) => {
2893 let key = key.clone();
2894 if !self.is_group_collapsed(&key, cx) {
2895 self.set_group_expanded(&key, false, cx);
2896 self.update_entries(cx);
2897 }
2898 }
2899 Some(ListEntry::Thread(_)) => {
2900 for i in (0..ix).rev() {
2901 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
2902 {
2903 let key = key.clone();
2904 self.selection = Some(i);
2905 self.set_group_expanded(&key, false, cx);
2906 self.update_entries(cx);
2907 break;
2908 }
2909 }
2910 }
2911 None => {}
2912 }
2913 }
2914
2915 fn toggle_selected_fold(
2916 &mut self,
2917 _: &editor::actions::ToggleFold,
2918 _window: &mut Window,
2919 cx: &mut Context<Self>,
2920 ) {
2921 let Some(ix) = self.selection else { return };
2922
2923 // Find the group header for the current selection.
2924 let header_ix = match self.contents.entries.get(ix) {
2925 Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2926 Some(ListEntry::Thread(_)) => (0..ix).rev().find(|&i| {
2927 matches!(
2928 self.contents.entries.get(i),
2929 Some(ListEntry::ProjectHeader { .. })
2930 )
2931 }),
2932 None => None,
2933 };
2934
2935 if let Some(header_ix) = header_ix {
2936 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
2937 {
2938 let key = key.clone();
2939 if self.is_group_collapsed(&key, cx) {
2940 self.set_group_expanded(&key, true, cx);
2941 } else {
2942 self.selection = Some(header_ix);
2943 self.set_group_expanded(&key, false, cx);
2944 }
2945 self.update_entries(cx);
2946 }
2947 }
2948 }
2949
2950 fn fold_all(
2951 &mut self,
2952 _: &editor::actions::FoldAll,
2953 _window: &mut Window,
2954 cx: &mut Context<Self>,
2955 ) {
2956 if let Some(mw) = self.multi_workspace.upgrade() {
2957 mw.update(cx, |mw, _cx| {
2958 mw.set_all_groups_expanded(false);
2959 });
2960 }
2961 self.update_entries(cx);
2962 }
2963
2964 fn unfold_all(
2965 &mut self,
2966 _: &editor::actions::UnfoldAll,
2967 _window: &mut Window,
2968 cx: &mut Context<Self>,
2969 ) {
2970 if let Some(mw) = self.multi_workspace.upgrade() {
2971 mw.update(cx, |mw, _cx| {
2972 mw.set_all_groups_expanded(true);
2973 });
2974 }
2975 self.update_entries(cx);
2976 }
2977
2978 fn stop_thread(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut Context<Self>) {
2979 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2980 return;
2981 };
2982
2983 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
2984 for workspace in workspaces {
2985 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2986 let cancelled =
2987 agent_panel.update(cx, |panel, cx| panel.cancel_thread(thread_id, cx));
2988 if cancelled {
2989 return;
2990 }
2991 }
2992 }
2993 }
2994
2995 fn archive_thread(
2996 &mut self,
2997 session_id: &acp::SessionId,
2998 window: &mut Window,
2999 cx: &mut Context<Self>,
3000 ) {
3001 let store = ThreadMetadataStore::global(cx);
3002 let metadata = store.read(cx).entry_by_session(session_id).cloned();
3003 let active_workspace = metadata.as_ref().and_then(|metadata| {
3004 self.active_entry.as_ref().and_then(|entry| {
3005 if entry.is_active_thread(&metadata.thread_id) {
3006 Some(entry.workspace.clone())
3007 } else {
3008 None
3009 }
3010 })
3011 });
3012 let thread_id = metadata.as_ref().map(|metadata| metadata.thread_id);
3013 let thread_folder_paths = metadata
3014 .as_ref()
3015 .map(|metadata| metadata.folder_paths().clone())
3016 .or_else(|| {
3017 active_workspace
3018 .as_ref()
3019 .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)))
3020 });
3021
3022 // Compute which linked worktree roots should be archived from disk if
3023 // this thread is archived. This must happen before we remove any
3024 // workspace from the MultiWorkspace, because `build_root_plan` needs
3025 // the currently open workspaces in order to find the affected projects
3026 // and repository handles for each linked worktree.
3027 let roots_to_archive = metadata
3028 .as_ref()
3029 .map(|metadata| {
3030 let mut workspaces = self
3031 .multi_workspace
3032 .upgrade()
3033 .map(|multi_workspace| {
3034 multi_workspace
3035 .read(cx)
3036 .workspaces()
3037 .cloned()
3038 .collect::<Vec<_>>()
3039 })
3040 .unwrap_or_default();
3041 for workspace in thread_worktree_archive::all_open_workspaces(cx) {
3042 if !workspaces.contains(&workspace) {
3043 workspaces.push(workspace);
3044 }
3045 }
3046 metadata
3047 .folder_paths()
3048 .ordered_paths()
3049 .filter_map(|path| {
3050 thread_worktree_archive::build_root_plan(
3051 path,
3052 metadata.remote_connection.as_ref(),
3053 &workspaces,
3054 cx,
3055 )
3056 })
3057 .filter(|plan| {
3058 thread_id.map_or(true, |tid| {
3059 !store
3060 .read(cx)
3061 .path_is_referenced_by_other_unarchived_threads(
3062 tid,
3063 &plan.root_path,
3064 metadata.remote_connection.as_ref(),
3065 )
3066 })
3067 })
3068 .collect::<Vec<_>>()
3069 })
3070 .unwrap_or_default();
3071
3072 // Find the neighbor thread in the sidebar (by display position).
3073 // Look below first, then above, for the nearest thread that isn't
3074 // the one being archived. We capture both the neighbor's metadata
3075 // (for activation) and its workspace paths (for the workspace
3076 // removal fallback).
3077 let current_pos = self.contents.entries.iter().position(|entry| match entry {
3078 ListEntry::Thread(thread) => thread_id.map_or_else(
3079 || thread.metadata.session_id.as_ref() == Some(session_id),
3080 |tid| thread.metadata.thread_id == tid,
3081 ),
3082 _ => false,
3083 });
3084 let neighbor = current_pos.and_then(|pos| {
3085 self.contents.entries[pos + 1..]
3086 .iter()
3087 .chain(self.contents.entries[..pos].iter().rev())
3088 .find_map(|entry| match entry {
3089 ListEntry::Thread(t) if t.metadata.session_id.as_ref() != Some(session_id) => {
3090 let (workspace_paths, project_group_key) = match &t.workspace {
3091 ThreadEntryWorkspace::Open(ws) => (
3092 PathList::new(&ws.read(cx).root_paths(cx)),
3093 ws.read(cx).project_group_key(cx),
3094 ),
3095 ThreadEntryWorkspace::Closed {
3096 folder_paths,
3097 project_group_key,
3098 } => (folder_paths.clone(), project_group_key.clone()),
3099 };
3100 Some((t.metadata.clone(), workspace_paths, project_group_key))
3101 }
3102 _ => None,
3103 })
3104 });
3105
3106 // Check if archiving this thread would leave its worktree workspace
3107 // with no threads, requiring workspace removal.
3108 let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| {
3109 if folder_paths.is_empty() {
3110 return None;
3111 }
3112
3113 let thread_remote_connection =
3114 metadata.as_ref().and_then(|m| m.remote_connection.as_ref());
3115 let remaining = ThreadMetadataStore::global(cx)
3116 .read(cx)
3117 .entries_for_path(folder_paths, thread_remote_connection)
3118 .filter(|t| t.session_id.as_ref() != Some(session_id))
3119 .count();
3120
3121 if remaining > 0 {
3122 return None;
3123 }
3124
3125 let multi_workspace = self.multi_workspace.upgrade()?;
3126 let workspace = multi_workspace
3127 .read(cx)
3128 .workspace_for_paths(folder_paths, None, cx)?;
3129
3130 let group_key = workspace.read(cx).project_group_key(cx);
3131 let is_linked_worktree = group_key.path_list() != folder_paths;
3132
3133 is_linked_worktree.then_some(workspace)
3134 });
3135
3136 // Also find workspaces for root plans that aren't covered by
3137 // workspace_to_remove. For workspaces that exclusively contain
3138 // worktrees being archived, remove the whole workspace. For
3139 // "mixed" workspaces (containing both archived and non-archived
3140 // worktrees), close only the editor items referencing the
3141 // archived worktrees so their Entity<Worktree> handles are
3142 // dropped without destroying the user's workspace layout.
3143 let mut workspaces_to_remove: Vec<Entity<Workspace>> =
3144 workspace_to_remove.into_iter().collect();
3145 let mut close_item_tasks: Vec<Task<anyhow::Result<()>>> = Vec::new();
3146
3147 let archive_paths: HashSet<&Path> = roots_to_archive
3148 .iter()
3149 .map(|root| root.root_path.as_path())
3150 .collect();
3151
3152 // Classify workspaces into "exclusive" (all worktrees archived)
3153 // and "mixed" (some worktrees archived, some not).
3154 let mut mixed_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeId>)> = Vec::new();
3155
3156 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3157 let all_workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
3158
3159 for workspace in all_workspaces {
3160 if workspaces_to_remove.contains(&workspace) {
3161 continue;
3162 }
3163
3164 let project = workspace.read(cx).project().read(cx);
3165 let visible_worktrees: Vec<_> = project
3166 .visible_worktrees(cx)
3167 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
3168 .collect();
3169
3170 let archived_worktree_ids: Vec<WorktreeId> = visible_worktrees
3171 .iter()
3172 .filter(|(_, path)| archive_paths.contains(path.as_ref()))
3173 .map(|(id, _)| *id)
3174 .collect();
3175
3176 if archived_worktree_ids.is_empty() {
3177 continue;
3178 }
3179
3180 if visible_worktrees.len() == archived_worktree_ids.len() {
3181 workspaces_to_remove.push(workspace);
3182 } else {
3183 mixed_workspaces.push((workspace, archived_worktree_ids));
3184 }
3185 }
3186 }
3187
3188 // For mixed workspaces, close only items belonging to the
3189 // worktrees being archived.
3190 for (workspace, archived_worktree_ids) in &mixed_workspaces {
3191 let panes: Vec<_> = workspace.read(cx).panes().to_vec();
3192 for pane in panes {
3193 let items_to_close: Vec<EntityId> = pane
3194 .read(cx)
3195 .items()
3196 .filter(|item| {
3197 item.project_path(cx)
3198 .is_some_and(|pp| archived_worktree_ids.contains(&pp.worktree_id))
3199 })
3200 .map(|item| item.item_id())
3201 .collect();
3202
3203 if !items_to_close.is_empty() {
3204 let task = pane.update(cx, |pane, cx| {
3205 pane.close_items(window, cx, SaveIntent::Close, &|item_id| {
3206 items_to_close.contains(&item_id)
3207 })
3208 });
3209 close_item_tasks.push(task);
3210 }
3211 }
3212 }
3213
3214 if !workspaces_to_remove.is_empty() {
3215 let multi_workspace = self.multi_workspace.upgrade().unwrap();
3216 let session_id = session_id.clone();
3217
3218 let (fallback_paths, project_group_key) = neighbor
3219 .as_ref()
3220 .map(|(_, paths, project_group_key)| (paths.clone(), project_group_key.clone()))
3221 .unwrap_or_else(|| {
3222 workspaces_to_remove
3223 .first()
3224 .map(|ws| {
3225 let key = ws.read(cx).project_group_key(cx);
3226 (key.path_list().clone(), key)
3227 })
3228 .unwrap_or_default()
3229 });
3230
3231 let excluded = workspaces_to_remove.clone();
3232 let remove_task = multi_workspace.update(cx, |mw, cx| {
3233 mw.remove(
3234 workspaces_to_remove,
3235 move |this, window, cx| {
3236 let active_workspace = this.workspace().clone();
3237 this.find_or_create_workspace(
3238 fallback_paths,
3239 project_group_key.host(),
3240 Some(project_group_key),
3241 |options, window, cx| {
3242 connect_remote(active_workspace, options, window, cx)
3243 },
3244 &excluded,
3245 None,
3246 OpenMode::Activate,
3247 window,
3248 cx,
3249 )
3250 },
3251 window,
3252 cx,
3253 )
3254 });
3255
3256 let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
3257 let thread_folder_paths = thread_folder_paths.clone();
3258 cx.spawn_in(window, async move |this, cx| {
3259 if !remove_task.await? {
3260 return anyhow::Ok(());
3261 }
3262
3263 for task in close_item_tasks {
3264 let result: anyhow::Result<()> = task.await;
3265 result.log_err();
3266 }
3267
3268 this.update_in(cx, |this, window, cx| {
3269 let in_flight = thread_id.and_then(|tid| {
3270 this.start_archive_worktree_task(tid, roots_to_archive, cx)
3271 });
3272 this.archive_and_activate(
3273 &session_id,
3274 thread_id,
3275 neighbor_metadata.as_ref(),
3276 thread_folder_paths.as_ref(),
3277 in_flight,
3278 window,
3279 cx,
3280 );
3281 })?;
3282 anyhow::Ok(())
3283 })
3284 .detach_and_log_err(cx);
3285 } else if !close_item_tasks.is_empty() {
3286 let session_id = session_id.clone();
3287 let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
3288 let thread_folder_paths = thread_folder_paths.clone();
3289 cx.spawn_in(window, async move |this, cx| {
3290 for task in close_item_tasks {
3291 let result: anyhow::Result<()> = task.await;
3292 result.log_err();
3293 }
3294
3295 this.update_in(cx, |this, window, cx| {
3296 let in_flight = thread_id.and_then(|tid| {
3297 this.start_archive_worktree_task(tid, roots_to_archive, cx)
3298 });
3299 this.archive_and_activate(
3300 &session_id,
3301 thread_id,
3302 neighbor_metadata.as_ref(),
3303 thread_folder_paths.as_ref(),
3304 in_flight,
3305 window,
3306 cx,
3307 );
3308 })?;
3309 anyhow::Ok(())
3310 })
3311 .detach_and_log_err(cx);
3312 } else {
3313 let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
3314 let in_flight = thread_id
3315 .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx));
3316 self.archive_and_activate(
3317 session_id,
3318 thread_id,
3319 neighbor_metadata.as_ref(),
3320 thread_folder_paths.as_ref(),
3321 in_flight,
3322 window,
3323 cx,
3324 );
3325 }
3326 }
3327
3328 /// Archive a thread and activate the nearest neighbor or a draft.
3329 ///
3330 /// IMPORTANT: when activating a neighbor or creating a fallback draft,
3331 /// this method also activates the target workspace in the MultiWorkspace.
3332 /// This is critical because `rebuild_contents` derives the active
3333 /// workspace from `mw.workspace()`. If the linked worktree workspace is
3334 /// still active after archiving its last thread, `rebuild_contents` sees
3335 /// the threadless linked worktree as active and emits a spurious
3336 /// "+ New Thread" entry with the worktree chip — keeping the worktree
3337 /// alive and preventing disk cleanup.
3338 ///
3339 /// When `in_flight_archive` is present, it is the background task that
3340 /// persists the linked worktree's git state and deletes it from disk.
3341 /// We attach it to the metadata store at the same time we mark the thread
3342 /// archived so failures can automatically unarchive the thread and user-
3343 /// initiated unarchive can cancel the task.
3344 fn archive_and_activate(
3345 &mut self,
3346 _session_id: &acp::SessionId,
3347 thread_id: Option<agent_ui::ThreadId>,
3348 neighbor: Option<&ThreadMetadata>,
3349 thread_folder_paths: Option<&PathList>,
3350 in_flight_archive: Option<(Task<()>, smol::channel::Sender<()>)>,
3351 window: &mut Window,
3352 cx: &mut Context<Self>,
3353 ) {
3354 if let Some(thread_id) = thread_id {
3355 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3356 store.archive(thread_id, in_flight_archive, cx);
3357 });
3358 }
3359
3360 let is_active = self
3361 .active_entry
3362 .as_ref()
3363 .is_some_and(|entry| thread_id.is_some_and(|tid| entry.is_active_thread(&tid)));
3364
3365 if is_active {
3366 self.active_entry = None;
3367 }
3368
3369 if !is_active {
3370 // The user is looking at a different thread/draft. Clear the
3371 // archived thread from its workspace's panel so that switching
3372 // to that workspace later doesn't show a stale thread.
3373 if let Some(folder_paths) = thread_folder_paths {
3374 if let Some(workspace) = self
3375 .multi_workspace
3376 .upgrade()
3377 .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx))
3378 {
3379 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3380 let panel_shows_archived = panel
3381 .read(cx)
3382 .active_conversation_view()
3383 .map(|cv| cv.read(cx).parent_id())
3384 .is_some_and(|live_thread_id| {
3385 thread_id.is_some_and(|id| id == live_thread_id)
3386 });
3387 if panel_shows_archived {
3388 panel.update(cx, |panel, cx| {
3389 panel.clear_base_view(window, cx);
3390 });
3391 }
3392 }
3393 }
3394 }
3395 return;
3396 }
3397
3398 // Try to activate the neighbor thread. If its workspace is open,
3399 // tell the panel to load it and activate that workspace.
3400 // `rebuild_contents` will reconcile `active_entry` once the thread
3401 // finishes loading.
3402
3403 if let Some(metadata) = neighbor {
3404 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
3405 mw.read(cx)
3406 .workspace_for_paths(metadata.folder_paths(), None, cx)
3407 }) {
3408 self.active_entry = Some(ActiveEntry {
3409 thread_id: metadata.thread_id,
3410 session_id: metadata.session_id.clone(),
3411 workspace: workspace.clone(),
3412 });
3413 self.activate_workspace(&workspace, window, cx);
3414 Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx);
3415 return;
3416 }
3417 }
3418
3419 // No neighbor or its workspace isn't open — just clear the
3420 // panel so the group is left empty.
3421 if let Some(folder_paths) = thread_folder_paths {
3422 let workspace = self
3423 .multi_workspace
3424 .upgrade()
3425 .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx));
3426 if let Some(workspace) = workspace {
3427 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3428 panel.update(cx, |panel, cx| {
3429 panel.clear_base_view(window, cx);
3430 });
3431 }
3432 }
3433 }
3434 }
3435
3436 fn start_archive_worktree_task(
3437 &self,
3438 thread_id: ThreadId,
3439 roots: Vec<thread_worktree_archive::RootPlan>,
3440 cx: &mut Context<Self>,
3441 ) -> Option<(Task<()>, smol::channel::Sender<()>)> {
3442 if roots.is_empty() {
3443 return None;
3444 }
3445
3446 let (cancel_tx, cancel_rx) = smol::channel::bounded::<()>(1);
3447 let task = cx.spawn(async move |_this, cx| {
3448 match Self::archive_worktree_roots(roots, cancel_rx, cx).await {
3449 Ok(ArchiveWorktreeOutcome::Success) => {
3450 cx.update(|cx| {
3451 ThreadMetadataStore::global(cx).update(cx, |store, _cx| {
3452 store.cleanup_completed_archive(thread_id);
3453 });
3454 });
3455 }
3456 Ok(ArchiveWorktreeOutcome::Cancelled) => {}
3457 Err(error) => {
3458 log::error!("Failed to archive worktree: {error:#}");
3459 cx.update(|cx| {
3460 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3461 store.unarchive(thread_id, cx);
3462 });
3463 });
3464 }
3465 }
3466 });
3467
3468 Some((task, cancel_tx))
3469 }
3470
3471 async fn archive_worktree_roots(
3472 roots: Vec<thread_worktree_archive::RootPlan>,
3473 cancel_rx: smol::channel::Receiver<()>,
3474 cx: &mut gpui::AsyncApp,
3475 ) -> anyhow::Result<ArchiveWorktreeOutcome> {
3476 let mut completed_persists: Vec<(i64, thread_worktree_archive::RootPlan)> = Vec::new();
3477
3478 for root in &roots {
3479 if cancel_rx.is_closed() {
3480 for &(id, ref completed_root) in completed_persists.iter().rev() {
3481 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3482 }
3483 return Ok(ArchiveWorktreeOutcome::Cancelled);
3484 }
3485
3486 match thread_worktree_archive::persist_worktree_state(root, cx).await {
3487 Ok(id) => {
3488 completed_persists.push((id, root.clone()));
3489 }
3490 Err(error) => {
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 Err(error);
3495 }
3496 }
3497
3498 if cancel_rx.is_closed() {
3499 for &(id, ref completed_root) in completed_persists.iter().rev() {
3500 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3501 }
3502 return Ok(ArchiveWorktreeOutcome::Cancelled);
3503 }
3504
3505 if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await {
3506 if let Some(&(id, ref completed_root)) = completed_persists.last() {
3507 if completed_root.root_path == root.root_path {
3508 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3509 completed_persists.pop();
3510 }
3511 }
3512 for &(id, ref completed_root) in completed_persists.iter().rev() {
3513 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3514 }
3515 return Err(error);
3516 }
3517 }
3518
3519 Ok(ArchiveWorktreeOutcome::Success)
3520 }
3521
3522 fn activate_workspace(
3523 &self,
3524 workspace: &Entity<Workspace>,
3525 window: &mut Window,
3526 cx: &mut Context<Self>,
3527 ) {
3528 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3529 multi_workspace.update(cx, |mw, cx| {
3530 mw.activate(workspace.clone(), None, window, cx);
3531 });
3532 }
3533 }
3534
3535 fn remove_selected_thread(
3536 &mut self,
3537 _: &RemoveSelectedThread,
3538 window: &mut Window,
3539 cx: &mut Context<Self>,
3540 ) {
3541 let Some(ix) = self.selection else {
3542 return;
3543 };
3544 match self.contents.entries.get(ix) {
3545 Some(ListEntry::Thread(thread)) => {
3546 match thread.status {
3547 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
3548 return;
3549 }
3550 AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
3551 }
3552 if let Some(session_id) = thread.metadata.session_id.clone() {
3553 self.archive_thread(&session_id, window, cx);
3554 }
3555 }
3556 _ => {}
3557 }
3558 }
3559
3560 fn record_thread_access(&mut self, id: &ThreadId) {
3561 self.thread_last_accessed.insert(*id, Utc::now());
3562 }
3563
3564 fn record_thread_message_sent_or_queued(
3565 &mut self,
3566 thread_id: &agent_ui::ThreadId,
3567 cx: &mut App,
3568 ) {
3569 let store = ThreadMetadataStore::global(cx);
3570 store.update(cx, |store, cx| {
3571 store.update_interacted_at(thread_id, Utc::now(), cx);
3572 })
3573 }
3574
3575 fn thread_display_time(metadata: &ThreadMetadata) -> DateTime<Utc> {
3576 metadata.interacted_at.unwrap_or(metadata.updated_at)
3577 }
3578
3579 /// The sort order used by the ctrl-tab switcher
3580 fn thread_cmp_for_switcher(&self, left: &ThreadMetadata, right: &ThreadMetadata) -> Ordering {
3581 let sort_time = |x: &ThreadMetadata| {
3582 self.thread_last_accessed
3583 .get(&x.thread_id)
3584 .copied()
3585 .or(x.interacted_at)
3586 .unwrap_or(x.updated_at)
3587 };
3588
3589 // .reverse() = most recent first
3590 sort_time(left).cmp(&sort_time(right)).reverse()
3591 }
3592
3593 fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
3594 let mut current_header_label: Option<SharedString> = None;
3595 let mut current_header_key: Option<ProjectGroupKey> = None;
3596 let mut entries: Vec<ThreadSwitcherEntry> = self
3597 .contents
3598 .entries
3599 .iter()
3600 .filter_map(|entry| match entry {
3601 ListEntry::ProjectHeader { label, key, .. } => {
3602 current_header_label = Some(label.clone());
3603 current_header_key = Some(key.clone());
3604 None
3605 }
3606 ListEntry::Thread(thread) => {
3607 let session_id = thread.metadata.session_id.clone()?;
3608 let workspace = match &thread.workspace {
3609 ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
3610 ThreadEntryWorkspace::Closed { .. } => {
3611 current_header_key.as_ref().and_then(|key| {
3612 self.multi_workspace.upgrade().and_then(|mw| {
3613 mw.read(cx).workspace_for_paths(
3614 key.path_list(),
3615 key.host().as_ref(),
3616 cx,
3617 )
3618 })
3619 })
3620 }
3621 }?;
3622 let notified = self.contents.is_thread_notified(&thread.metadata.thread_id);
3623 let timestamp: SharedString =
3624 format_history_entry_timestamp(Self::thread_display_time(&thread.metadata))
3625 .into();
3626 Some(ThreadSwitcherEntry {
3627 session_id,
3628 title: thread.metadata.display_title(),
3629 icon: thread.icon,
3630 icon_from_external_svg: thread.icon_from_external_svg.clone(),
3631 status: thread.status,
3632 metadata: thread.metadata.clone(),
3633 workspace,
3634 project_name: current_header_label.clone(),
3635 worktrees: thread
3636 .worktrees
3637 .iter()
3638 .cloned()
3639 .map(|mut wt| {
3640 wt.highlight_positions = Vec::new();
3641 wt
3642 })
3643 .collect(),
3644 diff_stats: thread.diff_stats,
3645 is_title_generating: thread.is_title_generating,
3646 notified,
3647 timestamp,
3648 })
3649 }
3650 })
3651 .collect();
3652
3653 entries.sort_by(|a, b| self.thread_cmp_for_switcher(&a.metadata, &b.metadata));
3654
3655 entries
3656 }
3657
3658 fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
3659 self.thread_switcher = None;
3660 self._thread_switcher_subscriptions.clear();
3661 if let Some(mw) = self.multi_workspace.upgrade() {
3662 mw.update(cx, |mw, cx| {
3663 mw.set_sidebar_overlay(None, cx);
3664 });
3665 }
3666 }
3667
3668 fn on_toggle_thread_switcher(
3669 &mut self,
3670 action: &ToggleThreadSwitcher,
3671 window: &mut Window,
3672 cx: &mut Context<Self>,
3673 ) {
3674 self.toggle_thread_switcher_impl(action.select_last, window, cx);
3675 }
3676
3677 fn toggle_thread_switcher_impl(
3678 &mut self,
3679 select_last: bool,
3680 window: &mut Window,
3681 cx: &mut Context<Self>,
3682 ) {
3683 if let Some(thread_switcher) = &self.thread_switcher {
3684 thread_switcher.update(cx, |switcher, cx| {
3685 if select_last {
3686 switcher.select_last(cx);
3687 } else {
3688 switcher.cycle_selection(cx);
3689 }
3690 });
3691 return;
3692 }
3693
3694 let entries = self.mru_threads_for_switcher(cx);
3695 if entries.len() < 2 {
3696 return;
3697 }
3698
3699 let weak_multi_workspace = self.multi_workspace.clone();
3700
3701 let original_metadata = match &self.active_entry {
3702 Some(ActiveEntry { thread_id, .. }) => entries
3703 .iter()
3704 .find(|e| *thread_id == e.metadata.thread_id)
3705 .map(|e| e.metadata.clone()),
3706 _ => None,
3707 };
3708 let original_workspace = self
3709 .multi_workspace
3710 .upgrade()
3711 .map(|mw| mw.read(cx).workspace().clone());
3712
3713 let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
3714
3715 let mut subscriptions = Vec::new();
3716
3717 subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
3718 let thread_switcher = thread_switcher.clone();
3719 move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
3720 ThreadSwitcherEvent::Preview {
3721 metadata,
3722 workspace,
3723 } => {
3724 if let Some(mw) = weak_multi_workspace.upgrade() {
3725 mw.update(cx, |mw, cx| {
3726 mw.activate(workspace.clone(), None, window, cx);
3727 });
3728 }
3729 this.active_entry = Some(ActiveEntry {
3730 thread_id: metadata.thread_id,
3731 session_id: metadata.session_id.clone(),
3732 workspace: workspace.clone(),
3733 });
3734 this.update_entries(cx);
3735 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3736 let focus = thread_switcher.focus_handle(cx);
3737 window.focus(&focus, cx);
3738 }
3739 ThreadSwitcherEvent::Confirmed {
3740 metadata,
3741 workspace,
3742 } => {
3743 if let Some(mw) = weak_multi_workspace.upgrade() {
3744 mw.update(cx, |mw, cx| {
3745 mw.activate(workspace.clone(), None, window, cx);
3746 mw.retain_active_workspace(cx);
3747 });
3748 }
3749 this.record_thread_access(&metadata.thread_id);
3750 this.active_entry = Some(ActiveEntry {
3751 thread_id: metadata.thread_id,
3752 session_id: metadata.session_id.clone(),
3753 workspace: workspace.clone(),
3754 });
3755 this.update_entries(cx);
3756 this.dismiss_thread_switcher(cx);
3757 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
3758 }
3759 ThreadSwitcherEvent::Dismissed => {
3760 if let Some(mw) = weak_multi_workspace.upgrade() {
3761 if let Some(original_ws) = &original_workspace {
3762 mw.update(cx, |mw, cx| {
3763 mw.activate(original_ws.clone(), None, window, cx);
3764 });
3765 }
3766 }
3767 if let Some(metadata) = &original_metadata {
3768 if let Some(original_ws) = &original_workspace {
3769 this.active_entry = Some(ActiveEntry {
3770 thread_id: metadata.thread_id,
3771 session_id: metadata.session_id.clone(),
3772 workspace: original_ws.clone(),
3773 });
3774 }
3775 this.update_entries(cx);
3776 if let Some(original_ws) = &original_workspace {
3777 Self::load_agent_thread_in_workspace(
3778 original_ws,
3779 metadata,
3780 false,
3781 window,
3782 cx,
3783 );
3784 }
3785 }
3786 this.dismiss_thread_switcher(cx);
3787 }
3788 }
3789 }));
3790
3791 subscriptions.push(cx.subscribe_in(
3792 &thread_switcher,
3793 window,
3794 |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
3795 this.dismiss_thread_switcher(cx);
3796 },
3797 ));
3798
3799 let focus = thread_switcher.focus_handle(cx);
3800 let overlay_view = gpui::AnyView::from(thread_switcher.clone());
3801
3802 // Replay the initial preview that was emitted during construction
3803 // before subscriptions were wired up.
3804 let initial_preview = thread_switcher
3805 .read(cx)
3806 .selected_entry()
3807 .map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
3808
3809 self.thread_switcher = Some(thread_switcher);
3810 self._thread_switcher_subscriptions = subscriptions;
3811 if let Some(mw) = self.multi_workspace.upgrade() {
3812 mw.update(cx, |mw, cx| {
3813 mw.set_sidebar_overlay(Some(overlay_view), cx);
3814 });
3815 }
3816
3817 if let Some((metadata, workspace)) = initial_preview {
3818 if let Some(mw) = self.multi_workspace.upgrade() {
3819 mw.update(cx, |mw, cx| {
3820 mw.activate(workspace.clone(), None, window, cx);
3821 });
3822 }
3823 self.active_entry = Some(ActiveEntry {
3824 thread_id: metadata.thread_id,
3825 session_id: metadata.session_id.clone(),
3826 workspace: workspace.clone(),
3827 });
3828 self.update_entries(cx);
3829 Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
3830 }
3831
3832 window.focus(&focus, cx);
3833 }
3834
3835 fn render_thread(
3836 &self,
3837 ix: usize,
3838 thread: &ThreadEntry,
3839 is_active: bool,
3840 is_focused: bool,
3841 cx: &mut Context<Self>,
3842 ) -> AnyElement {
3843 let has_notification = self.contents.is_thread_notified(&thread.metadata.thread_id);
3844
3845 let title: SharedString = thread.metadata.display_title();
3846 let metadata = thread.metadata.clone();
3847 let thread_workspace = thread.workspace.clone();
3848
3849 let is_hovered = self.hovered_thread_index == Some(ix);
3850 let is_selected = is_active;
3851 let is_running = matches!(
3852 thread.status,
3853 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
3854 );
3855
3856 let thread_id_for_actions = thread.metadata.thread_id;
3857 let session_id_for_delete = thread.metadata.session_id.clone();
3858 let focus_handle = self.focus_handle.clone();
3859
3860 let id = SharedString::from(format!("thread-entry-{}", ix));
3861
3862 let color = cx.theme().colors();
3863 let sidebar_bg = color
3864 .title_bar_background
3865 .blend(color.panel_background.opacity(0.25));
3866
3867 let timestamp = format_history_entry_timestamp(Self::thread_display_time(&thread.metadata));
3868
3869 let is_remote = thread.workspace.is_remote(cx);
3870
3871 let worktrees = apply_worktree_label_mode(
3872 thread.worktrees.clone(),
3873 cx.flag_value::<AgentThreadWorktreeLabelFlag>(),
3874 );
3875
3876 ThreadItem::new(id, title)
3877 .base_bg(sidebar_bg)
3878 .icon(thread.icon)
3879 .status(thread.status)
3880 .is_remote(is_remote)
3881 .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
3882 this.custom_icon_from_external_svg(svg)
3883 })
3884 .worktrees(worktrees)
3885 .timestamp(timestamp)
3886 .highlight_positions(thread.highlight_positions.to_vec())
3887 .title_generating(thread.is_title_generating)
3888 .notified(has_notification)
3889 .when(thread.diff_stats.lines_added > 0, |this| {
3890 this.added(thread.diff_stats.lines_added as usize)
3891 })
3892 .when(thread.diff_stats.lines_removed > 0, |this| {
3893 this.removed(thread.diff_stats.lines_removed as usize)
3894 })
3895 .selected(is_selected)
3896 .focused(is_focused)
3897 .hovered(is_hovered)
3898 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
3899 if *is_hovered {
3900 this.hovered_thread_index = Some(ix);
3901 } else if this.hovered_thread_index == Some(ix) {
3902 this.hovered_thread_index = None;
3903 }
3904 cx.notify();
3905 }))
3906 .when(is_hovered && is_running, |this| {
3907 this.action_slot(
3908 IconButton::new("stop-thread", IconName::Stop)
3909 .icon_size(IconSize::Small)
3910 .icon_color(Color::Error)
3911 .style(ButtonStyle::Tinted(TintColor::Error))
3912 .tooltip(Tooltip::text("Stop Generation"))
3913 .on_click({
3914 cx.listener(move |this, _, _window, cx| {
3915 this.stop_thread(&thread_id_for_actions, cx);
3916 })
3917 }),
3918 )
3919 })
3920 .when(is_hovered && !is_running, |this| {
3921 this.action_slot(
3922 IconButton::new("archive-thread", IconName::Archive)
3923 .icon_size(IconSize::Small)
3924 .icon_color(Color::Muted)
3925 .tooltip({
3926 let focus_handle = focus_handle.clone();
3927 move |_window, cx| {
3928 Tooltip::for_action_in(
3929 "Archive Thread",
3930 &RemoveSelectedThread,
3931 &focus_handle,
3932 cx,
3933 )
3934 }
3935 })
3936 .on_click({
3937 let session_id = session_id_for_delete.clone();
3938 cx.listener(move |this, _, window, cx| {
3939 if let Some(ref session_id) = session_id {
3940 this.archive_thread(session_id, window, cx);
3941 }
3942 })
3943 }),
3944 )
3945 })
3946 .on_click({
3947 cx.listener(move |this, _, window, cx| {
3948 this.selection = None;
3949 match &thread_workspace {
3950 ThreadEntryWorkspace::Open(workspace) => {
3951 this.activate_thread(metadata.clone(), workspace, false, window, cx);
3952 }
3953 ThreadEntryWorkspace::Closed {
3954 folder_paths,
3955 project_group_key,
3956 } => {
3957 this.open_workspace_and_activate_thread(
3958 metadata.clone(),
3959 folder_paths.clone(),
3960 project_group_key,
3961 window,
3962 cx,
3963 );
3964 }
3965 }
3966 })
3967 })
3968 .into_any_element()
3969 }
3970
3971 fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
3972 div()
3973 .min_w_0()
3974 .flex_1()
3975 .capture_action(
3976 cx.listener(|this, _: &editor::actions::Newline, window, cx| {
3977 this.editor_confirm(window, cx);
3978 }),
3979 )
3980 .child(self.filter_editor.clone())
3981 }
3982
3983 fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3984 let multi_workspace = self.multi_workspace.upgrade();
3985
3986 let workspace = multi_workspace
3987 .as_ref()
3988 .map(|mw| mw.read(cx).workspace().downgrade());
3989
3990 let focus_handle = workspace
3991 .as_ref()
3992 .and_then(|ws| ws.upgrade())
3993 .map(|w| w.read(cx).focus_handle(cx))
3994 .unwrap_or_else(|| cx.focus_handle());
3995
3996 let window_project_groups: Vec<ProjectGroupKey> = multi_workspace
3997 .as_ref()
3998 .map(|mw| mw.read(cx).project_group_keys())
3999 .unwrap_or_default();
4000
4001 let popover_handle = self.recent_projects_popover_handle.clone();
4002
4003 PopoverMenu::new("sidebar-recent-projects-menu")
4004 .with_handle(popover_handle)
4005 .menu(move |window, cx| {
4006 workspace.as_ref().map(|ws| {
4007 SidebarRecentProjects::popover(
4008 ws.clone(),
4009 window_project_groups.clone(),
4010 focus_handle.clone(),
4011 window,
4012 cx,
4013 )
4014 })
4015 })
4016 .trigger_with_tooltip(
4017 IconButton::new("open-project", IconName::OpenFolder)
4018 .icon_size(IconSize::Small)
4019 .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
4020 |_window, cx| {
4021 Tooltip::for_action(
4022 "Add Project",
4023 &OpenRecent {
4024 create_new_window: false,
4025 },
4026 cx,
4027 )
4028 },
4029 )
4030 .offset(gpui::Point {
4031 x: px(-2.0),
4032 y: px(-2.0),
4033 })
4034 .anchor(gpui::Corner::BottomRight)
4035 }
4036
4037 fn new_thread_in_group(
4038 &mut self,
4039 _: &NewThreadInGroup,
4040 window: &mut Window,
4041 cx: &mut Context<Self>,
4042 ) {
4043 if let Some(key) = self.selected_group_key() {
4044 self.set_group_expanded(&key, true, cx);
4045 self.selection = None;
4046 if let Some(workspace) = self.workspace_for_group(&key, cx) {
4047 self.create_new_thread(&workspace, window, cx);
4048 } else {
4049 self.open_workspace_and_create_draft(&key, window, cx);
4050 }
4051 } else if let Some(workspace) = self.active_workspace(cx) {
4052 self.create_new_thread(&workspace, window, cx);
4053 }
4054 }
4055
4056 fn create_new_thread(
4057 &mut self,
4058 workspace: &Entity<Workspace>,
4059 window: &mut Window,
4060 cx: &mut Context<Self>,
4061 ) {
4062 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4063 return;
4064 };
4065
4066 multi_workspace.update(cx, |multi_workspace, cx| {
4067 multi_workspace.activate(workspace.clone(), None, window, cx);
4068 });
4069
4070 let draft_id = workspace.update(cx, |workspace, cx| {
4071 let panel = workspace.panel::<AgentPanel>(cx)?;
4072 let draft_id = panel.update(cx, |panel, cx| {
4073 panel.activate_draft(true, window, cx);
4074 panel.active_thread_id(cx)
4075 });
4076 workspace.focus_panel::<AgentPanel>(window, cx);
4077 draft_id
4078 });
4079
4080 if let Some(draft_id) = draft_id {
4081 self.active_entry = Some(ActiveEntry {
4082 thread_id: draft_id,
4083 session_id: None,
4084 workspace: workspace.clone(),
4085 });
4086 }
4087 }
4088
4089 fn selected_group_key(&self) -> Option<ProjectGroupKey> {
4090 let ix = self.selection?;
4091 match self.contents.entries.get(ix) {
4092 Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()),
4093 Some(ListEntry::Thread(_)) => {
4094 (0..ix)
4095 .rev()
4096 .find_map(|i| match self.contents.entries.get(i) {
4097 Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()),
4098 _ => None,
4099 })
4100 }
4101 _ => None,
4102 }
4103 }
4104
4105 fn workspace_for_group(&self, key: &ProjectGroupKey, cx: &App) -> Option<Entity<Workspace>> {
4106 let mw = self.multi_workspace.upgrade()?;
4107 let mw = mw.read(cx);
4108 let active = mw.workspace().clone();
4109 let active_key = active.read(cx).project_group_key(cx);
4110 if active_key == *key {
4111 Some(active)
4112 } else {
4113 mw.workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
4114 }
4115 }
4116
4117 pub(crate) fn activate_or_open_workspace_for_group(
4118 &mut self,
4119 key: &ProjectGroupKey,
4120 window: &mut Window,
4121 cx: &mut Context<Self>,
4122 ) {
4123 let workspace = self
4124 .multi_workspace
4125 .upgrade()
4126 .and_then(|mw| mw.read(cx).last_active_workspace_for_group(key, cx))
4127 .or_else(|| self.workspace_for_group(key, cx));
4128 if let Some(workspace) = workspace {
4129 self.activate_workspace(&workspace, window, cx);
4130 } else {
4131 self.open_workspace_for_group(key, window, cx);
4132 }
4133 self.selection = None;
4134 self.active_entry = None;
4135 }
4136
4137 fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
4138 let multi_workspace = self.multi_workspace.upgrade()?;
4139 let multi_workspace = multi_workspace.read(cx);
4140 Some(multi_workspace.project_group_key_for_workspace(multi_workspace.workspace(), cx))
4141 }
4142
4143 fn active_project_header_position(&self, cx: &App) -> Option<usize> {
4144 let active_key = self.active_project_group_key(cx)?;
4145 self.contents
4146 .project_header_indices
4147 .iter()
4148 .position(|&entry_ix| {
4149 matches!(
4150 &self.contents.entries[entry_ix],
4151 ListEntry::ProjectHeader { key, .. } if *key == active_key
4152 )
4153 })
4154 }
4155
4156 fn cycle_project_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4157 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4158 return;
4159 };
4160
4161 let header_count = self.contents.project_header_indices.len();
4162 if header_count == 0 {
4163 return;
4164 }
4165
4166 let current_pos = self.active_project_header_position(cx);
4167
4168 let next_pos = match current_pos {
4169 Some(pos) => {
4170 if forward {
4171 (pos + 1) % header_count
4172 } else {
4173 (pos + header_count - 1) % header_count
4174 }
4175 }
4176 None => 0,
4177 };
4178
4179 let header_entry_ix = self.contents.project_header_indices[next_pos];
4180 let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_entry_ix)
4181 else {
4182 return;
4183 };
4184 let key = key.clone();
4185
4186 // Uncollapse the target group so that threads become visible.
4187 self.set_group_expanded(&key, true, cx);
4188
4189 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
4190 mw.read(cx)
4191 .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
4192 }) {
4193 multi_workspace.update(cx, |multi_workspace, cx| {
4194 multi_workspace.activate(workspace, None, window, cx);
4195 multi_workspace.retain_active_workspace(cx);
4196 });
4197 } else {
4198 self.open_workspace_for_group(&key, window, cx);
4199 }
4200 }
4201
4202 fn on_next_project(&mut self, _: &NextProject, window: &mut Window, cx: &mut Context<Self>) {
4203 self.cycle_project_impl(true, window, cx);
4204 }
4205
4206 fn on_previous_project(
4207 &mut self,
4208 _: &PreviousProject,
4209 window: &mut Window,
4210 cx: &mut Context<Self>,
4211 ) {
4212 self.cycle_project_impl(false, window, cx);
4213 }
4214
4215 fn cycle_thread_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4216 let thread_indices: Vec<usize> = self
4217 .contents
4218 .entries
4219 .iter()
4220 .enumerate()
4221 .filter_map(|(ix, entry)| match entry {
4222 ListEntry::Thread(_) => Some(ix),
4223 _ => None,
4224 })
4225 .collect();
4226
4227 if thread_indices.is_empty() {
4228 return;
4229 }
4230
4231 let current_thread_pos = self.active_entry.as_ref().and_then(|active| {
4232 thread_indices
4233 .iter()
4234 .position(|&ix| active.matches_entry(&self.contents.entries[ix]))
4235 });
4236
4237 let next_pos = match current_thread_pos {
4238 Some(pos) => {
4239 let count = thread_indices.len();
4240 if forward {
4241 (pos + 1) % count
4242 } else {
4243 (pos + count - 1) % count
4244 }
4245 }
4246 None => 0,
4247 };
4248
4249 let entry_ix = thread_indices[next_pos];
4250 let ListEntry::Thread(thread) = &self.contents.entries[entry_ix] else {
4251 return;
4252 };
4253
4254 let metadata = thread.metadata.clone();
4255 match &thread.workspace {
4256 ThreadEntryWorkspace::Open(workspace) => {
4257 let workspace = workspace.clone();
4258 self.activate_thread(metadata, &workspace, true, window, cx);
4259 }
4260 ThreadEntryWorkspace::Closed {
4261 folder_paths,
4262 project_group_key,
4263 } => {
4264 let folder_paths = folder_paths.clone();
4265 let project_group_key = project_group_key.clone();
4266 self.open_workspace_and_activate_thread(
4267 metadata,
4268 folder_paths,
4269 &project_group_key,
4270 window,
4271 cx,
4272 );
4273 }
4274 }
4275 }
4276
4277 fn on_next_thread(&mut self, _: &NextThread, window: &mut Window, cx: &mut Context<Self>) {
4278 self.cycle_thread_impl(true, window, cx);
4279 }
4280
4281 fn on_previous_thread(
4282 &mut self,
4283 _: &PreviousThread,
4284 window: &mut Window,
4285 cx: &mut Context<Self>,
4286 ) {
4287 self.cycle_thread_impl(false, window, cx);
4288 }
4289
4290 fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
4291 let has_query = self.has_filter_query(cx);
4292 let message = if has_query {
4293 "No threads match your search."
4294 } else {
4295 "No threads yet"
4296 };
4297
4298 v_flex()
4299 .id("sidebar-no-results")
4300 .p_4()
4301 .size_full()
4302 .items_center()
4303 .justify_center()
4304 .child(
4305 Label::new(message)
4306 .size(LabelSize::Small)
4307 .color(Color::Muted),
4308 )
4309 }
4310
4311 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4312 v_flex()
4313 .id("sidebar-empty-state")
4314 .p_4()
4315 .size_full()
4316 .items_center()
4317 .justify_center()
4318 .gap_1()
4319 .track_focus(&self.focus_handle(cx))
4320 .child(
4321 Button::new("open_project", "Open Project")
4322 .full_width()
4323 .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
4324 .on_click(|_, window, cx| {
4325 let side = match AgentSettings::get_global(cx).sidebar_side() {
4326 SidebarSide::Left => "left",
4327 SidebarSide::Right => "right",
4328 };
4329 telemetry::event!("Sidebar Add Project Clicked", side = side);
4330 window.dispatch_action(
4331 Open {
4332 create_new_window: false,
4333 }
4334 .boxed_clone(),
4335 cx,
4336 );
4337 }),
4338 )
4339 .child(
4340 h_flex()
4341 .w_1_2()
4342 .gap_2()
4343 .child(Divider::horizontal().color(ui::DividerColor::Border))
4344 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
4345 .child(Divider::horizontal().color(ui::DividerColor::Border)),
4346 )
4347 .child(
4348 Button::new("clone_repo", "Clone Repository")
4349 .full_width()
4350 .on_click(|_, window, cx| {
4351 window.dispatch_action(git::Clone.boxed_clone(), cx);
4352 }),
4353 )
4354 }
4355
4356 fn render_sidebar_header(
4357 &self,
4358 no_open_projects: bool,
4359 window: &Window,
4360 cx: &mut Context<Self>,
4361 ) -> impl IntoElement {
4362 let has_query = self.has_filter_query(cx);
4363 let sidebar_on_left = self.side(cx) == SidebarSide::Left;
4364 let sidebar_on_right = self.side(cx) == SidebarSide::Right;
4365 let not_fullscreen = !window.is_fullscreen();
4366 let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4367 let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4368 let right_window_controls =
4369 !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
4370 let header_height = platform_title_bar_height(window);
4371
4372 h_flex()
4373 .h(header_height)
4374 .mt_px()
4375 .pb_px()
4376 .when(left_window_controls, |this| {
4377 this.children(Self::render_left_window_controls(window, cx))
4378 })
4379 .map(|this| {
4380 if traffic_lights {
4381 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
4382 } else if !left_window_controls {
4383 this.pl_1p5()
4384 } else {
4385 this
4386 }
4387 })
4388 .when(!right_window_controls, |this| this.pr_1p5())
4389 .gap_1()
4390 .when(!no_open_projects, |this| {
4391 this.border_b_1()
4392 .border_color(cx.theme().colors().border)
4393 .when(traffic_lights, |this| {
4394 this.child(Divider::vertical().color(ui::DividerColor::Border))
4395 })
4396 .child(
4397 div().ml_1().child(
4398 Icon::new(IconName::MagnifyingGlass)
4399 .size(IconSize::Small)
4400 .color(Color::Muted),
4401 ),
4402 )
4403 .child(self.render_filter_input(cx))
4404 .child(
4405 h_flex()
4406 .gap_1()
4407 .when(
4408 self.selection.is_some()
4409 && !self.filter_editor.focus_handle(cx).is_focused(window),
4410 |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
4411 )
4412 .when(has_query, |this| {
4413 this.child(
4414 IconButton::new("clear_filter", IconName::Close)
4415 .icon_size(IconSize::Small)
4416 .tooltip(Tooltip::text("Clear Search"))
4417 .on_click(cx.listener(|this, _, window, cx| {
4418 this.reset_filter_editor_text(window, cx);
4419 this.update_entries(cx);
4420 })),
4421 )
4422 }),
4423 )
4424 })
4425 .when(right_window_controls, |this| {
4426 this.children(Self::render_right_window_controls(window, cx))
4427 })
4428 }
4429
4430 fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4431 platform_title_bar::render_left_window_controls(
4432 cx.button_layout(),
4433 Box::new(CloseWindow),
4434 window,
4435 )
4436 }
4437
4438 fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4439 platform_title_bar::render_right_window_controls(
4440 cx.button_layout(),
4441 Box::new(CloseWindow),
4442 window,
4443 )
4444 }
4445
4446 fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
4447 let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
4448
4449 sidebar_side_context_menu("sidebar-toggle-menu", _cx)
4450 .anchor(if on_right {
4451 gpui::Corner::BottomRight
4452 } else {
4453 gpui::Corner::BottomLeft
4454 })
4455 .attach(if on_right {
4456 gpui::Corner::TopRight
4457 } else {
4458 gpui::Corner::TopLeft
4459 })
4460 .trigger(move |_is_active, _window, _cx| {
4461 let icon = if on_right {
4462 IconName::ThreadsSidebarRightOpen
4463 } else {
4464 IconName::ThreadsSidebarLeftOpen
4465 };
4466 IconButton::new("sidebar-close-toggle", icon)
4467 .icon_size(IconSize::Small)
4468 .tooltip(Tooltip::element(move |_window, cx| {
4469 v_flex()
4470 .gap_1()
4471 .child(
4472 h_flex()
4473 .gap_2()
4474 .justify_between()
4475 .child(Label::new("Toggle Sidebar"))
4476 .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
4477 )
4478 .child(
4479 h_flex()
4480 .pt_1()
4481 .gap_2()
4482 .border_t_1()
4483 .border_color(cx.theme().colors().border_variant)
4484 .justify_between()
4485 .child(Label::new("Focus Sidebar"))
4486 .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
4487 )
4488 .into_any_element()
4489 }))
4490 .on_click(|_, window, cx| {
4491 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
4492 multi_workspace.update(cx, |multi_workspace, cx| {
4493 multi_workspace.close_sidebar(window, cx);
4494 });
4495 }
4496 })
4497 })
4498 }
4499
4500 fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4501 let is_archive = matches!(self.view, SidebarView::Archive(..));
4502 let on_right = self.side(cx) == SidebarSide::Right;
4503
4504 h_flex()
4505 .p_1()
4506 .gap_1()
4507 .when(on_right, |this| this.flex_row_reverse())
4508 .border_t_1()
4509 .border_color(cx.theme().colors().border)
4510 .child(self.render_sidebar_toggle_button(cx))
4511 .child(
4512 IconButton::new("history", IconName::Clock)
4513 .icon_size(IconSize::Small)
4514 .toggle_state(is_archive)
4515 .tooltip(move |_, cx| {
4516 let label = if is_archive {
4517 "Hide Thread History"
4518 } else {
4519 "Show Thread History"
4520 };
4521 Tooltip::for_action(label, &ToggleThreadHistory, cx)
4522 })
4523 .on_click(cx.listener(|this, _, window, cx| {
4524 this.toggle_archive(&ToggleThreadHistory, window, cx);
4525 })),
4526 )
4527 .child(div().flex_1())
4528 .child(self.render_recent_projects_button(cx))
4529 }
4530
4531 fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
4532 self.multi_workspace
4533 .upgrade()
4534 .map(|w| w.read(cx).workspace().clone())
4535 }
4536
4537 fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4538 let Some(active_workspace) = self.active_workspace(cx) else {
4539 return;
4540 };
4541
4542 let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
4543 return;
4544 };
4545
4546 let agent_server_store = active_workspace
4547 .read(cx)
4548 .project()
4549 .read(cx)
4550 .agent_server_store()
4551 .clone();
4552
4553 let workspace_handle = active_workspace.downgrade();
4554 let multi_workspace = self.multi_workspace.clone();
4555
4556 active_workspace.update(cx, |workspace, cx| {
4557 workspace.toggle_modal(window, cx, |window, cx| {
4558 ThreadImportModal::new(
4559 agent_server_store,
4560 agent_registry_store,
4561 workspace_handle.clone(),
4562 multi_workspace.clone(),
4563 window,
4564 cx,
4565 )
4566 });
4567 });
4568 }
4569
4570 fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
4571 let has_external_agents = self
4572 .active_workspace(cx)
4573 .map(|ws| {
4574 ws.read(cx)
4575 .project()
4576 .read(cx)
4577 .agent_server_store()
4578 .read(cx)
4579 .has_external_agents()
4580 })
4581 .unwrap_or(false);
4582
4583 has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
4584 }
4585
4586 fn render_acp_import_onboarding(
4587 &mut self,
4588 verbose_labels: bool,
4589 cx: &mut Context<Self>,
4590 ) -> impl IntoElement {
4591 let on_import = cx.listener(|this, _, window, cx| {
4592 this.show_archive(window, cx);
4593 this.show_thread_import_modal(window, cx);
4594 });
4595 render_import_onboarding_banner(
4596 "acp",
4597 "Looking for threads from external agents?",
4598 "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.",
4599 if verbose_labels {
4600 "Import Threads from External Agents"
4601 } else {
4602 "Import Threads"
4603 },
4604 |_, _window, cx| AcpThreadImportOnboarding::dismiss(cx),
4605 on_import,
4606 cx,
4607 )
4608 }
4609
4610 fn should_render_cross_channel_import_onboarding(&self, cx: &App) -> bool {
4611 !CrossChannelImportOnboarding::dismissed(cx) && !channels_with_threads(cx).is_empty()
4612 }
4613
4614 fn render_cross_channel_import_onboarding(
4615 &mut self,
4616 verbose_labels: bool,
4617 cx: &mut Context<Self>,
4618 ) -> impl IntoElement {
4619 let channels = channels_with_threads(cx);
4620 let channel_names = channels
4621 .iter()
4622 .map(|channel| channel.display_name())
4623 .collect::<Vec<_>>()
4624 .join(" and ");
4625
4626 let description = format!(
4627 "Import threads from {} to continue where you left off.",
4628 channel_names
4629 );
4630
4631 let on_import = cx.listener(|this, _, _window, cx| {
4632 CrossChannelImportOnboarding::dismiss(cx);
4633 if let Some(workspace) = this.active_workspace(cx) {
4634 workspace.update(cx, |workspace, cx| {
4635 import_threads_from_other_channels(workspace, cx);
4636 });
4637 }
4638 });
4639 render_import_onboarding_banner(
4640 "channel",
4641 "Threads found from other channels",
4642 description,
4643 if verbose_labels {
4644 "Import Threads from Other Channels"
4645 } else {
4646 "Import Threads"
4647 },
4648 |_, _window, cx| CrossChannelImportOnboarding::dismiss(cx),
4649 on_import,
4650 cx,
4651 )
4652 }
4653
4654 fn toggle_archive(
4655 &mut self,
4656 _: &ToggleThreadHistory,
4657 window: &mut Window,
4658 cx: &mut Context<Self>,
4659 ) {
4660 match &self.view {
4661 SidebarView::ThreadList => {
4662 let side = match self.side(cx) {
4663 SidebarSide::Left => "left",
4664 SidebarSide::Right => "right",
4665 };
4666 telemetry::event!("Thread History Viewed", side = side);
4667 self.show_archive(window, cx);
4668 }
4669 SidebarView::Archive(_) => self.show_thread_list(window, cx),
4670 }
4671 }
4672
4673 fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4674 let Some(active_workspace) = self
4675 .multi_workspace
4676 .upgrade()
4677 .map(|w| w.read(cx).workspace().clone())
4678 else {
4679 return;
4680 };
4681 let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
4682 return;
4683 };
4684
4685 let agent_server_store = active_workspace
4686 .read(cx)
4687 .project()
4688 .read(cx)
4689 .agent_server_store()
4690 .downgrade();
4691
4692 let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
4693
4694 let archive_view = cx.new(|cx| {
4695 ThreadsArchiveView::new(
4696 active_workspace.downgrade(),
4697 agent_connection_store.clone(),
4698 agent_server_store.clone(),
4699 window,
4700 cx,
4701 )
4702 });
4703
4704 let subscription = cx.subscribe_in(
4705 &archive_view,
4706 window,
4707 |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
4708 ThreadsArchiveViewEvent::Close => {
4709 this.show_thread_list(window, cx);
4710 }
4711 ThreadsArchiveViewEvent::Activate { thread } => {
4712 this.open_thread_from_archive(thread.clone(), window, cx);
4713 }
4714 ThreadsArchiveViewEvent::CancelRestore { thread_id } => {
4715 this.restoring_tasks.remove(thread_id);
4716 }
4717 ThreadsArchiveViewEvent::Import => {
4718 this.show_thread_import_modal(window, cx);
4719 }
4720 },
4721 );
4722
4723 self._subscriptions.push(subscription);
4724 self.view = SidebarView::Archive(archive_view.clone());
4725 archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
4726 self.serialize(cx);
4727 cx.notify();
4728 }
4729
4730 fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4731 self.view = SidebarView::ThreadList;
4732 self._subscriptions.clear();
4733 let handle = self.filter_editor.read(cx).focus_handle(cx);
4734 handle.focus(window, cx);
4735 self.serialize(cx);
4736 cx.notify();
4737 }
4738}
4739
4740fn render_import_onboarding_banner(
4741 id: impl Into<SharedString>,
4742 title: impl Into<SharedString>,
4743 description: impl Into<SharedString>,
4744 button_label: impl Into<SharedString>,
4745 on_dismiss: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
4746 on_import: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
4747 cx: &App,
4748) -> impl IntoElement {
4749 let id: SharedString = id.into();
4750 let bg = cx.theme().colors().text_accent;
4751
4752 v_flex()
4753 .min_w_0()
4754 .w_full()
4755 .p_2()
4756 .border_t_1()
4757 .border_color(cx.theme().colors().border)
4758 .bg(linear_gradient(
4759 360.,
4760 linear_color_stop(bg.opacity(0.06), 1.),
4761 linear_color_stop(bg.opacity(0.), 0.),
4762 ))
4763 .child(
4764 h_flex()
4765 .min_w_0()
4766 .w_full()
4767 .gap_1()
4768 .justify_between()
4769 .flex_wrap()
4770 .child(Label::new(title).size(LabelSize::Small))
4771 .child(
4772 IconButton::new(
4773 SharedString::from(format!("close-{id}-onboarding")),
4774 IconName::Close,
4775 )
4776 .icon_size(IconSize::Small)
4777 .on_click(on_dismiss),
4778 ),
4779 )
4780 .child(
4781 Label::new(description)
4782 .size(LabelSize::Small)
4783 .color(Color::Muted)
4784 .mb_2(),
4785 )
4786 .child(
4787 Button::new(SharedString::from(format!("import-{id}")), button_label)
4788 .full_width()
4789 .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
4790 .label_size(LabelSize::Small)
4791 .start_icon(
4792 Icon::new(IconName::Download)
4793 .size(IconSize::Small)
4794 .color(Color::Muted),
4795 )
4796 .on_click(on_import),
4797 )
4798}
4799
4800impl WorkspaceSidebar for Sidebar {
4801 fn width(&self, _cx: &App) -> Pixels {
4802 self.width
4803 }
4804
4805 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
4806 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
4807 cx.notify();
4808 }
4809
4810 fn has_notifications(&self, _cx: &App) -> bool {
4811 !self.contents.notified_threads.is_empty()
4812 }
4813
4814 fn is_threads_list_view_active(&self) -> bool {
4815 matches!(self.view, SidebarView::ThreadList)
4816 }
4817
4818 fn side(&self, cx: &App) -> SidebarSide {
4819 AgentSettings::get_global(cx).sidebar_side()
4820 }
4821
4822 fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4823 self.selection = None;
4824 cx.notify();
4825 }
4826
4827 fn toggle_thread_switcher(
4828 &mut self,
4829 select_last: bool,
4830 window: &mut Window,
4831 cx: &mut Context<Self>,
4832 ) {
4833 self.toggle_thread_switcher_impl(select_last, window, cx);
4834 }
4835
4836 fn cycle_project(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4837 self.cycle_project_impl(forward, window, cx);
4838 }
4839
4840 fn cycle_thread(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4841 self.cycle_thread_impl(forward, window, cx);
4842 }
4843
4844 fn serialized_state(&self, _cx: &App) -> Option<String> {
4845 let serialized = SerializedSidebar {
4846 width: Some(f32::from(self.width)),
4847 active_view: match self.view {
4848 SidebarView::ThreadList => SerializedSidebarView::ThreadList,
4849 SidebarView::Archive(_) => SerializedSidebarView::History,
4850 },
4851 };
4852 serde_json::to_string(&serialized).ok()
4853 }
4854
4855 fn restore_serialized_state(
4856 &mut self,
4857 state: &str,
4858 window: &mut Window,
4859 cx: &mut Context<Self>,
4860 ) {
4861 if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
4862 if let Some(width) = serialized.width {
4863 self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
4864 }
4865 if serialized.active_view == SerializedSidebarView::History {
4866 cx.defer_in(window, |this, window, cx| {
4867 this.show_archive(window, cx);
4868 });
4869 }
4870 }
4871 cx.notify();
4872 }
4873}
4874
4875impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
4876
4877impl Focusable for Sidebar {
4878 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4879 self.focus_handle.clone()
4880 }
4881}
4882
4883impl Render for Sidebar {
4884 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4885 let _titlebar_height = ui::utils::platform_title_bar_height(window);
4886 let ui_font = theme_settings::setup_ui_font(window, cx);
4887 let sticky_header = self.render_sticky_header(window, cx);
4888
4889 let color = cx.theme().colors();
4890 let bg = color
4891 .title_bar_background
4892 .blend(color.panel_background.opacity(0.25));
4893
4894 let no_open_projects = !self.contents.has_open_projects;
4895 let no_search_results = self.contents.entries.is_empty();
4896
4897 v_flex()
4898 .id("workspace-sidebar")
4899 .key_context(self.dispatch_context(window, cx))
4900 .track_focus(&self.focus_handle)
4901 .on_action(cx.listener(Self::select_next))
4902 .on_action(cx.listener(Self::select_previous))
4903 .on_action(cx.listener(Self::editor_move_down))
4904 .on_action(cx.listener(Self::editor_move_up))
4905 .on_action(cx.listener(Self::select_first))
4906 .on_action(cx.listener(Self::select_last))
4907 .on_action(cx.listener(Self::confirm))
4908 .on_action(cx.listener(Self::expand_selected_entry))
4909 .on_action(cx.listener(Self::collapse_selected_entry))
4910 .on_action(cx.listener(Self::toggle_selected_fold))
4911 .on_action(cx.listener(Self::fold_all))
4912 .on_action(cx.listener(Self::unfold_all))
4913 .on_action(cx.listener(Self::cancel))
4914 .on_action(cx.listener(Self::remove_selected_thread))
4915 .on_action(cx.listener(Self::new_thread_in_group))
4916 .on_action(cx.listener(Self::toggle_archive))
4917 .on_action(cx.listener(Self::focus_sidebar_filter))
4918 .on_action(cx.listener(Self::on_toggle_thread_switcher))
4919 .on_action(cx.listener(Self::on_next_project))
4920 .on_action(cx.listener(Self::on_previous_project))
4921 .on_action(cx.listener(Self::on_next_thread))
4922 .on_action(cx.listener(Self::on_previous_thread))
4923 .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
4924 this.recent_projects_popover_handle.toggle(window, cx);
4925 }))
4926 .font(ui_font)
4927 .h_full()
4928 .w(self.width)
4929 .bg(bg)
4930 .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
4931 .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
4932 .border_color(color.border)
4933 .map(|this| match &self.view {
4934 SidebarView::ThreadList => this
4935 .child(self.render_sidebar_header(no_open_projects, window, cx))
4936 .map(|this| {
4937 if no_open_projects {
4938 this.child(self.render_empty_state(cx))
4939 } else {
4940 this.child(
4941 v_flex()
4942 .relative()
4943 .flex_1()
4944 .overflow_hidden()
4945 .child(
4946 list(
4947 self.list_state.clone(),
4948 cx.processor(Self::render_list_entry),
4949 )
4950 .flex_1()
4951 .size_full(),
4952 )
4953 .when(no_search_results, |this| {
4954 this.child(self.render_no_results(cx))
4955 })
4956 .when_some(sticky_header, |this, header| this.child(header))
4957 .vertical_scrollbar_for(&self.list_state, window, cx),
4958 )
4959 }
4960 }),
4961 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
4962 })
4963 .map(|this| {
4964 let show_acp = self.should_render_acp_import_onboarding(cx);
4965 let show_cross_channel = self.should_render_cross_channel_import_onboarding(cx);
4966
4967 let verbose = *self
4968 .import_banners_use_verbose_labels
4969 .get_or_insert(show_acp && show_cross_channel);
4970
4971 this.when(show_acp, |this| {
4972 this.child(self.render_acp_import_onboarding(verbose, cx))
4973 })
4974 .when(show_cross_channel, |this| {
4975 this.child(self.render_cross_channel_import_onboarding(verbose, cx))
4976 })
4977 })
4978 .child(self.render_sidebar_bottom_bar(cx))
4979 }
4980}
4981
4982fn all_thread_infos_for_workspace(
4983 workspace: &Entity<Workspace>,
4984 cx: &App,
4985) -> impl Iterator<Item = ActiveThreadInfo> {
4986 let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4987 return None.into_iter().flatten();
4988 };
4989 let agent_panel = agent_panel.read(cx);
4990 let threads = agent_panel
4991 .conversation_views()
4992 .into_iter()
4993 .filter_map(|conversation_view| {
4994 let has_pending_tool_call = conversation_view
4995 .read(cx)
4996 .root_thread_has_pending_tool_call(cx);
4997 let conversation_thread_id = conversation_view.read(cx).parent_id();
4998 let thread_view = conversation_view.read(cx).root_thread_view()?;
4999 let thread_view_ref = thread_view.read(cx);
5000 let thread = thread_view_ref.thread.read(cx);
5001
5002 let icon = thread_view_ref.agent_icon;
5003 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
5004 let title = thread
5005 .title()
5006 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
5007 let is_title_generating = thread_view_ref
5008 .as_native_thread(cx)
5009 .is_some_and(|native_thread| native_thread.read(cx).is_generating_title());
5010 let session_id = thread.session_id().clone();
5011 let is_background = agent_panel.is_retained_thread(&conversation_thread_id);
5012
5013 let status = if has_pending_tool_call {
5014 AgentThreadStatus::WaitingForConfirmation
5015 } else if thread.had_error() {
5016 AgentThreadStatus::Error
5017 } else {
5018 match thread.status() {
5019 ThreadStatus::Generating => AgentThreadStatus::Running,
5020 ThreadStatus::Idle => AgentThreadStatus::Completed,
5021 }
5022 };
5023
5024 let diff_stats = thread.action_log().read(cx).diff_stats(cx);
5025
5026 Some(ActiveThreadInfo {
5027 session_id,
5028 title,
5029 status,
5030 icon,
5031 icon_from_external_svg,
5032 is_background,
5033 is_title_generating,
5034 diff_stats,
5035 })
5036 });
5037
5038 Some(threads).into_iter().flatten()
5039}
5040
5041pub fn dump_workspace_info(
5042 workspace: &mut Workspace,
5043 _: &DumpWorkspaceInfo,
5044 window: &mut gpui::Window,
5045 cx: &mut gpui::Context<Workspace>,
5046) {
5047 use std::fmt::Write;
5048
5049 let mut output = String::new();
5050 let this_entity = cx.entity();
5051
5052 let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
5053 let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
5054 Some(mw) => mw.read(cx).workspaces().cloned().collect(),
5055 None => vec![this_entity.clone()],
5056 };
5057 let active_workspace = multi_workspace
5058 .as_ref()
5059 .map(|mw| mw.read(cx).workspace().clone());
5060
5061 writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
5062
5063 if let Some(mw) = &multi_workspace {
5064 let keys: Vec<_> = mw.read(cx).project_group_keys();
5065 writeln!(output, "Project group keys ({}):", keys.len()).ok();
5066 for key in keys {
5067 writeln!(output, " - {key:?}").ok();
5068 }
5069 }
5070
5071 writeln!(output).ok();
5072
5073 for (index, ws) in workspaces.iter().enumerate() {
5074 let is_active = active_workspace.as_ref() == Some(ws);
5075 writeln!(
5076 output,
5077 "--- Workspace {index}{} ---",
5078 if is_active { " (active)" } else { "" }
5079 )
5080 .ok();
5081
5082 // project_group_key_for_workspace internally reads the workspace,
5083 // so we can only call it for workspaces other than this_entity
5084 // (which is already being updated).
5085 if let Some(mw) = &multi_workspace {
5086 if *ws == this_entity {
5087 let workspace_key = workspace.project_group_key(cx);
5088 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
5089 } else {
5090 let effective_key = mw.read(cx).project_group_key_for_workspace(ws, cx);
5091 let workspace_key = ws.read(cx).project_group_key(cx);
5092 if effective_key != workspace_key {
5093 writeln!(
5094 output,
5095 "ProjectGroupKey (multi_workspace): {effective_key:?}"
5096 )
5097 .ok();
5098 writeln!(
5099 output,
5100 "ProjectGroupKey (workspace, DISAGREES): {workspace_key:?}"
5101 )
5102 .ok();
5103 } else {
5104 writeln!(output, "ProjectGroupKey: {effective_key:?}").ok();
5105 }
5106 }
5107 } else {
5108 let workspace_key = workspace.project_group_key(cx);
5109 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
5110 }
5111
5112 // The action handler is already inside an update on `this_entity`,
5113 // so we must avoid a nested read/update on that same entity.
5114 if *ws == this_entity {
5115 dump_single_workspace(workspace, &mut output, cx);
5116 } else {
5117 ws.read_with(cx, |ws, cx| {
5118 dump_single_workspace(ws, &mut output, cx);
5119 });
5120 }
5121 }
5122
5123 let project = workspace.project().clone();
5124 cx.spawn_in(window, async move |_this, cx| {
5125 let buffer = project
5126 .update(cx, |project, cx| project.create_buffer(None, false, cx))
5127 .await?;
5128
5129 buffer.update(cx, |buffer, cx| {
5130 buffer.set_text(output, cx);
5131 });
5132
5133 let buffer = cx.new(|cx| {
5134 editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
5135 });
5136
5137 _this.update_in(cx, |workspace, window, cx| {
5138 workspace.add_item_to_active_pane(
5139 Box::new(cx.new(|cx| {
5140 let mut editor =
5141 editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
5142 editor.set_read_only(true);
5143 editor.set_should_serialize(false, cx);
5144 editor.set_breadcrumb_header("Workspace Info".into());
5145 editor
5146 })),
5147 None,
5148 true,
5149 window,
5150 cx,
5151 );
5152 })
5153 })
5154 .detach_and_log_err(cx);
5155}
5156
5157fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
5158 use std::fmt::Write;
5159
5160 let workspace_db_id = workspace.database_id();
5161 match workspace_db_id {
5162 Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
5163 None => writeln!(output, "Workspace DB ID: (none)").ok(),
5164 };
5165
5166 let project = workspace.project().read(cx);
5167
5168 let repos: Vec<_> = project
5169 .repositories(cx)
5170 .values()
5171 .map(|repo| repo.read(cx).snapshot())
5172 .collect();
5173
5174 writeln!(output, "Worktrees:").ok();
5175 for worktree in project.worktrees(cx) {
5176 let worktree = worktree.read(cx);
5177 let abs_path = worktree.abs_path();
5178 let visible = worktree.is_visible();
5179
5180 let repo_info = repos
5181 .iter()
5182 .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
5183
5184 let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
5185 let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
5186 let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
5187
5188 write!(output, " - {}", abs_path.display()).ok();
5189 if !visible {
5190 write!(output, " (hidden)").ok();
5191 }
5192 if let Some(branch) = &branch {
5193 write!(output, " [branch: {branch}]").ok();
5194 }
5195 if is_linked {
5196 if let Some(original) = original_repo_path {
5197 write!(output, " [linked worktree -> {}]", original.display()).ok();
5198 } else {
5199 write!(output, " [linked worktree]").ok();
5200 }
5201 }
5202 writeln!(output).ok();
5203 }
5204
5205 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5206 let panel = panel.read(cx);
5207
5208 let panel_workspace_id = panel.workspace_id();
5209 if panel_workspace_id != workspace_db_id {
5210 writeln!(
5211 output,
5212 " \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
5213 )
5214 .ok();
5215 }
5216
5217 if let Some(thread) = panel.active_agent_thread(cx) {
5218 let thread = thread.read(cx);
5219 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5220 let session_id = thread.session_id();
5221 let status = match thread.status() {
5222 ThreadStatus::Idle => "idle",
5223 ThreadStatus::Generating => "generating",
5224 };
5225 let entry_count = thread.entries().len();
5226 write!(output, "Active thread: {title} (session: {session_id})").ok();
5227 write!(output, " [{status}, {entry_count} entries").ok();
5228 if panel
5229 .active_conversation_view()
5230 .is_some_and(|conversation_view| {
5231 conversation_view
5232 .read(cx)
5233 .root_thread_has_pending_tool_call(cx)
5234 })
5235 {
5236 write!(output, ", awaiting confirmation").ok();
5237 }
5238 writeln!(output, "]").ok();
5239 } else {
5240 writeln!(output, "Active thread: (none)").ok();
5241 }
5242
5243 let background_threads = panel.retained_threads();
5244 if !background_threads.is_empty() {
5245 writeln!(
5246 output,
5247 "Background threads ({}): ",
5248 background_threads.len()
5249 )
5250 .ok();
5251 for (session_id, conversation_view) in background_threads {
5252 if let Some(thread_view) = conversation_view.read(cx).root_thread_view() {
5253 let thread = thread_view.read(cx).thread.read(cx);
5254 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5255 let status = match thread.status() {
5256 ThreadStatus::Idle => "idle",
5257 ThreadStatus::Generating => "generating",
5258 };
5259 let entry_count = thread.entries().len();
5260 write!(output, " - {title} (thread: {session_id:?})").ok();
5261 write!(output, " [{status}, {entry_count} entries").ok();
5262 if conversation_view
5263 .read(cx)
5264 .root_thread_has_pending_tool_call(cx)
5265 {
5266 write!(output, ", awaiting confirmation").ok();
5267 }
5268 writeln!(output, "]").ok();
5269 } else {
5270 writeln!(output, " - (not connected) (thread: {session_id:?})").ok();
5271 }
5272 }
5273 }
5274 } else {
5275 writeln!(output, "Agent panel: not loaded").ok();
5276 }
5277
5278 writeln!(output).ok();
5279}