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