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