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