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