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