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