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