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